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 table = safeTableName(tableName || "", "bom");
const sql = ` const sql = `
SELECT b.*, 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 FROM ${table} b
LEFT JOIN item_info i ON b.item_id = i.id LEFT JOIN item_info i ON b.item_id = i.id
WHERE b.id = $1 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)에만 사용 // originalData: changedData 계산(PATCH)에만 사용
// INSERT/UPDATE 판단에는 사용하지 않음 // INSERT/UPDATE 판단에는 사용하지 않음
setOriginalData(isCreateMode ? {} : editData || {}); 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 = () => { const renderLabel = () => {
if (hideLabel) return null; if (hideLabel) return null;
const labelStyle = widget.style || {}; const ls = widget.style || {};
const labelElement = ( const labelElement = (
<label <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={{ style={{
fontSize: labelStyle.labelFontSize || "14px", fontSize: ls.labelFontSize || "14px",
color: hasError ? "hsl(var(--destructive))" : labelStyle.labelColor || undefined, color: hasError ? "hsl(var(--destructive))" : ls.labelColor || undefined,
fontWeight: labelStyle.labelFontWeight || "500", fontWeight: ls.labelFontWeight || "500",
fontFamily: labelStyle.labelFontFamily, fontFamily: ls.labelFontFamily,
textAlign: labelStyle.labelTextAlign || "left", textAlign: ls.labelTextAlign || "left",
backgroundColor: labelStyle.labelBackgroundColor, backgroundColor: ls.labelBackgroundColor,
padding: labelStyle.labelPadding, padding: ls.labelPadding,
borderRadius: labelStyle.labelBorderRadius, borderRadius: ls.labelBorderRadius,
marginBottom: labelStyle.labelMarginBottom || "8px", ...(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} {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 ( return (
<div key={comp.id} className="space-y-2"> <div key={comp.id}>
{renderLabel()} <div style={{ display: "flex", flexDirection: labelPos === "left" ? "row" : "row-reverse", alignItems: "center", gap: widget.style?.labelGap || "8px" }}>
{renderByWebType()} {labelElement}
{renderFieldValidation()} <div style={{ flex: 1, minWidth: 0 }}>{widgetElement}</div>
</div>
{validationElement}
</div>
);
}
return (
<div key={comp.id}>
{labelPos === "top" && labelElement}
{widgetElement}
{labelPos === "bottom" && labelElement}
{validationElement}
</div> </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", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#212121", color: component.style?.labelColor || "#212121",
fontWeight: component.style?.labelFontWeight || "500", fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent", backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0", padding: component.style?.labelPadding || "0",
borderRadius: component.style?.labelBorderRadius || "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 /> <TableOptionsToolbar />
{/* 메인 컨텐츠 */} {/* 메인 컨텐츠 - 라벨 위치에 따라 flex 방향 변경 */}
<div className="h-full flex-1" style={{ width: '100%' }}> <div
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */} className="h-full flex-1"
{shouldShowLabel && ( style={{
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> 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} {labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>} {(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label> </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>
</div> </div>

View File

@ -1078,17 +1078,21 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// TableSearchWidget의 경우 높이를 자동으로 설정 // TableSearchWidget의 경우 높이를 자동으로 설정
const isTableSearchWidget = (component as any).componentId === "table-search-widget"; const isTableSearchWidget = (component as any).componentId === "table-search-widget";
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트) // 라벨 표시 여부 확인 (V2 입력 컴포넌트)
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시 const compType = (component as any).componentType || "";
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date"; const isV2InputComponent =
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
const hasVisibleLabel = isV2InputComponent && const hasVisibleLabel = isV2InputComponent &&
style?.labelDisplay !== false && style?.labelDisplay !== false &&
(style?.labelText || (component as any).label); (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 labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; 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 calculateCanvasSplitX = (): { x: number; w: number } => {
const compType = (component as any).componentType || ""; const compType = (component as any).componentType || "";
@ -1238,10 +1242,56 @@ 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이 아닌 경우: 외부에서 라벨을 렌더링하고 내부 라벨은 숨김
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 ( return (
<> <>
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}> <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> </div>
{/* 팝업 화면 렌더링 */} {/* 팝업 화면 렌더링 */}

View File

@ -839,6 +839,44 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
className="h-6 w-full px-2 py-0 text-xs" 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>
<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="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
@ -860,12 +898,21 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
<Input <Select
value={selectedComponent.style?.labelMarginBottom || "4px"} value={selectedComponent.style?.labelFontWeight || "500"}
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)} onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
className="h-6 w-full px-2 py-0 text-xs" >
/> <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>
<div className="flex items-center space-x-2 pt-5"> <div className="flex items-center space-x-2 pt-5">
<Checkbox <Checkbox

View File

@ -466,10 +466,56 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; 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 labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; 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 ( return (
<div <div
@ -481,27 +527,8 @@ export const V2Date = forwardRef<HTMLDivElement, V2DateProps>((props, ref) => {
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} {labelElement}
{showLabel && ( {dateContent}
<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>
</div> </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 actualLabel = label || style?.labelText;
const showLabel = actualLabel && style?.labelDisplay === true; const showLabel = actualLabel && style?.labelDisplay === true;
// size에서 우선 가져오고, 없으면 style에서 가져옴
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; 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 labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; 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 hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor; const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius; const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (내부 input/textarea에 직접 전달)
const customTextStyle: React.CSSProperties = {}; const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color; if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize; if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight; if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0; const hasCustomText = Object.keys(customTextStyle).length > 0;
// 내부 input에 직접 적용할 텍스트 스타일 (fontSize, color, fontWeight, textAlign)
const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined; 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 ( return (
<div <div
ref={ref} ref={ref}
@ -1001,38 +1048,8 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 (높이에 포함되지 않음) */} {labelElement}
{showLabel && ( {inputContent}
<Label
htmlFor={id}
style={{
position: "absolute",
top: `-${estimatedLabelHeight}px`,
left: 0,
fontSize: style?.labelFontSize || "14px",
color: style?.labelColor || "#64748b",
fontWeight: style?.labelFontWeight || "500",
}}
className="text-sm font-medium whitespace-nowrap"
>
{actualLabel}
{required && <span className="ml-0.5 text-orange-500">*</span>}
</Label>
)}
<div
className={cn(
"h-full w-full",
// 커스텀 테두리 설정 시, 내부 input/textarea의 기본 테두리 제거 (외부 래퍼 스타일이 보이도록)
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거 (외부 래퍼가 처리)
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
// 커스텀 배경 설정 시, 내부 input을 투명하게 (외부 배경이 보이도록)
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
)}
style={hasCustomText ? customTextStyle : undefined}
>
{renderInput()}
</div>
</div> </div>
); );
}); });

View File

@ -1041,17 +1041,19 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
const componentWidth = size?.width || style?.width; const componentWidth = size?.width || style?.width;
const componentHeight = size?.height || style?.height; 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 labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4; 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에서 설정한 테두리/배경/텍스트 스타일) // 커스텀 스타일 감지
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border); const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
const hasCustomBackground = !!style?.backgroundColor; const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius; const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (CSS 상속)
const customTextStyle: React.CSSProperties = {}; const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color; if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize; 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"]; if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0; 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 ( return (
<div <div
ref={ref} ref={ref}
@ -1069,38 +1123,8 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
height: componentHeight, height: componentHeight,
}} }}
> >
{/* 🔧 라벨을 absolute로 컴포넌트 위에 배치 */} {labelElement}
{showLabel && ( {selectContent}
<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>
</div> </div>
); );
} }

View File

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

View File

@ -291,6 +291,7 @@ export function BomTreeComponent({
item_name: raw.item_name || "", item_name: raw.item_name || "",
item_code: raw.item_number || raw.item_code || "", item_code: raw.item_number || raw.item_code || "",
item_type: raw.item_type || raw.division || "", item_type: raw.item_type || raw.division || "",
unit: raw.unit || raw.item_unit || "",
} as BomHeaderInfo; } as BomHeaderInfo;
} }
} catch (e) { } catch (e) {
@ -376,6 +377,18 @@ export function BomTreeComponent({
detail.editData[key] = (headerInfo as any)[key]; 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)보다 반드시 먼저 실행 // capture: true → EditModal 리스너(bubble)보다 반드시 먼저 실행
window.addEventListener("openEditModal", handler, true); window.addEventListener("openEditModal", handler, true);

View File

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