refactor: Improve label toggling functionality in ScreenDesigner and enhance SelectedItemsDetailInputComponent

- Updated the label toggling logic in ScreenDesigner to allow toggling of labels for selected components or all components based on the current selection.
- Enhanced the SelectedItemsDetailInputComponent by implementing a caching mechanism for table columns and refining the logic for loading category options based on field groups.
- Introduced a new helper function to convert category codes to labels, improving the clarity and maintainability of the price calculation logic.
- Added support for determining the source table for field groups, facilitating better data management and retrieval.
This commit is contained in:
DDD1542 2026-02-09 10:07:07 +09:00
parent 79d8f0b160
commit bb4d90fd58
4 changed files with 252 additions and 99 deletions

View File

@ -1871,17 +1871,33 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
[groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory] [groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory]
); );
// 라벨 일괄 토글 // 라벨 일괄 토글 (선택된 컴포넌트가 있으면 선택된 것만, 없으면 전체)
const handleToggleAllLabels = useCallback(() => { const handleToggleAllLabels = useCallback(() => {
saveToHistory(layout); saveToHistory(layout);
const newComponents = toggleAllLabels(layout.components);
const selectedIds = groupState.selectedComponents;
const isPartial = selectedIds.length > 0;
// 토글 대상 컴포넌트 필터
const targetComponents = layout.components.filter((c) => {
if (!c.label || ["group", "datatable"].includes(c.type)) return false;
if (isPartial) return selectedIds.includes(c.id);
return true;
});
const hadHidden = targetComponents.some(
(c) => (c.style as any)?.labelDisplay === false
);
const newComponents = toggleAllLabels(layout.components, selectedIds);
setLayout((prev) => ({ ...prev, components: newComponents })); setLayout((prev) => ({ ...prev, components: newComponents }));
const hasHidden = layout.components.some( // 강제 리렌더링 트리거
(c) => c.type === "widget" && (c.style as any)?.labelDisplay === false setForceRenderTrigger((prev) => prev + 1);
);
toast.success(hasHidden ? "모든 라벨 표시" : "모든 라벨 숨기기"); const scope = isPartial ? `선택된 ${targetComponents.length}` : "모든";
}, [layout, saveToHistory]); toast.success(hadHidden ? `${scope} 라벨 표시` : `${scope} 라벨 숨기기`);
}, [layout, saveToHistory, groupState.selectedComponents]);
// Nudge (화살표 키 이동) // Nudge (화살표 키 이동)
const handleNudge = useCallback( const handleNudge = useCallback(

View File

@ -146,59 +146,69 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...codeOptions }; const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...codeOptions };
// 🆕 대상 테이블의 컬럼 메타데이터에서 codeCategory 가져오기 // 🆕 그룹별 sourceTable 매핑 구성
const targetTable = componentConfig.targetTable; const groups = componentConfig.fieldGroups || [];
let targetTableColumns: any[] = []; const groupSourceTableMap: Record<string, string> = {};
groups.forEach((g) => {
if (g.sourceTable) {
groupSourceTableMap[g.id] = g.sourceTable;
}
});
const defaultTargetTable = componentConfig.targetTable;
if (targetTable) { // 테이블별 컬럼 메타데이터 캐시
const tableColumnsCache: Record<string, any[]> = {};
const getTableColumns = async (tableName: string) => {
if (tableColumnsCache[tableName]) return tableColumnsCache[tableName];
try { try {
const { tableTypeApi } = await import("@/lib/api/screen"); const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(targetTable); const columnsResponse = await tableTypeApi.getColumns(tableName);
targetTableColumns = columnsResponse || []; tableColumnsCache[tableName] = columnsResponse || [];
return tableColumnsCache[tableName];
} catch (error) { } catch (error) {
console.error("❌ 대상 테이블 컬럼 조회 실패:", error); console.error(`❌ 테이블 컬럼 조회 실패 (${tableName}):`, error);
return [];
} }
} };
for (const field of codeFields) { for (const field of codeFields) {
// 이미 로드된 옵션이면 스킵
if (newOptions[field.name]) { if (newOptions[field.name]) {
console.log(`⏭️ 이미 로드된 옵션 (${field.name})`); console.log(`⏭️ 이미 로드된 옵션 (${field.name})`);
continue; continue;
} }
// 🆕 필드의 그룹 sourceTable 결정
const fieldSourceTable = (field.groupId && groupSourceTableMap[field.groupId]) || defaultTargetTable;
try { try {
// 🆕 category 타입이면 table_column_category_values에서 로드 if (field.inputType === "category" && fieldSourceTable) {
if (field.inputType === "category" && targetTable) { console.log(`🔄 카테고리 옵션 로드 (${fieldSourceTable}.${field.name})`);
console.log(`🔄 카테고리 옵션 로드 시도 (${targetTable}.${field.name})`);
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue"); const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const response = await getCategoryValues(targetTable, field.name, false); const response = await getCategoryValues(fieldSourceTable, field.name, false);
console.log("📥 getCategoryValues 응답:", response); if (response.success && response.data && response.data.length > 0) {
if (response.success && response.data) {
newOptions[field.name] = response.data.map((item: any) => ({ newOptions[field.name] = response.data.map((item: any) => ({
label: item.value_label || item.valueLabel, label: item.value_label || item.valueLabel,
value: item.value_code || item.valueCode, value: item.value_code || item.valueCode,
})); }));
console.log(`✅ 카테고리 옵션 로드 완료 (${field.name}):`, newOptions[field.name]); console.log(`✅ 카테고리 옵션 로드 완료 (${fieldSourceTable}.${field.name}):`, newOptions[field.name]);
} else { } else {
console.error(`❌ 카테고리 옵션 로드 실패 (${field.name}):`, response.error || "응답 없음"); console.warn(`⚠️ 카테고리 옵션 없음 (${fieldSourceTable}.${field.name})`);
} }
} else if (field.inputType === "code") { } else if (field.inputType === "code") {
// code 타입이면 기존대로 code_info에서 로드
// 이미 codeCategory가 있으면 사용
let codeCategory = field.codeCategory; let codeCategory = field.codeCategory;
// 🆕 codeCategory가 없으면 대상 테이블 컬럼에서 찾기 if (!codeCategory && fieldSourceTable) {
if (!codeCategory && targetTableColumns.length > 0) { const targetTableColumns = await getTableColumns(fieldSourceTable);
const columnMeta = targetTableColumns.find( if (targetTableColumns.length > 0) {
(col: any) => (col.columnName || col.column_name) === field.name, const columnMeta = targetTableColumns.find(
); (col: any) => (col.columnName || col.column_name) === field.name,
if (columnMeta) { );
codeCategory = columnMeta.codeCategory || columnMeta.code_category; if (columnMeta) {
console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory); codeCategory = columnMeta.codeCategory || columnMeta.code_category;
console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory);
}
} }
} }
@ -784,13 +794,22 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onClick?.(); onClick?.();
}; };
// 🆕 실시간 단가 계산 함수 (설정 기반 + 카테고리 매핑) // 🆕 카테고리 코드 → 라벨명 변환 헬퍼
const getOptionLabel = useCallback(
(fieldName: string, valueCode: string): string => {
const options = codeOptions[fieldName] || [];
const matched = options.find((opt) => opt.value === valueCode);
return matched?.label || valueCode || "";
},
[codeOptions],
);
// 🆕 실시간 단가 계산 함수 (라벨명 기반 - 회사별 코드 무관)
const calculatePrice = useCallback( const calculatePrice = useCallback(
(entry: GroupEntry): number => { (entry: GroupEntry): number => {
// 자동 계산 설정이 없으면 계산하지 않음
if (!componentConfig.autoCalculation) return 0; if (!componentConfig.autoCalculation) return 0;
const { inputFields, valueMapping } = componentConfig.autoCalculation; const { inputFields } = componentConfig.autoCalculation;
// 기본 단가 // 기본 단가
const basePrice = parseFloat(entry[inputFields.basePrice] || "0"); const basePrice = parseFloat(entry[inputFields.basePrice] || "0");
@ -798,38 +817,46 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
let price = basePrice; let price = basePrice;
// 1단계: 할인 적용 // 1단계: 할인 적용 (라벨명으로 판단)
const discountTypeValue = entry[inputFields.discountType]; const discountTypeCode = entry[inputFields.discountType];
const discountTypeLabel = getOptionLabel("discount_type", discountTypeCode);
const discountValue = parseFloat(entry[inputFields.discountValue] || "0"); const discountValue = parseFloat(entry[inputFields.discountValue] || "0");
// 매핑을 통해 실제 연산 타입 결정 if (discountTypeLabel.includes("할인율") || discountTypeLabel.includes("%")) {
const discountOperation = valueMapping?.discountType?.[discountTypeValue] || "none"; // 할인율(%)
if (discountOperation === "rate") {
price = price * (1 - discountValue / 100); price = price * (1 - discountValue / 100);
} else if (discountOperation === "amount") { } else if (discountTypeLabel.includes("할인금액") || discountTypeLabel.includes("금액")) {
// 할인금액
price = price - discountValue; price = price - discountValue;
} }
// "할인없음"이면 그대로
// 2단계: 반올림 적용 // 2단계: 반올림 적용
const roundingTypeValue = entry[inputFields.roundingType]; // rounding_type = 단위 (10원, 100원, 1000원)
const roundingUnitValue = entry[inputFields.roundingUnit]; // rounding_unit_value = 방법 (반올림, 절삭, 올림, 반올림없음)
const roundingTypeCode = entry[inputFields.roundingType];
const roundingTypeLabel = getOptionLabel("rounding_type", roundingTypeCode);
// 매핑을 통해 실제 연산 타입 결정 const roundingUnitCode = entry[inputFields.roundingUnit];
const roundingOperation = valueMapping?.roundingType?.[roundingTypeValue] || "none"; const roundingUnitLabel = getOptionLabel("rounding_unit_value", roundingUnitCode);
const unit = valueMapping?.roundingUnit?.[roundingUnitValue] || parseFloat(roundingUnitValue) || 1;
if (roundingOperation === "round") { // roundingType 라벨에서 단위 숫자 추출 (예: "10원" → 10, "1000원" → 1000)
const unitMatch = roundingTypeLabel.match(/(\d+)/);
const unit = unitMatch ? parseInt(unitMatch[1]) : parseFloat(roundingTypeCode) || 1;
// roundingUnit 라벨로 반올림 방법 결정
if (roundingUnitLabel.includes("반올림") && !roundingUnitLabel.includes("없음")) {
price = Math.round(price / unit) * unit; price = Math.round(price / unit) * unit;
} else if (roundingOperation === "floor") { } else if (roundingUnitLabel.includes("절삭")) {
price = Math.floor(price / unit) * unit; price = Math.floor(price / unit) * unit;
} else if (roundingOperation === "ceil") { } else if (roundingUnitLabel.includes("올림")) {
price = Math.ceil(price / unit) * unit; price = Math.ceil(price / unit) * unit;
} }
// "반올림없음"이면 그대로
return price; return price;
}, },
[componentConfig.autoCalculation], [componentConfig.autoCalculation, getOptionLabel],
); );
// 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName // 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName
@ -851,7 +878,23 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const existingEntryIndex = groupEntries.findIndex((e) => e.id === entryId); const existingEntryIndex = groupEntries.findIndex((e) => e.id === entryId);
if (existingEntryIndex >= 0) { if (existingEntryIndex >= 0) {
// 기존 entry 업데이트 (항상 이 경로로만 진입) const currentEntry = groupEntries[existingEntryIndex];
// 날짜 검증: 종료일이 시작일보다 앞서면 차단
if (fieldName === "end_date" && value && currentEntry.start_date) {
if (new Date(value) < new Date(currentEntry.start_date as string)) {
alert("종료일은 시작일보다 이후여야 합니다.");
return item; // 변경 취소
}
}
if (fieldName === "start_date" && value && currentEntry.end_date) {
if (new Date(value) > new Date(currentEntry.end_date as string)) {
alert("시작일은 종료일보다 이전이어야 합니다.");
return item; // 변경 취소
}
}
// 기존 entry 업데이트
const updatedEntries = [...groupEntries]; const updatedEntries = [...groupEntries];
const updatedEntry = { const updatedEntry = {
...updatedEntries[existingEntryIndex], ...updatedEntries[existingEntryIndex],
@ -1099,16 +1142,19 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
case "integer": case "integer":
case "bigint": case "bigint":
case "decimal": case "decimal":
case "numeric": case "numeric": {
// 🆕 계산된 단가는 천 단위 구분 및 강조 표시 // 숫자 포맷팅 헬퍼: 콤마 표시 + 실제 값은 숫자만 저장
if (isCalculatedField) { const rawNum = value ? String(value).replace(/,/g, "") : "";
const numericValue = parseFloat(value) || 0; const displayNum = rawNum && !isNaN(Number(rawNum))
const formattedValue = new Intl.NumberFormat("ko-KR").format(numericValue); ? new Intl.NumberFormat("ko-KR").format(Number(rawNum))
: rawNum;
// 계산된 단가는 읽기 전용 + 강조 표시
if (isCalculatedField) {
return ( return (
<div className="relative"> <div className="relative">
<Input <Input
value={formattedValue} value={displayNum}
readOnly readOnly
disabled disabled
className={cn( className={cn(
@ -1124,14 +1170,20 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
return ( return (
<Input <Input
{...commonProps} value={displayNum}
type="number" placeholder={field.placeholder}
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} disabled={componentConfig.disabled || componentConfig.readonly}
min={field.validation?.min} type="text"
max={field.validation?.max} inputMode="numeric"
onChange={(e) => {
// 콤마 제거 후 숫자만 저장
const cleaned = e.target.value.replace(/,/g, "").replace(/[^0-9.\-]/g, "");
handleFieldChange(itemId, groupId, entryId, field.name, cleaned);
}}
className="h-7 text-xs" className="h-7 text-xs"
/> />
); );
}
case "date": case "date":
case "timestamp": case "timestamp":
@ -1194,7 +1246,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)} onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)}
disabled={componentConfig.disabled || componentConfig.readonly} disabled={componentConfig.disabled || componentConfig.readonly}
> >
<SelectTrigger size="default" className="h-7 w-full text-xs"> <SelectTrigger className="h-7 w-full text-xs">
<SelectValue placeholder={field.placeholder || "선택하세요"} /> <SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1220,7 +1272,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
{...commonProps} {...commonProps}
type="text" type="text"
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm" className="h-7 text-xs"
/> />
); );
@ -1231,7 +1283,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)} onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)}
disabled={componentConfig.disabled || componentConfig.readonly} disabled={componentConfig.disabled || componentConfig.readonly}
> >
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm"> <SelectTrigger className="h-7 w-full text-xs">
<SelectValue placeholder={field.placeholder || "선택하세요"} /> <SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1254,7 +1306,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
type="text" type="text"
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
maxLength={field.validation?.maxLength} maxLength={field.validation?.maxLength}
className="h-8 text-xs sm:h-10 sm:text-sm" className="h-7 text-xs"
/> />
); );
} }
@ -1295,42 +1347,91 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const displayItems = group?.displayItems || []; const displayItems = group?.displayItems || [];
if (displayItems.length === 0) { if (displayItems.length === 0) {
// displayItems가 없으면 기본 방식 (해당 그룹의 visible 필드만 나열)
const fields = (componentConfig.additionalFields || []).filter((f) => { const fields = (componentConfig.additionalFields || []).filter((f) => {
// 그룹 필터
const matchGroup = componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0 const matchGroup = componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0
? f.groupId === groupId ? f.groupId === groupId
: true; : true;
// hidden 필드 제외 (width: "0px"인 필드)
const isVisible = f.width !== "0px"; const isVisible = f.width !== "0px";
return matchGroup && isVisible; return matchGroup && isVisible;
}); });
// 값이 있는 필드만 "라벨: 값" 형식으로 표시 // 헬퍼: 값을 사람이 읽기 좋은 형태로 변환
const displayParts = fields const formatValue = (f: any, value: any): string => {
.map((f) => { if (!value && value !== 0) return "";
const value = entry[f.name]; const strValue = String(value);
if (!value && value !== 0) return null;
const strValue = String(value); // 날짜 포맷
const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/);
if (isoDateMatch) {
const [, year, month, day] = isoDateMatch;
return `${year}.${month}.${day}`;
}
// ISO 날짜 형식 자동 포맷팅 // 카테고리/코드 -> 라벨명
const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/); const renderType = f.inputType || f.type;
if (isoDateMatch) { if (renderType === "category" || renderType === "code" || renderType === "select") {
const [, year, month, day] = isoDateMatch; const options = codeOptions[f.name] || f.options || [];
return `${f.label}: ${year}.${month}.${day}`; const matched = options.find((opt: any) => opt.value === strValue);
} if (matched) return matched.label;
}
return `${f.label}: ${strValue}`; // 숫자는 천 단위 구분
}) if (renderType === "number" && !isNaN(Number(strValue))) {
.filter(Boolean); return new Intl.NumberFormat("ko-KR").format(Number(strValue));
}
// 빈 항목 표시: 그룹 필드명으로 안내 메시지 생성 return strValue;
if (displayParts.length === 0) { };
// 간결한 요약 생성 (그룹별 핵심 정보만)
const hasAnyValue = fields.some((f) => {
const v = entry[f.name];
return v !== undefined && v !== null && v !== "";
});
if (!hasAnyValue) {
const fieldLabels = fields.slice(0, 2).map(f => f.label).join("/"); const fieldLabels = fields.slice(0, 2).map(f => f.label).join("/");
return `신규 ${fieldLabels} 입력`; return `신규 ${fieldLabels} 입력`;
} }
return displayParts.join(" ");
// 날짜 범위가 있으면 우선 표시
const startDate = entry["start_date"] ? formatValue({ inputType: "date" }, entry["start_date"]) : "";
const endDate = entry["end_date"] ? formatValue({ inputType: "date" }, entry["end_date"]) : "";
// 기준단가(calculated_price) 또는 기준가(base_price) 표시
const calcPrice = entry["calculated_price"] ? formatValue({ inputType: "number" }, entry["calculated_price"]) : "";
const basePrice = entry["base_price"] ? formatValue({ inputType: "number" }, entry["base_price"]) : "";
// 통화코드
const currencyCode = entry["currency_code"] ? formatValue(
fields.find(f => f.name === "currency_code") || { inputType: "category", name: "currency_code" },
entry["currency_code"]
) : "";
if (startDate || calcPrice || basePrice) {
// 날짜 + 단가 간결 표시
const parts: string[] = [];
if (startDate) {
parts.push(endDate ? `${startDate} ~ ${endDate}` : `${startDate} ~`);
}
if (calcPrice) {
parts.push(`${currencyCode || ""} ${calcPrice}`.trim());
} else if (basePrice) {
parts.push(`${currencyCode || ""} ${basePrice}`.trim());
}
return parts.join(" | ");
}
// 그 외 그룹 (거래처 품번 등): 첫 2개 필드만 표시
const summaryParts = fields
.slice(0, 3)
.map((f) => {
const value = entry[f.name];
if (!value && value !== 0) return null;
return `${f.label}: ${formatValue(f, value)}`;
})
.filter(Boolean);
return summaryParts.join(" ");
} }
// displayItems 설정대로 렌더링 // displayItems 설정대로 렌더링
@ -1499,7 +1600,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
</> </>
); );
}, },
[componentConfig.fieldGroups, componentConfig.additionalFields], [componentConfig.fieldGroups, componentConfig.additionalFields, codeOptions],
); );
// 빈 상태 렌더링 // 빈 상태 렌더링

View File

@ -56,6 +56,8 @@ export interface FieldGroup {
order?: number; order?: number;
/** 🆕 최대 항목 수 (1이면 1:1 관계 - 자동 생성, + 추가 버튼 숨김) */ /** 🆕 최대 항목 수 (1이면 1:1 관계 - 자동 생성, + 추가 버튼 숨김) */
maxEntries?: number; maxEntries?: number;
/** 🆕 이 그룹의 소스 테이블 (카테고리 옵션 로드 시 사용) */
sourceTable?: string;
/** 🆕 이 그룹의 항목 표시 설정 (그룹별로 다른 표시 형식 가능) */ /** 🆕 이 그룹의 항목 표시 설정 (그룹별로 다른 표시 형식 가능) */
displayItems?: DisplayItem[]; displayItems?: DisplayItem[];
} }

View File

@ -214,19 +214,53 @@ export function matchComponentSize(
* / . * / .
* , * ,
*/ */
export function toggleAllLabels(components: ComponentData[], forceShow?: boolean): ComponentData[] { /**
// 현재 라벨이 숨겨진(labelDisplay === false) 컴포넌트가 있는지 확인 *
const hasHiddenLabel = components.some( * label , style.labelDisplay를
(c) => c.type === "widget" && (c.style as any)?.labelDisplay === false */
function hasLabelSupport(component: ComponentData): boolean {
// 라벨이 없는 컴포넌트는 제외
if (!component.label) return false;
// 그룹, datatable 등은 라벨 토글 대상에서 제외
const excludedTypes = ["group", "datatable"];
if (excludedTypes.includes(component.type)) return false;
// 나머지 (widget, component, container, file, flow 등)는 대상
return true;
}
/**
* @param components -
* @param selectedIds - ID ( )
* @param forceShow - / ( )
*/
export function toggleAllLabels(
components: ComponentData[],
selectedIds: string[] = [],
forceShow?: boolean
): ComponentData[] {
// 대상 컴포넌트 필터: selectedIds가 있으면 선택된 것만, 없으면 전체
const targetComponents = components.filter((c) => {
if (!hasLabelSupport(c)) return false;
if (selectedIds.length > 0) return selectedIds.includes(c.id);
return true;
});
// 대상 중 라벨이 숨겨진 컴포넌트가 있는지 확인
const hasHiddenLabel = targetComponents.some(
(c) => (c.style as any)?.labelDisplay === false
); );
// forceShow가 지정되면 그 값 사용, 아니면 자동 판단 // forceShow가 지정되면 그 값 사용, 아니면 자동 판단
// 숨겨진 라벨이 있으면 모두 표시, 아니면 모두 숨기기
const shouldShow = forceShow !== undefined ? forceShow : hasHiddenLabel; const shouldShow = forceShow !== undefined ? forceShow : hasHiddenLabel;
// 대상 ID Set (빠른 조회용)
const targetIdSet = new Set(targetComponents.map((c) => c.id));
return components.map((c) => { return components.map((c) => {
// 위젯 타입만 라벨 토글 대상 // 대상이 아닌 컴포넌트는 건드리지 않음
if (c.type !== "widget") return c; if (!targetIdSet.has(c.id)) return c;
return { return {
...c, ...c,