; Please enter a commit message to explain why this merge is necessary,
; especially if it merges an updated upstream into a topic branch.
;
; Lines starting with ';' will be ignored, and an empty message aborts
; the commit.
This commit is contained in:
leeheejin
2026-01-16 10:19:05 +09:00
18 changed files with 1714 additions and 3099 deletions

View File

@@ -27,7 +27,6 @@ import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
import { applyMappingRules } from "@/lib/utils/dataMapping";
import { apiClient } from "@/lib/api/client";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
config?: ButtonPrimaryConfig;
@@ -108,7 +107,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const screenContext = useScreenContextOptional(); // 화면 컨텍스트
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
const { getTranslatedText } = useScreenMultiLang(); // 다국어 컨텍스트
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
const splitPanelPosition = screenContext?.splitPanelPosition;
@@ -301,20 +299,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
const [modalStoreData, setModalStoreData] = useState<Record<string, any[]>>({});
// 🆕 splitPanelContext?.selectedLeftData를 로컬 상태로 추적 (리렌더링 보장)
const [trackedSelectedLeftData, setTrackedSelectedLeftData] = useState<Record<string, any> | null>(null);
// splitPanelContext?.selectedLeftData 변경 감지 및 로컬 상태 동기화
useEffect(() => {
const newData = splitPanelContext?.selectedLeftData ?? null;
setTrackedSelectedLeftData(newData);
// console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", {
// label: component.label,
// hasData: !!newData,
// dataKeys: newData ? Object.keys(newData) : [],
// });
}, [splitPanelContext?.selectedLeftData, component.label]);
// modalDataStore 상태 구독 (실시간 업데이트)
useEffect(() => {
const actionConfig = component.componentConfig?.action;
@@ -373,8 +357,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 2. 분할 패널 좌측 선택 데이터 확인
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
// SplitPanelContext에서 확인 (trackedSelectedLeftData 사용으로 리렌더링 보장)
if (trackedSelectedLeftData && Object.keys(trackedSelectedLeftData).length > 0) {
// SplitPanelContext에서 확인
if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) {
if (!hasSelection) {
hasSelection = true;
selectionCount = 1;
@@ -413,7 +397,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
selectionCount,
selectionSource,
hasSplitPanelContext: !!splitPanelContext,
trackedSelectedLeftData: trackedSelectedLeftData,
selectedLeftData: splitPanelContext?.selectedLeftData,
selectedRowsData: selectedRowsData?.length,
selectedRows: selectedRows?.length,
flowSelectedData: flowSelectedData?.length,
@@ -445,7 +429,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
component.label,
selectedRows,
selectedRowsData,
trackedSelectedLeftData,
splitPanelContext?.selectedLeftData,
flowSelectedData,
splitPanelContext,
modalStoreData,
@@ -737,99 +721,61 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
return;
}
if (!screenContext) {
toast.error("화면 컨텍스트를 찾을 수 없습니다.");
return;
}
try {
let sourceData: any[] = [];
// 1. 소스 컴포넌트에서 데이터 가져오기
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
// 1. ScreenContext에서 DataProvider를 통해 데이터 가져오기 시도
if (screenContext) {
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
if (!sourceProvider) {
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
const allProviders = screenContext.getAllDataProviders();
// 테이블 리스트 우선 탐색
for (const [id, provider] of allProviders) {
if (provider.componentType === "table-list") {
sourceProvider = provider;
console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`);
break;
}
}
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
if (!sourceProvider && allProviders.size > 0) {
const firstEntry = allProviders.entries().next().value;
if (firstEntry) {
sourceProvider = firstEntry[1];
console.log(
`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`,
);
}
}
// 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
if (!sourceProvider) {
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
const allProviders = screenContext.getAllDataProviders();
console.log(`📋 [ButtonPrimary] 등록된 DataProvider 목록:`, Array.from(allProviders.keys()));
// 테이블 리스트 우선 탐색
for (const [id, provider] of allProviders) {
if (provider.componentType === "table-list") {
sourceProvider = provider;
console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`);
break;
}
}
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
if (!sourceProvider && allProviders.size > 0) {
const firstEntry = allProviders.entries().next().value;
if (firstEntry) {
sourceProvider = firstEntry[1];
console.log(
`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`,
);
}
}
}
if (sourceProvider) {
const rawSourceData = sourceProvider.getSelectedData();
sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : [];
console.log("📦 [ButtonPrimary] ScreenContext에서 소스 데이터 획득:", {
rawSourceData,
sourceData,
count: sourceData.length
});
}
} else {
console.log("⚠️ [ButtonPrimary] ScreenContext가 없습니다. modalDataStore에서 데이터를 찾습니다.");
}
// 2. ScreenContext에서 데이터를 찾지 못한 경우, modalDataStore에서 fallback 조회
if (sourceData.length === 0) {
console.log("🔍 [ButtonPrimary] modalDataStore에서 데이터 탐색 시도...");
try {
const { useModalDataStore } = await import("@/stores/modalDataStore");
const dataRegistry = useModalDataStore.getState().dataRegistry;
console.log("📋 [ButtonPrimary] modalDataStore 전체 키:", Object.keys(dataRegistry));
// sourceTableName이 지정되어 있으면 해당 테이블에서 조회
const sourceTableName = dataTransferConfig.sourceTableName || tableName;
if (sourceTableName && dataRegistry[sourceTableName]) {
const modalData = dataRegistry[sourceTableName];
sourceData = modalData.map((item: any) => item.originalData || item);
console.log(`✅ [ButtonPrimary] modalDataStore에서 데이터 발견 (${sourceTableName}):`, sourceData.length, "건");
} else {
// 테이블명으로 못 찾으면 첫 번째 데이터 사용
const firstKey = Object.keys(dataRegistry)[0];
if (firstKey && dataRegistry[firstKey]?.length > 0) {
const modalData = dataRegistry[firstKey];
sourceData = modalData.map((item: any) => item.originalData || item);
console.log(`✅ [ButtonPrimary] modalDataStore 첫 번째 키에서 데이터 발견 (${firstKey}):`, sourceData.length, "건");
}
}
} catch (err) {
console.warn("⚠️ [ButtonPrimary] modalDataStore 접근 실패:", err);
toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다.");
return;
}
}
// 3. 여전히 데이터가 없으면 에러
const rawSourceData = sourceProvider.getSelectedData();
// 🆕 배열이 아닌 경우 배열로 변환
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : [];
console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) });
if (!sourceData || sourceData.length === 0) {
console.error("❌ [ButtonPrimary] 선택된 데이터를 찾을 수 없습니다.", {
hasScreenContext: !!screenContext,
sourceComponentId: dataTransferConfig.sourceComponentId,
sourceTableName: dataTransferConfig.sourceTableName || tableName,
});
toast.warning("선택된 데이터가 없습니다. 항목을 먼저 선택해주세요.");
toast.warning("선택된 데이터 없습니다.");
return;
}
console.log("📦 [ButtonPrimary] 최종 소스 데이터:", { sourceData, count: sourceData.length });
// 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값)
let additionalData: Record<string, any> = {};
@@ -1360,10 +1306,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
...userStyle,
};
// 다국어 적용: componentConfig.langKey가 있으면 번역 텍스트 사용
const langKey = (component as any).componentConfig?.langKey;
const originalButtonText = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
const buttonContent = getTranslatedText(langKey, originalButtonText);
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
return (
<>

View File

@@ -180,8 +180,11 @@ export function ModalRepeaterTableComponent({
filterCondition: propFilterCondition,
companyCode: propCompanyCode,
// 🆕 그룹 데이터 (EditModal에서 전달, 같은 그룹의 여러 품목)
groupedData,
...props
}: ModalRepeaterTableComponentProps) {
}: ModalRepeaterTableComponentProps & { groupedData?: Record<string, any>[] }) {
// ✅ config 또는 component.config 또는 개별 prop 우선순위로 병합
const componentConfig = {
...config,
@@ -208,9 +211,16 @@ export function ModalRepeaterTableComponent({
// 모달 필터 설정
const modalFilters = componentConfig?.modalFilters || [];
// ✅ value는 formData[columnName] 우선, 없으면 prop 사용
// ✅ value는 groupedData 우선, 없으면 formData[columnName], 없으면 prop 사용
const columnName = component?.columnName;
const externalValue = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// 🆕 groupedData가 전달되면 (EditModal에서 그룹 조회 결과) 우선 사용
const externalValue = (() => {
if (groupedData && groupedData.length > 0) {
return groupedData;
}
return (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
})();
// 빈 객체 판단 함수 (수정 모달의 실제 데이터는 유지)
const isEmptyRow = (item: any): boolean => {

View File

@@ -6,7 +6,6 @@ import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache";
import { getCategoryLabelsByCodes } from "@/lib/api/tableCategoryValue";
import { useEntityJoinOptimization } from "@/lib/hooks/useEntityJoinOptimization";
import { getFullImageUrl } from "@/lib/api/client";
import { Button } from "@/components/ui/button";
@@ -67,7 +66,6 @@ import { useAuth } from "@/hooks/useAuth";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
// ========================================
// 인터페이스
@@ -244,11 +242,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
parentTabsComponentId,
companyCode,
}) => {
// ========================================
// 다국어 번역 훅
// ========================================
const { getTranslatedText } = useScreenMultiLang();
// ========================================
// 설정 및 스타일
// ========================================
@@ -481,7 +474,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
// 2. 헤더 필터 적용 (joinColumnMapping 사용 안 함 - 직접 컬럼명 사용)
// 🆕 다중 값 지원: 셀 값이 "A,B,C" 형태일 때, 필터에서 "A"를 선택하면 해당 행도 표시
if (Object.keys(headerFilters).length > 0) {
result = result.filter((row) => {
return Object.entries(headerFilters).every(([columnName, values]) => {
@@ -491,16 +483,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : "";
// 정확히 일치하는 경우
if (values.has(cellStr)) return true;
// 다중 값인 경우: 콤마로 분리해서 하나라도 포함되면 true
if (cellStr.includes(",")) {
const cellValues = cellStr.split(",").map(v => v.trim());
return cellValues.some(v => values.has(v));
}
return false;
return values.has(cellStr);
});
});
}
@@ -871,55 +854,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
// 화면 컨텍스트에 데이터 제공자/수신자로 등록
// 🔧 dataProvider와 dataReceiver를 의존성에 포함하지 않고,
// 대신 data와 selectedRows가 변경될 때마다 재등록하여 최신 클로저 참조
useEffect(() => {
if (screenContext && component.id) {
// 🔧 매번 새로운 dataProvider를 등록하여 최신 selectedRows 참조
const currentDataProvider: DataProvidable = {
componentId: component.id,
componentType: "table-list",
getSelectedData: () => {
const selectedData = filteredData.filter((row) => {
const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || "");
return selectedRows.has(rowId);
});
console.log("📊 [TableList] getSelectedData 호출:", {
componentId: component.id,
selectedRowsSize: selectedRows.size,
filteredDataLength: filteredData.length,
resultLength: selectedData.length,
});
return selectedData;
},
getAllData: () => filteredData,
clearSelection: () => {
setSelectedRows(new Set());
setIsAllSelected(false);
},
};
const currentDataReceiver: DataReceivable = {
componentId: component.id,
componentType: "table",
receiveData: dataReceiver.receiveData,
getData: () => data,
};
screenContext.registerDataProvider(component.id, currentDataProvider);
screenContext.registerDataReceiver(component.id, currentDataReceiver);
console.log("✅ [TableList] ScreenContext에 등록:", {
componentId: component.id,
selectedRowsSize: selectedRows.size,
});
screenContext.registerDataProvider(component.id, dataProvider);
screenContext.registerDataReceiver(component.id, dataReceiver);
return () => {
screenContext.unregisterDataProvider(component.id);
screenContext.unregisterDataReceiver(component.id);
};
}
}, [screenContext, component.id, data, selectedRows, filteredData, tableConfig.selectedTable]);
}, [screenContext, component.id, data, selectedRows]);
// 분할 패널 컨텍스트에 데이터 수신자로 등록
// useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
@@ -1086,16 +1031,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
// 틀고정 컬럼 관련
frozenColumnCount, // 현재 틀고정 컬럼 수
onFrozenColumnCountChange: (count: number, updatedColumns?: Array<{ columnName: string; visible: boolean }>) => {
onFrozenColumnCountChange: (count: number) => {
setFrozenColumnCount(count);
// 체크박스 컬럼은 항상 틀고정에 포함
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
// 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정
// updatedColumns가 전달되면 그것을 사용, 아니면 columnsToRegister 사용
const colsToUse = updatedColumns || columnsToRegister;
const visibleCols = colsToUse
const visibleCols = columnsToRegister
.filter((col) => col.visible !== false)
.map((col) => col.columnName || (col as any).field);
.map((col) => col.columnName || col.field);
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)];
setFrozenColumns(newFrozenColumns);
},
@@ -2106,7 +2049,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return row.id || row.uuid || `row-${index}`;
};
const handleRowSelection = (rowKey: string, checked: boolean, rowData?: any) => {
const handleRowSelection = (rowKey: string, checked: boolean) => {
const newSelectedRows = new Set(selectedRows);
if (checked) {
newSelectedRows.add(rowKey);
@@ -2149,31 +2092,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장/해제 (체크박스 선택 시에도 작동)
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (checked && selectedRowsData.length > 0) {
// 선택된 경우: 첫 번째 선택된 데이터 저장 (또는 전달된 rowData)
const dataToStore = rowData || selectedRowsData[selectedRowsData.length - 1];
splitPanelContext.setSelectedLeftData(dataToStore);
console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 저장:", {
rowKey,
dataToStore,
});
} else if (!checked && selectedRowsData.length === 0) {
// 모든 선택이 해제된 경우: 데이터 초기화
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 초기화");
} else if (selectedRowsData.length > 0) {
// 일부 선택 해제된 경우: 남은 첫 번째 데이터로 업데이트
splitPanelContext.setSelectedLeftData(selectedRowsData[0]);
console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 업데이트:", {
remainingCount: selectedRowsData.length,
firstData: selectedRowsData[0],
});
}
}
const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
setIsAllSelected(allRowsSelected && filteredData.length > 0);
};
@@ -2243,8 +2161,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const rowKey = getRowKey(row, index);
const isCurrentlySelected = selectedRows.has(rowKey);
// handleRowSelection에서 분할 패널 데이터 처리도 함께 수행됨
handleRowSelection(rowKey, !isCurrentlySelected, row);
handleRowSelection(rowKey, !isCurrentlySelected);
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
// currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음)
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", {
splitPanelPosition,
currentSplitPosition,
effectiveSplitPosition,
hasSplitPanelContext: !!splitPanelContext,
disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer,
});
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (!isCurrentlySelected) {
// 선택된 경우: 데이터 저장
splitPanelContext.setSelectedLeftData(row);
console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", {
row,
parentDataMapping: splitPanelContext.parentDataMapping,
});
} else {
// 선택 해제된 경우: 데이터 초기화
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화");
}
}
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
};
@@ -2311,176 +2256,30 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 편집 모드 진입 placeholder (실제 구현은 visibleColumns 정의 후)
const startEditingRef = useRef<() => void>(() => {});
// 🆕 카테고리 라벨 매핑 (API에서 가져온 것)
const [categoryLabelCache, setCategoryLabelCache] = useState<Record<string, string>>({});
// 🆕 각 컬럼의 고유값 목록 계산 (라벨 포함)
// 🆕 각 컬럼의 고유값 목록 계산
const columnUniqueValues = useMemo(() => {
const result: Record<string, Array<{ value: string; label: string }>> = {};
const result: Record<string, string[]> = {};
if (data.length === 0) return result;
// 🆕 전체 데이터에서 개별 값 -> 라벨 매핑 테이블 구축 (다중 값 처리용)
const globalLabelMap: Record<string, Map<string, string>> = {};
(tableConfig.columns || []).forEach((column: { columnName: string }) => {
if (column.columnName === "__checkbox__") return;
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
// 라벨 컬럼 후보들 (백엔드에서 _name, _label, _value_label 등으로 반환할 수 있음)
const labelColumnCandidates = [
`${column.columnName}_name`, // 예: division_name
`${column.columnName}_label`, // 예: division_label
`${column.columnName}_value_label`, // 예: division_value_label
];
const valuesMap = new Map<string, string>(); // value -> label
const singleValueLabelMap = new Map<string, string>(); // 개별 값 -> 라벨 (다중값 처리용)
const values = new Set<string>();
// 1차: 모든 데이터에서 개별 값 -> 라벨 매핑 수집 (단일값 + 다중값 모두)
data.forEach((row) => {
const val = row[mappedColumnName];
if (val !== null && val !== undefined && val !== "") {
const valueStr = String(val);
// 라벨 컬럼에서 라벨 찾기
let labelStr = "";
for (const labelCol of labelColumnCandidates) {
if (row[labelCol] && row[labelCol] !== "") {
labelStr = String(row[labelCol]);
break;
}
}
// 단일 값인 경우
if (!valueStr.includes(",")) {
if (labelStr) {
singleValueLabelMap.set(valueStr, labelStr);
}
} else {
// 다중 값인 경우: 값과 라벨을 각각 분리해서 매핑
const individualValues = valueStr.split(",").map(v => v.trim());
const individualLabels = labelStr ? labelStr.split(",").map(l => l.trim()) : [];
// 값과 라벨 개수가 같으면 1:1 매핑
if (individualValues.length === individualLabels.length) {
individualValues.forEach((v, idx) => {
if (individualLabels[idx] && !singleValueLabelMap.has(v)) {
singleValueLabelMap.set(v, individualLabels[idx]);
}
});
}
}
values.add(String(val));
}
});
// 2차: 모든 값 처리 (다중 값 포함) - 필터 목록용
data.forEach((row) => {
const val = row[mappedColumnName];
if (val !== null && val !== undefined && val !== "") {
const valueStr = String(val);
// 콤마로 구분된 다중 값인지 확인
if (valueStr.includes(",")) {
// 다중 값: 각각 분리해서 개별 라벨 찾기
const individualValues = valueStr.split(",").map(v => v.trim());
// 🆕 singleValueLabelMap → categoryLabelCache 순으로 라벨 찾기
const individualLabels = individualValues.map(v =>
singleValueLabelMap.get(v) || categoryLabelCache[v] || v
);
valuesMap.set(valueStr, individualLabels.join(", "));
} else {
// 단일 값: 매핑에서 찾거나 캐시에서 찾거나 원본 사용
const label = singleValueLabelMap.get(valueStr) || categoryLabelCache[valueStr] || valueStr;
valuesMap.set(valueStr, label);
}
}
});
globalLabelMap[column.columnName] = singleValueLabelMap;
// value-label 쌍으로 저장하고 라벨 기준 정렬
result[column.columnName] = Array.from(valuesMap.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label));
result[column.columnName] = Array.from(values).sort();
});
return result;
}, [data, tableConfig.columns, joinColumnMapping, categoryLabelCache]);
// 🆕 라벨을 못 찾은 CATEGORY_ 코드들을 API로 조회
useEffect(() => {
const unlabeledCodes = new Set<string>();
// columnUniqueValues에서 라벨이 코드 그대로인 항목 찾기
Object.values(columnUniqueValues).forEach(items => {
items.forEach(item => {
// 라벨에 CATEGORY_가 포함되어 있으면 라벨을 못 찾은 것
if (item.label.includes("CATEGORY_")) {
// 콤마로 분리해서 개별 코드 추출
const codes = item.label.split(",").map(c => c.trim());
codes.forEach(code => {
if (code.startsWith("CATEGORY_") && !categoryLabelCache[code]) {
unlabeledCodes.add(code);
}
});
}
});
});
if (unlabeledCodes.size === 0) return;
// API로 라벨 조회
const fetchLabels = async () => {
try {
const response = await getCategoryLabelsByCodes(Array.from(unlabeledCodes));
if (response.success && response.data) {
setCategoryLabelCache(prev => ({ ...prev, ...response.data }));
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
};
fetchLabels();
}, [columnUniqueValues, categoryLabelCache]);
// 🆕 데이터에서 CATEGORY_ 코드를 찾아 라벨 미리 로드 (테이블 셀 렌더링용)
useEffect(() => {
if (data.length === 0) return;
const categoryCodesToFetch = new Set<string>();
// 모든 데이터 행에서 CATEGORY_ 코드 수집
data.forEach((row) => {
Object.entries(row).forEach(([key, value]) => {
if (value && typeof value === "string") {
// 콤마로 구분된 다중 값도 처리
const codes = value.split(",").map((v) => v.trim());
codes.forEach((code) => {
if (code.startsWith("CATEGORY_") && !categoryLabelCache[code]) {
categoryCodesToFetch.add(code);
}
});
}
});
});
if (categoryCodesToFetch.size === 0) return;
// API로 라벨 조회
const fetchLabels = async () => {
try {
const response = await getCategoryLabelsByCodes(Array.from(categoryCodesToFetch));
if (response.success && response.data && Object.keys(response.data).length > 0) {
setCategoryLabelCache((prev) => ({ ...prev, ...response.data }));
}
} catch (error) {
console.error("CATEGORY_ 라벨 조회 실패:", error);
}
};
fetchLabels();
}, [data, categoryLabelCache]);
}, [data, tableConfig.columns, joinColumnMapping]);
// 🆕 헤더 필터 토글
const toggleHeaderFilter = useCallback((columnName: string, value: string) => {
@@ -4125,7 +3924,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (enterRow) {
const rowKey = getRowKey(enterRow, rowIndex);
const isCurrentlySelected = selectedRows.has(rowKey);
handleRowSelection(rowKey, !isCurrentlySelected, enterRow);
handleRowSelection(rowKey, !isCurrentlySelected);
}
break;
case " ": // Space
@@ -4135,7 +3934,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (spaceRow) {
const currentRowKey = getRowKey(spaceRow, rowIndex);
const isChecked = selectedRows.has(currentRowKey);
handleRowSelection(currentRowKey, !isChecked, spaceRow);
handleRowSelection(currentRowKey, !isChecked);
}
break;
case "F2":
@@ -4349,7 +4148,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return (
<Checkbox
checked={isChecked}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean, row)}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
aria-label={`${index + 1} 선택`}
/>
);
@@ -4638,36 +4437,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
case "boolean":
return value ? "예" : "아니오";
default:
// 🆕 CATEGORY_ 코드 자동 변환 (inputType이 category가 아니어도)
const strValue = String(value);
if (strValue.startsWith("CATEGORY_")) {
// rowData에서 _label 필드 찾기
if (rowData) {
const labelFieldCandidates = [
`${column.columnName}_label`,
`${column.columnName}_name`,
`${column.columnName}_value_label`,
];
for (const labelField of labelFieldCandidates) {
if (rowData[labelField] && rowData[labelField] !== "") {
return String(rowData[labelField]);
}
}
}
// categoryMappings에서 찾기
const mapping = categoryMappings[column.columnName];
if (mapping && mapping[strValue]) {
return mapping[strValue].label;
}
// categoryLabelCache에서 찾기 (필터용 캐시)
if (categoryLabelCache[strValue]) {
return categoryLabelCache[strValue];
}
}
return strValue;
return String(value);
}
},
[columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings, categoryLabelCache],
[columnMeta, joinedColumnMeta, optimizedConvertCode, categoryMappings],
);
// ========================================
@@ -4806,22 +4579,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
setColumnWidths(newWidths);
// 틀고정 컬럼 업데이트 (보이는 컬럼 기준으로 처음 N개를 틀고정)
// 기존 frozen 개수를 유지하면서, 숨겨진 컬럼을 제외한 보이는 컬럼 중 처음 N개를 틀고정
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
const visibleCols = config.columns
.filter((col) => col.visible && col.columnName !== "__checkbox__")
.map((col) => col.columnName);
// 현재 설정된 frozen 컬럼 개수 (체크박스 제외)
const currentFrozenCount = config.columns.filter(
(col) => col.frozen && col.columnName !== "__checkbox__"
).length;
// 보이는 컬럼 중 처음 currentFrozenCount개를 틀고정으로 설정
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, currentFrozenCount)];
// 틀고정 컬럼 업데이트
const newFrozenColumns = config.columns.filter((col) => col.frozen).map((col) => col.columnName);
setFrozenColumns(newFrozenColumns);
setFrozenColumnCount(currentFrozenCount);
// 그리드선 표시 업데이트
setShowGridLines(config.showGridLines);
@@ -5865,10 +5625,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
rowSpan={2}
className="border-primary/10 border-r px-2 py-1 text-center text-xs font-semibold sm:px-4 sm:text-sm"
>
{/* langKey가 있으면 다국어 번역 사용 */}
{(column as any).langKey
? getTranslatedText((column as any).langKey, columnLabels[column.columnName] || column.columnName)
: columnLabels[column.columnName] || column.columnName}
{columnLabels[column.columnName] || column.columnName}
</th>
);
}
@@ -5887,18 +5644,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{visibleColumns.map((column, columnIndex) => {
const columnWidth = columnWidths[column.columnName];
const isFrozen = frozenColumns.includes(column.columnName);
// 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
// 숨겨진 컬럼은 제외하고 보이는 틀고정 컬럼만 포함
const visibleFrozenColumns = visibleColumns
.filter(col => frozenColumns.includes(col.columnName))
.map(col => col.columnName);
const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName);
// 틀고정 컬럼의 left 위치 계산
let leftPosition = 0;
if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) {
const frozenCol = visibleFrozenColumns[i];
const frozenCol = frozenColumns[i];
// 체크박스 컬럼은 48px 고정
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth;
@@ -5964,12 +5716,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<Lock className="text-muted-foreground h-3 w-3" />
</span>
)}
<span>
{/* langKey가 있으면 다국어 번역 사용 */}
{(column as any).langKey
? getTranslatedText((column as any).langKey, columnLabels[column.columnName] || column.displayName || column.columnName)
: columnLabels[column.columnName] || column.displayName}
</span>
<span>{columnLabels[column.columnName] || column.displayName}</span>
{column.sortable !== false && sortColumn === column.columnName && (
<span>{sortDirection === "asc" ? "↑" : "↓"}</span>
)}
@@ -6017,16 +5764,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)}
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{columnUniqueValues[column.columnName]?.slice(0, 50).map((item) => {
const isSelected = headerFilters[column.columnName]?.has(item.value);
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
const isSelected = headerFilters[column.columnName]?.has(val);
return (
<div
key={item.value}
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => toggleHeaderFilter(column.columnName, item.value)}
onClick={() => toggleHeaderFilter(column.columnName, val)}
>
<div
className={cn(
@@ -6036,7 +5783,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{item.label || "(빈 값)"}</span>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
@@ -6209,17 +5956,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const isNumeric = inputType === "number" || inputType === "decimal";
const isFrozen = frozenColumns.includes(column.columnName);
// 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
const visibleFrozenColumns = visibleColumns
.filter(col => frozenColumns.includes(col.columnName))
.map(col => col.columnName);
const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0;
if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) {
const frozenCol = visibleFrozenColumns[i];
const frozenCol = frozenColumns[i];
// 체크박스 컬럼은 48px 고정
const frozenColWidth =
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
@@ -6366,12 +6109,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const isNumeric = inputType === "number" || inputType === "decimal";
const isFrozen = frozenColumns.includes(column.columnName);
// 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
const visibleFrozenColumns = visibleColumns
.filter(col => frozenColumns.includes(col.columnName))
.map(col => col.columnName);
const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName);
// 셀 포커스 상태
const isCellFocused = focusedCell?.rowIndex === index && focusedCell?.colIndex === colIndex;
@@ -6385,10 +6123,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 🆕 검색 하이라이트 여부
const isSearchHighlighted = searchHighlights.has(`${index}-${colIndex}`);
// 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0;
if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) {
const frozenCol = visibleFrozenColumns[i];
const frozenCol = frozenColumns[i];
// 체크박스 컬럼은 48px 고정
const frozenColWidth =
frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
@@ -6548,17 +6287,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const summary = summaryData[column.columnName];
const columnWidth = columnWidths[column.columnName];
const isFrozen = frozenColumns.includes(column.columnName);
// 틀고정된 컬럼의 left 위치 계산 (보이는 컬럼 기준으로 계산)
const visibleFrozenColumns = visibleColumns
.filter(col => frozenColumns.includes(col.columnName))
.map(col => col.columnName);
const frozenIndex = visibleFrozenColumns.indexOf(column.columnName);
const frozenIndex = frozenColumns.indexOf(column.columnName);
// 틀고정된 컬럼의 left 위치 계산
let leftPosition = 0;
if (isFrozen && frozenIndex > 0) {
for (let i = 0; i < frozenIndex; i++) {
const frozenCol = visibleFrozenColumns[i];
const frozenCol = frozenColumns[i];
// 체크박스 컬럼은 48px 고정
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : columnWidths[frozenCol] || 150;
leftPosition += frozenColWidth;