Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
@@ -892,6 +892,42 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
loadOptions();
|
||||
}, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]);
|
||||
|
||||
// 레거시 평문값 → 카테고리 코드 자동 정규화 (한글 텍스트로 저장된 데이터 대응)
|
||||
const resolvedValue = useMemo(() => {
|
||||
if (!value || options.length === 0) return value;
|
||||
|
||||
const resolveOne = (v: string): string => {
|
||||
if (options.some(o => o.value === v)) return v;
|
||||
const trimmed = v.trim();
|
||||
const match = options.find(o => {
|
||||
const cleanLabel = o.label.replace(/^[\s└]+/, '').trim();
|
||||
return cleanLabel === trimmed;
|
||||
});
|
||||
return match ? match.value : v;
|
||||
};
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const resolved = value.map(resolveOne);
|
||||
return resolved.every((v, i) => v === value[i]) ? value : resolved;
|
||||
}
|
||||
|
||||
// 콤마 구분 복합값 처리 (e.g., "구매품,판매품,CAT_xxx")
|
||||
if (typeof value === "string" && value.includes(",")) {
|
||||
const parts = value.split(",");
|
||||
const resolved = parts.map(p => resolveOne(p.trim()));
|
||||
const result = resolved.join(",");
|
||||
return result === value ? value : result;
|
||||
}
|
||||
|
||||
return resolveOne(value);
|
||||
}, [value, options]);
|
||||
|
||||
// 정규화 결과가 원본과 다르면 onChange로 자동 업데이트 (저장 시 코드 변환)
|
||||
useEffect(() => {
|
||||
if (!onChange || options.length === 0 || !value || value === resolvedValue) return;
|
||||
onChange(resolvedValue as string | string[]);
|
||||
}, [resolvedValue]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지
|
||||
const autoFillTargets = useMemo(() => {
|
||||
if (source !== "entity" || !entityTable || !allComponents) return [];
|
||||
@@ -1017,7 +1053,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
return (
|
||||
<DropdownSelect
|
||||
options={options}
|
||||
value={value}
|
||||
value={resolvedValue}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
placeholder="선택"
|
||||
searchable={config.mode === "combobox" ? true : config.searchable}
|
||||
@@ -1033,7 +1069,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
return (
|
||||
<RadioSelect
|
||||
options={options}
|
||||
value={typeof value === "string" ? value : value?.[0]}
|
||||
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
|
||||
onChange={(v) => handleChangeWithAutoFill(v)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
@@ -1044,7 +1080,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
return (
|
||||
<CheckSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
@@ -1055,7 +1091,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
return (
|
||||
<TagSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
@@ -1066,7 +1102,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
return (
|
||||
<TagboxSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
placeholder={config.placeholder || "선택하세요"}
|
||||
maxSelect={config.maxSelect}
|
||||
@@ -1079,7 +1115,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
return (
|
||||
<ToggleSelect
|
||||
options={options}
|
||||
value={typeof value === "string" ? value : value?.[0]}
|
||||
value={typeof resolvedValue === "string" ? resolvedValue : resolvedValue?.[0]}
|
||||
onChange={(v) => handleChangeWithAutoFill(v)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
@@ -1089,7 +1125,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
return (
|
||||
<SwapSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
value={Array.isArray(resolvedValue) ? resolvedValue : resolvedValue ? [resolvedValue] : []}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
@@ -1100,7 +1136,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
return (
|
||||
<DropdownSelect
|
||||
options={options}
|
||||
value={value}
|
||||
value={resolvedValue}
|
||||
onChange={handleChangeWithAutoFill}
|
||||
disabled={isDisabled}
|
||||
style={heightStyle}
|
||||
@@ -1113,17 +1149,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;
|
||||
@@ -1131,6 +1169,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}
|
||||
@@ -1141,38 +1231,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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user