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:
@@ -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
|
||||
|
||||
@@ -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 || {});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 팝업 화면 렌더링 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user