From bb4d90fd58f5ab75fab7c575793867b7c6ef9c07 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 9 Feb 2026 10:07:07 +0900 Subject: [PATCH] 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. --- frontend/components/screen/ScreenDesigner.tsx | 30 +- .../SelectedItemsDetailInputComponent.tsx | 271 ++++++++++++------ .../selected-items-detail-input/types.ts | 2 + frontend/lib/utils/alignmentUtils.ts | 48 +++- 4 files changed, 252 insertions(+), 99 deletions(-) diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 429f91f8..9e724a3f 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -1871,17 +1871,33 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU [groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory] ); - // 라벨 일괄 토글 + // 라벨 일괄 토글 (선택된 컴포넌트가 있으면 선택된 것만, 없으면 전체) const handleToggleAllLabels = useCallback(() => { 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 })); - const hasHidden = layout.components.some( - (c) => c.type === "widget" && (c.style as any)?.labelDisplay === false - ); - toast.success(hasHidden ? "모든 라벨 표시" : "모든 라벨 숨기기"); - }, [layout, saveToHistory]); + // 강제 리렌더링 트리거 + setForceRenderTrigger((prev) => prev + 1); + + const scope = isPartial ? `선택된 ${targetComponents.length}개` : "모든"; + toast.success(hadHidden ? `${scope} 라벨 표시` : `${scope} 라벨 숨기기`); + }, [layout, saveToHistory, groupState.selectedComponents]); // Nudge (화살표 키 이동) const handleNudge = useCallback( diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index 023002c0..27feafe2 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -146,59 +146,69 @@ export const SelectedItemsDetailInputComponent: React.FC> = { ...codeOptions }; - // 🆕 대상 테이블의 컬럼 메타데이터에서 codeCategory 가져오기 - const targetTable = componentConfig.targetTable; - let targetTableColumns: any[] = []; + // 🆕 그룹별 sourceTable 매핑 구성 + const groups = componentConfig.fieldGroups || []; + const groupSourceTableMap: Record = {}; + groups.forEach((g) => { + if (g.sourceTable) { + groupSourceTableMap[g.id] = g.sourceTable; + } + }); + const defaultTargetTable = componentConfig.targetTable; - if (targetTable) { + // 테이블별 컬럼 메타데이터 캐시 + const tableColumnsCache: Record = {}; + const getTableColumns = async (tableName: string) => { + if (tableColumnsCache[tableName]) return tableColumnsCache[tableName]; try { const { tableTypeApi } = await import("@/lib/api/screen"); - const columnsResponse = await tableTypeApi.getColumns(targetTable); - targetTableColumns = columnsResponse || []; + const columnsResponse = await tableTypeApi.getColumns(tableName); + tableColumnsCache[tableName] = columnsResponse || []; + return tableColumnsCache[tableName]; } catch (error) { - console.error("❌ 대상 테이블 컬럼 조회 실패:", error); + console.error(`❌ 테이블 컬럼 조회 실패 (${tableName}):`, error); + return []; } - } + }; for (const field of codeFields) { - // 이미 로드된 옵션이면 스킵 if (newOptions[field.name]) { console.log(`⏭️ 이미 로드된 옵션 (${field.name})`); continue; } + // 🆕 필드의 그룹 sourceTable 결정 + const fieldSourceTable = (field.groupId && groupSourceTableMap[field.groupId]) || defaultTargetTable; + try { - // 🆕 category 타입이면 table_column_category_values에서 로드 - if (field.inputType === "category" && targetTable) { - console.log(`🔄 카테고리 옵션 로드 시도 (${targetTable}.${field.name})`); + if (field.inputType === "category" && fieldSourceTable) { + console.log(`🔄 카테고리 옵션 로드 (${fieldSourceTable}.${field.name})`); 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) { + if (response.success && response.data && response.data.length > 0) { newOptions[field.name] = response.data.map((item: any) => ({ label: item.value_label || item.valueLabel, value: item.value_code || item.valueCode, })); - console.log(`✅ 카테고리 옵션 로드 완료 (${field.name}):`, newOptions[field.name]); + console.log(`✅ 카테고리 옵션 로드 완료 (${fieldSourceTable}.${field.name}):`, newOptions[field.name]); } else { - console.error(`❌ 카테고리 옵션 로드 실패 (${field.name}):`, response.error || "응답 없음"); + console.warn(`⚠️ 카테고리 옵션 없음 (${fieldSourceTable}.${field.name})`); } } else if (field.inputType === "code") { - // code 타입이면 기존대로 code_info에서 로드 - // 이미 codeCategory가 있으면 사용 let codeCategory = field.codeCategory; - // 🆕 codeCategory가 없으면 대상 테이블 컬럼에서 찾기 - if (!codeCategory && targetTableColumns.length > 0) { - const columnMeta = targetTableColumns.find( - (col: any) => (col.columnName || col.column_name) === field.name, - ); - if (columnMeta) { - codeCategory = columnMeta.codeCategory || columnMeta.code_category; - console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory); + if (!codeCategory && fieldSourceTable) { + const targetTableColumns = await getTableColumns(fieldSourceTable); + if (targetTableColumns.length > 0) { + const columnMeta = targetTableColumns.find( + (col: any) => (col.columnName || col.column_name) === field.name, + ); + if (columnMeta) { + codeCategory = columnMeta.codeCategory || columnMeta.code_category; + console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory); + } } } @@ -784,13 +794,22 @@ export const SelectedItemsDetailInputComponent: React.FC { + const options = codeOptions[fieldName] || []; + const matched = options.find((opt) => opt.value === valueCode); + return matched?.label || valueCode || ""; + }, + [codeOptions], + ); + + // 🆕 실시간 단가 계산 함수 (라벨명 기반 - 회사별 코드 무관) const calculatePrice = useCallback( (entry: GroupEntry): number => { - // 자동 계산 설정이 없으면 계산하지 않음 if (!componentConfig.autoCalculation) return 0; - const { inputFields, valueMapping } = componentConfig.autoCalculation; + const { inputFields } = componentConfig.autoCalculation; // 기본 단가 const basePrice = parseFloat(entry[inputFields.basePrice] || "0"); @@ -798,38 +817,46 @@ export const SelectedItemsDetailInputComponent: React.FC e.id === entryId); 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 updatedEntry = { ...updatedEntries[existingEntryIndex], @@ -1099,16 +1142,19 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} - min={field.validation?.min} - max={field.validation?.max} + value={displayNum} + placeholder={field.placeholder} + disabled={componentConfig.disabled || componentConfig.readonly} + type="text" + 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" /> ); + } case "date": case "timestamp": @@ -1194,7 +1246,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, val)} disabled={componentConfig.disabled || componentConfig.readonly} > - + @@ -1220,7 +1272,7 @@ export const SelectedItemsDetailInputComponent: React.FC 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 handleFieldChange(itemId, groupId, entryId, field.name, val)} disabled={componentConfig.disabled || componentConfig.readonly} > - + @@ -1254,7 +1306,7 @@ export const SelectedItemsDetailInputComponent: React.FC handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)} 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 { - // 그룹 필터 const matchGroup = componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0 ? f.groupId === groupId : true; - // hidden 필드 제외 (width: "0px"인 필드) const isVisible = f.width !== "0px"; return matchGroup && isVisible; }); - // 값이 있는 필드만 "라벨: 값" 형식으로 표시 - const displayParts = fields - .map((f) => { - const value = entry[f.name]; - if (!value && value !== 0) return null; + // 헬퍼: 값을 사람이 읽기 좋은 형태로 변환 + const formatValue = (f: any, value: any): string => { + if (!value && value !== 0) return ""; + const strValue = String(value); - 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|$)/); - if (isoDateMatch) { - const [, year, month, day] = isoDateMatch; - return `${f.label}: ${year}.${month}.${day}`; - } + // 카테고리/코드 -> 라벨명 + const renderType = f.inputType || f.type; + if (renderType === "category" || renderType === "code" || renderType === "select") { + const options = codeOptions[f.name] || f.options || []; + const matched = options.find((opt: any) => opt.value === strValue); + if (matched) return matched.label; + } - return `${f.label}: ${strValue}`; - }) - .filter(Boolean); + // 숫자는 천 단위 구분 + if (renderType === "number" && !isNaN(Number(strValue))) { + return new Intl.NumberFormat("ko-KR").format(Number(strValue)); + } - // 빈 항목 표시: 그룹 필드명으로 안내 메시지 생성 - if (displayParts.length === 0) { + return strValue; + }; + + // 간결한 요약 생성 (그룹별 핵심 정보만) + 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("/"); 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 설정대로 렌더링 @@ -1499,7 +1600,7 @@ export const SelectedItemsDetailInputComponent: React.FC ); }, - [componentConfig.fieldGroups, componentConfig.additionalFields], + [componentConfig.fieldGroups, componentConfig.additionalFields, codeOptions], ); // 빈 상태 렌더링 diff --git a/frontend/lib/registry/components/selected-items-detail-input/types.ts b/frontend/lib/registry/components/selected-items-detail-input/types.ts index d1778074..a2d5de34 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/types.ts +++ b/frontend/lib/registry/components/selected-items-detail-input/types.ts @@ -56,6 +56,8 @@ export interface FieldGroup { order?: number; /** 🆕 최대 항목 수 (1이면 1:1 관계 - 자동 생성, + 추가 버튼 숨김) */ maxEntries?: number; + /** 🆕 이 그룹의 소스 테이블 (카테고리 옵션 로드 시 사용) */ + sourceTable?: string; /** 🆕 이 그룹의 항목 표시 설정 (그룹별로 다른 표시 형식 가능) */ displayItems?: DisplayItem[]; } diff --git a/frontend/lib/utils/alignmentUtils.ts b/frontend/lib/utils/alignmentUtils.ts index e2af866e..c914defc 100644 --- a/frontend/lib/utils/alignmentUtils.ts +++ b/frontend/lib/utils/alignmentUtils.ts @@ -214,19 +214,53 @@ export function matchComponentSize( * 모든 컴포넌트의 라벨 표시/숨기기를 토글합니다. * 숨겨진 라벨이 하나라도 있으면 모두 표시, 모두 표시되어 있으면 모두 숨기기 */ -export function toggleAllLabels(components: ComponentData[], forceShow?: boolean): ComponentData[] { - // 현재 라벨이 숨겨진(labelDisplay === false) 컴포넌트가 있는지 확인 - const hasHiddenLabel = components.some( - (c) => c.type === "widget" && (c.style as any)?.labelDisplay === false +/** + * 라벨 토글 대상 타입 판별 + * label 속성이 있고, style.labelDisplay를 지원하는 컴포넌트인지 확인 + */ +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가 지정되면 그 값 사용, 아니면 자동 판단 - // 숨겨진 라벨이 있으면 모두 표시, 아니면 모두 숨기기 const shouldShow = forceShow !== undefined ? forceShow : hasHiddenLabel; + // 대상 ID Set (빠른 조회용) + const targetIdSet = new Set(targetComponents.map((c) => c.id)); + return components.map((c) => { - // 위젯 타입만 라벨 토글 대상 - if (c.type !== "widget") return c; + // 대상이 아닌 컴포넌트는 건드리지 않음 + if (!targetIdSet.has(c.id)) return c; return { ...c,