feat: V2 레이아웃 동기화 및 컴포넌트 개선
- TableManagementService에서 V2 레이아웃 동기화 로직을 추가하여, 새로운 입력 타입에 따라 화면 레이아웃을 자동으로 업데이트하도록 개선하였습니다. - syncScreenLayoutsV2InputType 메서드를 통해 V2 레이아웃의 컴포넌트 source를 동기화하는 기능을 구현하였습니다. - EditModal에서 배열 데이터를 쉼표 구분 문자열로 변환하는 로직을 추가하여, 손상된 값을 필터링하고 데이터 저장 시 일관성을 높였습니다. - CategorySelectComponent에서 불필요한 스타일 및 높이 관련 props를 제거하여 코드 간결성을 개선하였습니다. - V2Select 및 관련 컴포넌트에서 height 스타일을 통일하여 사용자 경험을 향상시켰습니다.
This commit is contained in:
@@ -302,6 +302,127 @@ const TagSelect = forwardRef<HTMLDivElement, {
|
||||
});
|
||||
TagSelect.displayName = "TagSelect";
|
||||
|
||||
/**
|
||||
* 태그박스 선택 컴포넌트 (태그 형태 + 체크박스 드롭다운)
|
||||
* - 선택된 값들이 태그(Badge)로 표시됨
|
||||
* - 클릭하면 체크박스 목록이 드롭다운으로 열림
|
||||
*/
|
||||
const TagboxSelect = forwardRef<HTMLDivElement, {
|
||||
options: SelectOption[];
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
maxSelect?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}>(({ options, value = [], onChange, placeholder = "선택하세요", maxSelect, disabled, className, style }, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// 선택된 옵션들의 라벨 가져오기
|
||||
const selectedOptions = useMemo(() =>
|
||||
options.filter((o) => value.includes(o.value)),
|
||||
[options, value]
|
||||
);
|
||||
|
||||
// 체크박스 토글 핸들러
|
||||
const handleToggle = useCallback((optionValue: string) => {
|
||||
const isSelected = value.includes(optionValue);
|
||||
if (isSelected) {
|
||||
onChange?.(value.filter((v) => v !== optionValue));
|
||||
} else {
|
||||
if (maxSelect && value.length >= maxSelect) return;
|
||||
onChange?.([...value, optionValue]);
|
||||
}
|
||||
}, [value, maxSelect, onChange]);
|
||||
|
||||
// 태그 제거 핸들러
|
||||
const handleRemove = useCallback((e: React.MouseEvent, optionValue: string) => {
|
||||
e.stopPropagation();
|
||||
onChange?.(value.filter((v) => v !== optionValue));
|
||||
}, [value, onChange]);
|
||||
|
||||
// 🔧 높이 처리: style.height가 있으면 minHeight로 사용 (기본 40px 보장)
|
||||
const triggerStyle: React.CSSProperties = {
|
||||
minHeight: style?.height || 40,
|
||||
height: style?.height || "auto",
|
||||
maxWidth: "100%", // 🔧 부모 컨테이너를 넘지 않도록
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("w-full max-w-full overflow-hidden", className)} style={{ width: style?.width }}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full max-w-full flex-wrap items-center gap-1.5 rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer overflow-hidden",
|
||||
"hover:border-primary/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
disabled && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
style={triggerStyle}
|
||||
>
|
||||
{selectedOptions.length > 0 ? (
|
||||
<>
|
||||
{selectedOptions.map((option) => (
|
||||
<Badge
|
||||
key={option.value}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1 px-2 py-0.5"
|
||||
>
|
||||
{option.label}
|
||||
<X
|
||||
className="h-3 w-3 cursor-pointer hover:text-destructive"
|
||||
onClick={(e) => !disabled && handleRemove(e, option.value)}
|
||||
/>
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<div className="max-h-[300px] overflow-auto p-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = value.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-pointer",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "bg-accent/50"
|
||||
)}
|
||||
onClick={() => !disabled && handleToggle(option.value)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={disabled}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{options.length === 0 && (
|
||||
<div className="py-2 text-center text-sm text-muted-foreground">
|
||||
옵션이 없습니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TagboxSelect.displayName = "TagboxSelect";
|
||||
|
||||
/**
|
||||
* 토글 선택 컴포넌트 (Boolean용)
|
||||
*/
|
||||
@@ -461,6 +582,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
onChange,
|
||||
tableName,
|
||||
columnName,
|
||||
isDesignMode, // 🔧 디자인 모드 (클릭 방지)
|
||||
} = props;
|
||||
|
||||
// config가 없으면 기본값 사용
|
||||
@@ -605,13 +727,13 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
// 트리 구조를 평탄화하여 옵션으로 변환
|
||||
// value로 valueId를 사용하여 채번 규칙 매핑과 일치하도록 함
|
||||
// 🔧 value로 valueCode를 사용 (커스텀 테이블 저장/조회 호환)
|
||||
const flattenTree = (items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[], depth: number = 0): SelectOption[] => {
|
||||
const result: SelectOption[] = [];
|
||||
for (const item of items) {
|
||||
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
|
||||
result.push({
|
||||
value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치)
|
||||
value: item.valueCode, // 🔧 valueCode를 value로 사용
|
||||
label: prefix + item.valueLabel,
|
||||
});
|
||||
if (item.children && item.children.length > 0) {
|
||||
@@ -639,7 +761,6 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
}
|
||||
} else if (!isValidColumnName) {
|
||||
// columnName이 없거나 유효하지 않으면 빈 옵션
|
||||
console.warn("V2Select: 유효한 columnName이 없어 옵션을 로드하지 않습니다.", { tableName, columnName });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,6 +790,48 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
? { height: componentHeight }
|
||||
: undefined;
|
||||
|
||||
// 🔧 디자인 모드용: 옵션이 없고 dropdown/combobox가 아닌 모드일 때 source 정보 표시
|
||||
const nonDropdownModes = ["radio", "check", "checkbox", "tag", "tagbox", "toggle", "swap"];
|
||||
if (options.length === 0 && nonDropdownModes.includes(config.mode || "dropdown")) {
|
||||
// 데이터 소스 정보 기반 메시지 생성
|
||||
let sourceInfo = "";
|
||||
if (source === "static") {
|
||||
sourceInfo = "정적 옵션 설정 필요";
|
||||
} else if (source === "code") {
|
||||
sourceInfo = codeGroup ? `공통코드: ${codeGroup}` : "공통코드 설정 필요";
|
||||
} else if (source === "entity") {
|
||||
sourceInfo = entityTable ? `엔티티: ${entityTable}` : "엔티티 설정 필요";
|
||||
} else if (source === "category") {
|
||||
const catInfo = categoryTable || tableName || columnName;
|
||||
sourceInfo = catInfo ? `카테고리: ${catInfo}` : "카테고리 설정 필요";
|
||||
} else if (source === "db") {
|
||||
sourceInfo = table ? `테이블: ${table}` : "테이블 설정 필요";
|
||||
} else if (!source || source === "distinct") {
|
||||
// distinct 또는 미설정인 경우 - 컬럼명 기반으로 표시
|
||||
sourceInfo = columnName ? `컬럼: ${columnName}` : "데이터 소스 설정 필요";
|
||||
} else {
|
||||
sourceInfo = `소스: ${source}`;
|
||||
}
|
||||
|
||||
// 모드 이름 한글화
|
||||
const modeNames: Record<string, string> = {
|
||||
radio: "라디오",
|
||||
check: "체크박스",
|
||||
checkbox: "체크박스",
|
||||
tag: "태그",
|
||||
tagbox: "태그박스",
|
||||
toggle: "토글",
|
||||
swap: "스왑",
|
||||
};
|
||||
const modeName = modeNames[config.mode || ""] || config.mode;
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-xs text-muted-foreground border border-dashed rounded p-2">
|
||||
<span className="opacity-70">[{modeName}] {sourceInfo}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (config.mode) {
|
||||
case "dropdown":
|
||||
case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운
|
||||
@@ -720,6 +883,19 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
/>
|
||||
);
|
||||
|
||||
case "tagbox":
|
||||
return (
|
||||
<TagboxSelect
|
||||
options={options}
|
||||
value={Array.isArray(value) ? value : value ? [value] : []}
|
||||
onChange={onChange}
|
||||
placeholder={config.placeholder || "선택하세요"}
|
||||
maxSelect={config.maxSelect}
|
||||
disabled={isDisabled}
|
||||
style={heightStyle}
|
||||
/>
|
||||
);
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
<ToggleSelect
|
||||
@@ -758,16 +934,6 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
const componentWidth = size?.width || style?.width;
|
||||
const componentHeight = size?.height || style?.height;
|
||||
|
||||
// 🔍 디버깅: 높이값 확인 (warn으로 변경하여 캡처되도록)
|
||||
console.warn("🔍 [V2Select] 높이 디버깅:", {
|
||||
id,
|
||||
"size?.height": size?.height,
|
||||
"style?.height": style?.height,
|
||||
componentHeight,
|
||||
size,
|
||||
style,
|
||||
});
|
||||
|
||||
// 라벨 높이 계산 (기본 20px, 사용자 설정에 따라 조정)
|
||||
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
@@ -777,7 +943,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
||||
<div
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="relative"
|
||||
className={cn("relative", isDesignMode && "pointer-events-none")}
|
||||
style={{
|
||||
width: componentWidth,
|
||||
height: componentHeight,
|
||||
|
||||
Reference in New Issue
Block a user