diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 880c54fc..4d911c57 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -32,10 +32,32 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { const companyCode = req.user!.companyCode; // 검색 필드 파싱 - const fields = searchFields + const requestedFields = searchFields ? (searchFields as string).split(",").map((f) => f.trim()) : []; + // 🆕 테이블의 실제 컬럼 목록 조회 + const pool = getPool(); + const columnsResult = await pool.query( + `SELECT column_name FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1`, + [tableName] + ); + const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name)); + + // 🆕 존재하는 컬럼만 필터링 + const fields = requestedFields.filter((field) => { + if (existingColumns.has(field)) { + return true; + } else { + logger.warn(`엔티티 검색: 테이블 "${tableName}"에 컬럼 "${field}"이(가) 존재하지 않아 제외`); + return false; + } + }); + + const existingColumnsArray = Array.from(existingColumns); + logger.info(`엔티티 검색 필드 확인 - 테이블: ${tableName}, 요청필드: [${requestedFields.join(", ")}], 유효필드: [${fields.join(", ")}], 테이블컬럼(샘플): [${existingColumnsArray.slice(0, 10).join(", ")}]`); + // WHERE 조건 생성 const whereConditions: string[] = []; const params: any[] = []; @@ -43,32 +65,57 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { // 멀티테넌시 필터링 if (companyCode !== "*") { - whereConditions.push(`company_code = $${paramIndex}`); - params.push(companyCode); - paramIndex++; + // 🆕 company_code 컬럼이 있는 경우에만 필터링 + if (existingColumns.has("company_code")) { + whereConditions.push(`company_code = $${paramIndex}`); + params.push(companyCode); + paramIndex++; + } } // 검색 조건 - if (searchText && fields.length > 0) { - const searchConditions = fields.map((field) => { - const condition = `${field}::text ILIKE $${paramIndex}`; - paramIndex++; - return condition; - }); - whereConditions.push(`(${searchConditions.join(" OR ")})`); + if (searchText) { + // 유효한 검색 필드가 없으면 기본 텍스트 컬럼에서 검색 + let searchableFields = fields; + if (searchableFields.length === 0) { + // 기본 검색 컬럼: name, code, description 등 일반적인 컬럼명 + const defaultSearchColumns = [ + 'name', 'code', 'description', 'title', 'label', + 'item_name', 'item_code', 'item_number', + 'equipment_name', 'equipment_code', + 'inspection_item', 'consumable_name', // 소모품명 추가 + 'supplier_name', 'customer_name', 'product_name', + ]; + searchableFields = defaultSearchColumns.filter(col => existingColumns.has(col)); + + logger.info(`엔티티 검색: 기본 검색 필드 사용 - 테이블: ${tableName}, 검색필드: [${searchableFields.join(", ")}]`); + } + + if (searchableFields.length > 0) { + const searchConditions = searchableFields.map((field) => { + const condition = `${field}::text ILIKE $${paramIndex}`; + paramIndex++; + return condition; + }); + whereConditions.push(`(${searchConditions.join(" OR ")})`); - // 검색어 파라미터 추가 - fields.forEach(() => { - params.push(`%${searchText}%`); - }); + // 검색어 파라미터 추가 + searchableFields.forEach(() => { + params.push(`%${searchText}%`); + }); + } } - // 추가 필터 조건 + // 추가 필터 조건 (존재하는 컬럼만) const additionalFilter = JSON.parse(filterCondition as string); for (const [key, value] of Object.entries(additionalFilter)) { - whereConditions.push(`${key} = $${paramIndex}`); - params.push(value); - paramIndex++; + if (existingColumns.has(key)) { + whereConditions.push(`${key} = $${paramIndex}`); + params.push(value); + paramIndex++; + } else { + logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key }); + } } // 페이징 @@ -78,8 +125,7 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { ? `WHERE ${whereConditions.join(" AND ")}` : ""; - // 쿼리 실행 - const pool = getPool(); + // 쿼리 실행 (pool은 위에서 이미 선언됨) const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`; const dataQuery = ` SELECT * FROM ${tableName} ${whereClause} diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 0713c1c3..f5553572 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -131,7 +131,7 @@ export const ScreenModal: React.FC = ({ className }) => { // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenModal = (event: CustomEvent) => { - const { screenId, title, description, size, urlParams, editData } = event.detail; + const { screenId, title, description, size, urlParams, editData, splitPanelParentData } = event.detail; // 🆕 모달 열린 시간 기록 modalOpenedAtRef.current = Date.now(); @@ -155,7 +155,12 @@ export const ScreenModal: React.FC = ({ className }) => { setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) } else { // 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정 - const parentData = splitPanelContext?.getMappedParentData() || {}; + // 1순위: 이벤트로 전달된 splitPanelParentData (탭 안에서 열린 모달) + // 2순위: splitPanelContext에서 직접 가져온 데이터 (분할 패널 내에서 열린 모달) + const parentData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0 + ? splitPanelParentData + : (splitPanelContext?.getMappedParentData() || {}); + if (Object.keys(parentData).length > 0) { console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정:", parentData); setFormData(parentData); diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx index ce7030eb..3880fc54 100644 --- a/frontend/components/screen-embedding/EmbeddedScreen.tsx +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -91,6 +91,21 @@ export const EmbeddedScreen = forwardRef { + // 우측 화면인 경우에만 적용 + if (position !== "right" || !splitPanelContext) return; + + const mappedData = splitPanelContext.getMappedParentData(); + if (Object.keys(mappedData).length > 0) { + console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData); + setFormData((prev) => ({ + ...prev, + ...mappedData, + })); + } + }, [position, splitPanelContext, splitPanelContext?.selectedLeftData]); + // 선택 변경 이벤트 전파 useEffect(() => { onSelectionChanged?.(selectedRows); diff --git a/frontend/components/screen-embedding/ScreenSplitPanel.tsx b/frontend/components/screen-embedding/ScreenSplitPanel.tsx index 4eba4f9b..60b6bf24 100644 --- a/frontend/components/screen-embedding/ScreenSplitPanel.tsx +++ b/frontend/components/screen-embedding/ScreenSplitPanel.tsx @@ -127,6 +127,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp leftScreenId={config?.leftScreenId || null} rightScreenId={config?.rightScreenId || null} parentDataMapping={config?.parentDataMapping || []} + linkedFilters={config?.linkedFilters || []} >
{/* 좌측 패널 */} diff --git a/frontend/components/screen/InteractiveDataTable.tsx b/frontend/components/screen/InteractiveDataTable.tsx index 1119e698..88d11447 100644 --- a/frontend/components/screen/InteractiveDataTable.tsx +++ b/frontend/components/screen/InteractiveDataTable.tsx @@ -54,6 +54,7 @@ import { SaveModal } from "./SaveModal"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useTableOptions } from "@/contexts/TableOptionsContext"; import { TableFilter, ColumnVisibility } from "@/types/table-options"; +import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; // 파일 데이터 타입 정의 (AttachedFileInfo와 호환) interface FileInfo { @@ -105,6 +106,7 @@ export const InteractiveDataTable: React.FC = ({ const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { user } = useAuth(); // 사용자 정보 가져오기 const { registerTable, unregisterTable } = useTableOptions(); // Context 훅 + const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 const [data, setData] = useState[]>([]); const [loading, setLoading] = useState(false); @@ -575,12 +577,72 @@ export const InteractiveDataTable: React.FC = ({ setLoading(true); try { - console.log("🔍 데이터 조회 시작:", { tableName: component.tableName, page, pageSize }); + // 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때) + let linkedFilterValues: Record = {}; + let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부 + let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부 + + if (splitPanelContext) { + // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) + const linkedFiltersConfig = splitPanelContext.linkedFilters || []; + hasLinkedFiltersConfigured = linkedFiltersConfig.some( + (filter) => filter.targetColumn?.startsWith(component.tableName + ".") || + filter.targetColumn === component.tableName + ); + + // 좌측 데이터 선택 여부 확인 + hasSelectedLeftData = splitPanelContext.selectedLeftData && + Object.keys(splitPanelContext.selectedLeftData).length > 0; + + linkedFilterValues = splitPanelContext.getLinkedFilterValues(); + // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) + const tableSpecificFilters: Record = {}; + for (const [key, value] of Object.entries(linkedFilterValues)) { + // key가 "테이블명.컬럼명" 형식인 경우 + if (key.includes(".")) { + const [tableName, columnName] = key.split("."); + if (tableName === component.tableName) { + tableSpecificFilters[columnName] = value; + hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음 + } + } else { + // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 + tableSpecificFilters[key] = value; + } + } + linkedFilterValues = tableSpecificFilters; + } + + // 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 + // → 빈 데이터 표시 (모든 데이터를 보여주지 않음) + if (hasLinkedFiltersConfigured && !hasSelectedLeftData) { + console.log("⚠️ [InteractiveDataTable] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시"); + setData([]); + setTotal(0); + setTotalPages(0); + setCurrentPage(1); + setLoading(false); + return; + } + + // 검색 파라미터와 연결 필터 병합 + const mergedSearchParams = { + ...searchParams, + ...linkedFilterValues, + }; + + console.log("🔍 데이터 조회 시작:", { + tableName: component.tableName, + page, + pageSize, + linkedFilterValues, + mergedSearchParams, + }); const result = await tableTypeApi.getTableData(component.tableName, { page, size: pageSize, - search: searchParams, + search: mergedSearchParams, autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달 }); @@ -680,7 +742,7 @@ export const InteractiveDataTable: React.FC = ({ setLoading(false); } }, - [component.tableName, pageSize, component.autoFilter], // 🆕 autoFilter 추가 + [component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData], // 🆕 autoFilter, 연결필터 추가 ); // 현재 사용자 정보 로드 diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index c9535285..3c9d16f5 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -19,6 +19,7 @@ import { FlowButtonGroup } from "./widgets/FlowButtonGroup"; import { FlowVisibilityConfig } from "@/types/control-management"; import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; +import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; // 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록 import "@/lib/registry/components/ButtonRenderer"; @@ -78,6 +79,7 @@ export const InteractiveScreenViewerDynamic: React.FC { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { userName: authUserName, user: authUser } = useAuth(); + const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 // 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서) const userName = externalUserName || authUserName; @@ -116,8 +118,30 @@ export const InteractiveScreenViewerDynamic: React.FC>({}); - // formData 결정 (외부에서 전달받은 것이 있으면 우선 사용) - const formData = externalFormData || localFormData; + // 🆕 분할 패널에서 매핑된 부모 데이터 가져오기 + const splitPanelMappedData = React.useMemo(() => { + if (splitPanelContext) { + return splitPanelContext.getMappedParentData(); + } + return {}; + }, [splitPanelContext, splitPanelContext?.selectedLeftData]); + + // formData 결정 (외부에서 전달받은 것이 있으면 우선 사용, 분할 패널 데이터도 병합) + const formData = React.useMemo(() => { + const baseData = externalFormData || localFormData; + // 분할 패널 매핑 데이터가 있으면 병합 (기존 값이 없는 경우에만) + if (Object.keys(splitPanelMappedData).length > 0) { + const merged = { ...baseData }; + for (const [key, value] of Object.entries(splitPanelMappedData)) { + // 기존 값이 없거나 빈 값인 경우에만 매핑 데이터 적용 + if (merged[key] === undefined || merged[key] === null || merged[key] === "") { + merged[key] = value; + } + } + return merged; + } + return baseData; + }, [externalFormData, localFormData, splitPanelMappedData]); // formData 업데이트 함수 const updateFormData = useCallback( diff --git a/frontend/components/table-category/CategoryValueAddDialog.tsx b/frontend/components/table-category/CategoryValueAddDialog.tsx index c486cc1d..9d962b22 100644 --- a/frontend/components/table-category/CategoryValueAddDialog.tsx +++ b/frontend/components/table-category/CategoryValueAddDialog.tsx @@ -52,23 +52,12 @@ export const CategoryValueAddDialog: React.FC< const [description, setDescription] = useState(""); const [color, setColor] = useState("none"); - // 라벨에서 코드 자동 생성 - const generateCode = (label: string): string => { - // 한글을 영문으로 변환하거나, 영문/숫자만 추출하여 대문자로 - const cleaned = label - .replace(/[^a-zA-Z0-9가-힣\s]/g, "") // 특수문자 제거 - .trim() - .toUpperCase(); - - // 영문이 있으면 영문만, 없으면 타임스탬프 기반 - const englishOnly = cleaned.replace(/[^A-Z0-9\s]/g, "").replace(/\s+/g, "_"); - - if (englishOnly.length > 0) { - return englishOnly.substring(0, 20); // 최대 20자 - } - - // 영문이 없으면 CATEGORY_TIMESTAMP 형식 - return `CATEGORY_${Date.now().toString().slice(-6)}`; + // 라벨에서 코드 자동 생성 (항상 고유한 코드 생성) + const generateCode = (): string => { + // 항상 CATEGORY_TIMESTAMP_RANDOM 형식으로 고유 코드 생성 + const timestamp = Date.now().toString().slice(-6); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `CATEGORY_${timestamp}${random}`; }; const handleSubmit = () => { @@ -76,7 +65,7 @@ export const CategoryValueAddDialog: React.FC< return; } - const valueCode = generateCode(valueLabel); + const valueCode = generateCode(); onAdd({ tableName: "", // CategoryValueManager에서 오버라이드됨 diff --git a/frontend/contexts/SplitPanelContext.tsx b/frontend/contexts/SplitPanelContext.tsx index 15f3e1f5..99cccdd8 100644 --- a/frontend/contexts/SplitPanelContext.tsx +++ b/frontend/contexts/SplitPanelContext.tsx @@ -26,6 +26,15 @@ export interface ParentDataMapping { targetColumn: string; // 우측 화면 저장 시 사용할 컬럼명 (예: equipment_code) } +/** + * 연결 필터 설정 + * 좌측 화면에서 선택한 데이터로 우측 화면의 테이블을 자동 필터링 + */ +export interface LinkedFilter { + sourceColumn: string; // 좌측 화면의 컬럼명 (예: equipment_code) + targetColumn: string; // 우측 화면 필터링에 사용할 컬럼명 (예: equipment_code) +} + /** * 분할 패널 컨텍스트 값 */ @@ -73,6 +82,12 @@ interface SplitPanelContextValue { // 🆕 매핑된 부모 데이터 가져오기 (우측 화면 저장 시 사용) getMappedParentData: () => Record; + + // 🆕 연결 필터 설정 (좌측 선택 → 우측 테이블 필터링) + linkedFilters: LinkedFilter[]; + + // 🆕 연결 필터 값 가져오기 (우측 테이블 조회 시 사용) + getLinkedFilterValues: () => Record; } const SplitPanelContext = createContext(null); @@ -82,6 +97,7 @@ interface SplitPanelProviderProps { leftScreenId: number | null; rightScreenId: number | null; parentDataMapping?: ParentDataMapping[]; // 🆕 부모 데이터 매핑 설정 + linkedFilters?: LinkedFilter[]; // 🆕 연결 필터 설정 children: React.ReactNode; } @@ -93,6 +109,7 @@ export function SplitPanelProvider({ leftScreenId, rightScreenId, parentDataMapping = [], + linkedFilters = [], children, }: SplitPanelProviderProps) { // 좌측/우측 화면의 데이터 수신자 맵 @@ -270,26 +287,68 @@ export function SplitPanelProvider({ /** * 🆕 매핑된 부모 데이터 가져오기 * 우측 화면에서 저장 시 이 함수를 호출하여 부모 키 값을 가져옴 + * + * 동작 방식: + * 1. 좌측 데이터의 모든 컬럼을 자동으로 전달 (동일한 컬럼명이면 자동 매핑) + * 2. 명시적 매핑이 있으면 소스→타겟 변환 적용 (다른 컬럼명으로 매핑 시) */ const getMappedParentData = useCallback((): Record => { - if (!selectedLeftData || parentDataMapping.length === 0) { + if (!selectedLeftData) { return {}; } const mappedData: Record = {}; + // 1단계: 좌측 데이터의 모든 컬럼을 자동으로 전달 (동일 컬럼명 자동 매핑) + for (const [key, value] of Object.entries(selectedLeftData)) { + if (value !== undefined && value !== null) { + mappedData[key] = value; + } + } + + // 2단계: 명시적 매핑이 있으면 추가 적용 (다른 컬럼명으로 변환) for (const mapping of parentDataMapping) { const value = selectedLeftData[mapping.sourceColumn]; if (value !== undefined && value !== null) { - mappedData[mapping.targetColumn] = value; - logger.debug(`[SplitPanelContext] 부모 데이터 매핑: ${mapping.sourceColumn} → ${mapping.targetColumn} = ${value}`); + // 소스와 타겟이 다른 경우에만 추가 매핑 + if (mapping.sourceColumn !== mapping.targetColumn) { + mappedData[mapping.targetColumn] = value; + logger.debug(`[SplitPanelContext] 명시적 매핑: ${mapping.sourceColumn} → ${mapping.targetColumn} = ${value}`); + } } } - logger.info(`[SplitPanelContext] 매핑된 부모 데이터:`, mappedData); + logger.info(`[SplitPanelContext] 매핑된 부모 데이터 (자동+명시적):`, { + autoMappedKeys: Object.keys(selectedLeftData), + explicitMappings: parentDataMapping.length, + finalKeys: Object.keys(mappedData), + }); return mappedData; }, [selectedLeftData, parentDataMapping]); + /** + * 🆕 연결 필터 값 가져오기 + * 우측 화면의 테이블 조회 시 이 값으로 필터링 + */ + const getLinkedFilterValues = useCallback((): Record => { + if (!selectedLeftData || linkedFilters.length === 0) { + return {}; + } + + const filterValues: Record = {}; + + for (const filter of linkedFilters) { + const value = selectedLeftData[filter.sourceColumn]; + if (value !== undefined && value !== null && value !== "") { + filterValues[filter.targetColumn] = value; + logger.debug(`[SplitPanelContext] 연결 필터: ${filter.sourceColumn} → ${filter.targetColumn} = ${value}`); + } + } + + logger.info(`[SplitPanelContext] 연결 필터 값:`, filterValues); + return filterValues; + }, [selectedLeftData, linkedFilters]); + // 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지) const value = React.useMemo(() => ({ splitPanelId, @@ -310,6 +369,9 @@ export function SplitPanelProvider({ setSelectedLeftData: handleSetSelectedLeftData, parentDataMapping, getMappedParentData, + // 🆕 연결 필터 관련 + linkedFilters, + getLinkedFilterValues, }), [ splitPanelId, leftScreenId, @@ -327,6 +389,8 @@ export function SplitPanelProvider({ handleSetSelectedLeftData, parentDataMapping, getMappedParentData, + linkedFilters, + getLinkedFilterValues, ]); return ( diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 0ea687bf..c0e0c87e 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -374,6 +374,11 @@ export const DynamicComponentRenderer: React.FC = height: component.size?.height ? `${component.size.height}px` : component.style?.height, }; + // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName을 사용해야 함 (화면 테이블이 아닌 검색 대상 테이블) + const useConfigTableName = componentType === "entity-search-input" || + componentType === "autocomplete-search-input" || + componentType === "modal-repeater-table"; + const rendererProps = { component, isSelected, @@ -396,7 +401,8 @@ export const DynamicComponentRenderer: React.FC = formData, onFormDataChange, onChange: handleChange, // 개선된 onChange 핸들러 전달 - tableName, + // 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용 + tableName: useConfigTableName ? (component.componentConfig?.tableName || tableName) : tableName, menuId, // 🆕 메뉴 ID menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프) selectedScreen, // 🆕 화면 정보 diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 564eed1d..0bf8bea2 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -693,11 +693,21 @@ export const ButtonPrimaryComponent: React.FC = ({ }); // 🆕 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함) + // 조건 완화: splitPanelContext가 있고 selectedLeftData가 있으면 가져옴 + // (탭 안에서도 분할 패널 컨텍스트에 접근 가능하도록) let splitPanelParentData: Record | undefined; - if (splitPanelContext && splitPanelPosition === "right") { - splitPanelParentData = splitPanelContext.getMappedParentData(); - if (Object.keys(splitPanelParentData).length > 0) { - console.log("🔗 [ButtonPrimaryComponent] 분할 패널 부모 데이터 포함:", splitPanelParentData); + if (splitPanelContext) { + // 우측 화면이거나, 탭 안의 화면(splitPanelPosition이 undefined)인 경우 모두 처리 + // 좌측 화면이 아닌 경우에만 부모 데이터 포함 (좌측에서 저장 시 자신의 데이터를 부모로 포함하면 안됨) + if (splitPanelPosition !== "left") { + splitPanelParentData = splitPanelContext.getMappedParentData(); + if (Object.keys(splitPanelParentData).length > 0) { + console.log("🔗 [ButtonPrimaryComponent] 분할 패널 부모 데이터 포함:", { + splitPanelParentData, + splitPanelPosition, + isInTab: !splitPanelPosition, // splitPanelPosition이 없으면 탭 안 + }); + } } } diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index 094ddf70..8a78f674 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState, useMemo } from "react"; +import React, { useEffect, useState, useMemo, useCallback } from "react"; import { ComponentRendererProps } from "@/types/component"; import { CardDisplayConfig } from "./types"; import { tableTypeApi } from "@/lib/api/screen"; @@ -8,6 +8,9 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; +import { useModalDataStore } from "@/stores/modalDataStore"; export interface CardDisplayComponentProps extends ComponentRendererProps { config?: CardDisplayConfig; @@ -38,13 +41,18 @@ export const CardDisplayComponent: React.FC = ({ tableColumns = [], ...props }) => { + // 컨텍스트 (선택적 - 디자인 모드에서는 없을 수 있음) + const screenContext = useScreenContextOptional(); + const splitPanelContext = useSplitPanelContext(); + const splitPanelPosition = screenContext?.splitPanelPosition; + // 테이블 데이터 상태 관리 const [loadedTableData, setLoadedTableData] = useState([]); const [loadedTableColumns, setLoadedTableColumns] = useState([]); const [loading, setLoading] = useState(false); - // 선택된 카드 상태 - const [selectedCardId, setSelectedCardId] = useState(null); + // 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게) + const [selectedRows, setSelectedRows] = useState>(new Set()); // 상세보기 모달 상태 const [viewModalOpen, setViewModalOpen] = useState(false); @@ -199,38 +207,132 @@ export const CardDisplayComponent: React.FC = ({ // 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용) const displayData = useMemo(() => { - // console.log("📋 CardDisplay: displayData 결정 중", { - // dataSource: componentConfig.dataSource, - // loadedTableDataLength: loadedTableData.length, - // tableDataLength: tableData.length, - // staticDataLength: componentConfig.staticData?.length || 0, - // }); - // 로드된 테이블 데이터가 있으면 항상 우선 사용 (dataSource 설정 무시) if (loadedTableData.length > 0) { - // console.log("📋 CardDisplay: 로드된 테이블 데이터 사용", loadedTableData.slice(0, 2)); return loadedTableData; } // props로 전달받은 테이블 데이터가 있으면 사용 if (tableData.length > 0) { - // console.log("📋 CardDisplay: props 테이블 데이터 사용", tableData.slice(0, 2)); return tableData; } if (componentConfig.staticData && componentConfig.staticData.length > 0) { - // console.log("📋 CardDisplay: 정적 데이터 사용", componentConfig.staticData.slice(0, 2)); return componentConfig.staticData; } // 데이터가 없으면 빈 배열 반환 - // console.log("📋 CardDisplay: 표시할 데이터가 없음"); return []; }, [componentConfig.dataSource, loadedTableData, tableData, componentConfig.staticData]); // 실제 사용할 테이블 컬럼 정보 (로드된 컬럼 우선 사용) const actualTableColumns = loadedTableColumns.length > 0 ? loadedTableColumns : tableColumns; + // 카드 ID 가져오기 함수 (훅은 조기 리턴 전에 선언) + const getCardKey = useCallback((data: any, index: number): string => { + return String(data.id || data.objid || data.ID || index); + }, []); + + // 카드 선택 핸들러 (테이블 리스트와 동일한 로직) + const handleCardSelection = useCallback((cardKey: string, data: any, checked: boolean) => { + const newSelectedRows = new Set(selectedRows); + if (checked) { + newSelectedRows.add(cardKey); + } else { + newSelectedRows.delete(cardKey); + } + setSelectedRows(newSelectedRows); + + // 선택된 카드 데이터 계산 + const selectedRowsData = displayData.filter((item, index) => + newSelectedRows.has(getCardKey(item, index)) + ); + + // onFormDataChange 호출 + if (onFormDataChange) { + onFormDataChange({ + selectedRows: Array.from(newSelectedRows), + selectedRowsData, + }); + } + + // modalDataStore에 선택된 데이터 저장 + const tableNameToUse = componentConfig.dataSource?.tableName || tableName; + if (tableNameToUse && selectedRowsData.length > 0) { + const modalItems = selectedRowsData.map((row, idx) => ({ + id: getCardKey(row, idx), + originalData: row, + additionalData: {}, + })); + useModalDataStore.getState().setData(tableNameToUse, modalItems); + console.log("✅ [CardDisplay] modalDataStore에 데이터 저장:", { + dataSourceId: tableNameToUse, + count: modalItems.length, + }); + } else if (tableNameToUse && selectedRowsData.length === 0) { + useModalDataStore.getState().clearData(tableNameToUse); + console.log("🗑️ [CardDisplay] modalDataStore 데이터 제거:", tableNameToUse); + } + + // 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) + if (splitPanelContext && splitPanelPosition === "left") { + if (checked) { + splitPanelContext.setSelectedLeftData(data); + console.log("🔗 [CardDisplay] 분할 패널 좌측 데이터 저장:", { + data, + parentDataMapping: splitPanelContext.parentDataMapping, + }); + } else if (newSelectedRows.size === 0) { + splitPanelContext.setSelectedLeftData(null); + console.log("🔗 [CardDisplay] 분할 패널 좌측 데이터 초기화"); + } + } + }, [selectedRows, displayData, getCardKey, onFormDataChange, componentConfig.dataSource?.tableName, tableName, splitPanelContext, splitPanelPosition]); + + const handleCardClick = useCallback((data: any, index: number) => { + const cardKey = getCardKey(data, index); + const isCurrentlySelected = selectedRows.has(cardKey); + + // 선택 토글 + handleCardSelection(cardKey, data, !isCurrentlySelected); + + if (componentConfig.onCardClick) { + componentConfig.onCardClick(data); + } + }, [getCardKey, selectedRows, handleCardSelection, componentConfig.onCardClick]); + + // DataProvidable 인터페이스 구현 (테이블 리스트와 동일) + const dataProvider = useMemo(() => ({ + componentId: component.id, + componentType: "card-display" as const, + + getSelectedData: () => { + const selectedData = displayData.filter((item, index) => + selectedRows.has(getCardKey(item, index)) + ); + return selectedData; + }, + + getAllData: () => { + return displayData; + }, + + clearSelection: () => { + setSelectedRows(new Set()); + }, + }), [component.id, displayData, selectedRows, getCardKey]); + + // ScreenContext에 데이터 제공자로 등록 + useEffect(() => { + if (screenContext && component.id) { + screenContext.registerDataProvider(component.id, dataProvider); + + return () => { + screenContext.unregisterDataProvider(component.id); + }; + } + }, [screenContext, component.id, dataProvider]); + // 로딩 중인 경우 로딩 표시 if (loading) { return ( @@ -323,20 +425,6 @@ export const CardDisplayComponent: React.FC = ({ onClick?.(); }; - const handleCardClick = (data: any) => { - const cardId = data.id || data.objid || data.ID; - // 이미 선택된 카드를 다시 클릭하면 선택 해제 - if (selectedCardId === cardId) { - setSelectedCardId(null); - } else { - setSelectedCardId(cardId); - } - - if (componentConfig.onCardClick) { - componentConfig.onCardClick(data); - } - }; - // DOM 안전한 props만 필터링 (filterDOMProps 유틸리티 사용) const safeDomProps = filterDOMProps(props); @@ -425,12 +513,12 @@ export const CardDisplayComponent: React.FC = ({ ? getColumnValue(data, componentConfig.columnMapping.imageColumn) : data.avatar || data.image || ""; - const cardId = data.id || data.objid || data.ID || index; - const isCardSelected = selectedCardId === cardId; + const cardKey = getCardKey(data, index); + const isCardSelected = selectedRows.has(cardKey); return (
= ({ : "0 1px 3px rgba(0, 0, 0, 0.08)", }} className="card-hover group cursor-pointer transition-all duration-150" - onClick={() => handleCardClick(data)} + onClick={() => handleCardClick(data, index)} > {/* 카드 이미지 */} {componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && ( diff --git a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx index 63f074d3..b8a1d3dc 100644 --- a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx +++ b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx @@ -15,7 +15,7 @@ import { getTableColumns } from "@/lib/api/tableManagement"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { cn } from "@/lib/utils"; -import type { ParentDataMapping } from "@/contexts/SplitPanelContext"; +import type { ParentDataMapping, LinkedFilter } from "@/contexts/SplitPanelContext"; interface ScreenSplitPanelConfigPanelProps { config: any; @@ -33,7 +33,15 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl // 좌측 화면의 테이블 컬럼 목록 const [leftScreenColumns, setLeftScreenColumns] = useState>([]); - const [isLoadingColumns, setIsLoadingColumns] = useState(false); + const [isLoadingLeftColumns, setIsLoadingLeftColumns] = useState(false); + + // 우측 화면의 테이블 컬럼 목록 (테이블별로 그룹화) + const [rightScreenTables, setRightScreenTables] = useState + }>>([]); + const [isLoadingRightColumns, setIsLoadingRightColumns] = useState(false); const [localConfig, setLocalConfig] = useState({ screenId: config.screenId || 0, @@ -44,6 +52,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl buttonLabel: config.buttonLabel || "데이터 전달", buttonPosition: config.buttonPosition || "center", parentDataMapping: config.parentDataMapping || [] as ParentDataMapping[], + linkedFilters: config.linkedFilters || [] as LinkedFilter[], ...config, }); @@ -59,6 +68,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl buttonLabel: config.buttonLabel || "데이터 전달", buttonPosition: config.buttonPosition || "center", parentDataMapping: config.parentDataMapping || [], + linkedFilters: config.linkedFilters || [], ...config, }); }, [config]); @@ -72,7 +82,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl } try { - setIsLoadingColumns(true); + setIsLoadingLeftColumns(true); // 좌측 화면 정보 조회 const screenData = await screenApi.getScreen(localConfig.leftScreenId); @@ -96,13 +106,126 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl console.error("좌측 화면 컬럼 로드 실패:", error); setLeftScreenColumns([]); } finally { - setIsLoadingColumns(false); + setIsLoadingLeftColumns(false); } }; loadLeftScreenColumns(); }, [localConfig.leftScreenId]); + // 우측 화면이 변경되면 해당 화면 및 임베드된 화면들의 테이블 컬럼 로드 + useEffect(() => { + const loadRightScreenColumns = async () => { + if (!localConfig.rightScreenId) { + setRightScreenTables([]); + return; + } + + try { + setIsLoadingRightColumns(true); + const tables: Array<{ tableName: string; screenName: string; columns: Array<{ columnName: string; columnLabel: string }> }> = []; + + // 우측 화면 정보 조회 + const screenData = await screenApi.getScreen(localConfig.rightScreenId); + + // 1. 메인 화면의 테이블 (있는 경우) + if (screenData?.tableName) { + const columnsResponse = await getTableColumns(screenData.tableName); + if (columnsResponse.success && columnsResponse.data?.columns) { + tables.push({ + tableName: screenData.tableName, + screenName: screenData.screenName || "메인 화면", + columns: columnsResponse.data.columns.map((col: any) => ({ + columnName: col.column_name || col.columnName, + columnLabel: col.column_label || col.columnLabel || col.column_name || col.columnName, + })), + }); + } + } + + // 2. 레이아웃에서 임베드된 화면들의 테이블 찾기 (탭, 분할 패널 등) + const layoutData = await screenApi.getLayout(localConfig.rightScreenId); + const components = layoutData?.components || []; + + if (components.length > 0) { + const embeddedScreenIds = new Set(); + + // 컴포넌트에서 임베드된 화면 ID 수집 + const findEmbeddedScreens = (comps: any[]) => { + for (const comp of comps) { + const config = comp.componentConfig || {}; + + // TabsWidget의 탭들 + if (comp.componentType === "tabs-widget" && config.tabs) { + for (const tab of config.tabs) { + if (tab.screenId) { + embeddedScreenIds.add(tab.screenId); + console.log("🔍 탭에서 화면 발견:", tab.screenId, tab.screenName); + } + } + } + + // ScreenSplitPanel + if (comp.componentType === "screen-split-panel") { + if (config.leftScreenId) embeddedScreenIds.add(config.leftScreenId); + if (config.rightScreenId) embeddedScreenIds.add(config.rightScreenId); + } + + // EmbeddedScreen + if (comp.componentType === "embedded-screen" && config.screenId) { + embeddedScreenIds.add(config.screenId); + } + + // 중첩된 컴포넌트 검색 + if (comp.children) { + findEmbeddedScreens(comp.children); + } + } + }; + + findEmbeddedScreens(components); + console.log("📋 발견된 임베드 화면 ID:", Array.from(embeddedScreenIds)); + + // 임베드된 화면들의 테이블 컬럼 로드 + for (const embeddedScreenId of embeddedScreenIds) { + try { + const embeddedScreen = await screenApi.getScreen(embeddedScreenId); + if (embeddedScreen?.tableName) { + // 이미 추가된 테이블인지 확인 + if (!tables.find(t => t.tableName === embeddedScreen.tableName)) { + const columnsResponse = await getTableColumns(embeddedScreen.tableName); + if (columnsResponse.success && columnsResponse.data?.columns) { + tables.push({ + tableName: embeddedScreen.tableName, + screenName: embeddedScreen.screenName || `화면 ${embeddedScreenId}`, + columns: columnsResponse.data.columns.map((col: any) => ({ + columnName: col.column_name || col.columnName, + columnLabel: col.column_label || col.columnLabel || col.column_name || col.columnName, + })), + }); + console.log("✅ 테이블 추가:", embeddedScreen.tableName); + } + } + } + } catch (err) { + console.warn(`임베드된 화면 ${embeddedScreenId} 로드 실패:`, err); + } + } + } + + setRightScreenTables(tables); + console.log("📋 우측 화면 테이블 로드 완료:", tables.map(t => t.tableName)); + } catch (error) { + console.error("우측 화면 컬럼 로드 실패:", error); + setRightScreenTables([]); + } finally { + setIsLoadingRightColumns(false); + } + }; + + loadRightScreenColumns(); + }, [localConfig.rightScreenId]); + // 화면 목록 로드 useEffect(() => { const loadScreens = async () => { @@ -168,21 +291,51 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl updateConfig("parentDataMapping", newMappings); }; + // 연결 필터 추가 + const addLinkedFilter = () => { + const newFilter: LinkedFilter = { + sourceColumn: "", + targetColumn: "", + }; + const newFilters = [...(localConfig.linkedFilters || []), newFilter]; + updateConfig("linkedFilters", newFilters); + }; + + // 연결 필터 수정 + const updateLinkedFilter = (index: number, field: keyof LinkedFilter, value: string) => { + const newFilters = [...(localConfig.linkedFilters || [])]; + newFilters[index] = { + ...newFilters[index], + [field]: value, + }; + updateConfig("linkedFilters", newFilters); + }; + + // 연결 필터 삭제 + const removeLinkedFilter = (index: number) => { + const newFilters = (localConfig.linkedFilters || []).filter((_: any, i: number) => i !== index); + updateConfig("linkedFilters", newFilters); + }; + return (
- - - + + + 레이아웃 - - - 화면 설정 + + + 화면 - - - 데이터 전달 + + + 연결필터 + + + + 데이터전달 @@ -385,6 +538,141 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl + {/* 연결 필터 탭 */} + + + + 연결 필터 + + 좌측 화면에서 행을 선택하면, 우측 화면의 테이블이 자동으로 필터링됩니다. + + + + {!localConfig.leftScreenId || !localConfig.rightScreenId ? ( +
+

+ 먼저 "화면" 탭에서 좌측/우측 화면을 모두 선택하세요. +

+
+ ) : isLoadingLeftColumns || isLoadingRightColumns ? ( +
+ + 컬럼 정보 로딩 중... +
+ ) : leftScreenColumns.length === 0 || rightScreenTables.length === 0 ? ( +
+

+ {leftScreenColumns.length === 0 && "좌측 화면에 테이블이 설정되지 않았습니다. "} + {rightScreenTables.length === 0 && "우측 화면에 테이블이 설정되지 않았습니다."} +

+
+ ) : ( + <> + {/* 연결 필터 설명 */} +
+

+ 예: 좌측에서 설비를 선택하면 → 우측 점검항목이 해당 설비의 항목만 표시됩니다. +
+ 좌측 equipment_code → + 우측 equipment_code +

+
+ + {/* 필터 목록 */} +
+ {(localConfig.linkedFilters || []).map((filter: LinkedFilter, index: number) => ( +
+
+ 필터 #{index + 1} + +
+
+
+ + +
+
+ +
+
+ + +
+
+
+ ))} +
+ + {/* 추가 버튼 */} + + + {/* 현재 설정 표시 */} + +
+ {(localConfig.linkedFilters || []).length > 0 + ? `${localConfig.linkedFilters.length}개 필터 설정됨` + : "필터 없음 - 우측 화면에 모든 데이터가 표시됩니다"} +
+ + )} +
+
+
+ {/* 데이터 전달 탭 */} @@ -395,69 +683,105 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl - {!localConfig.leftScreenId ? ( + {!localConfig.leftScreenId || !localConfig.rightScreenId ? (

- 먼저 "화면 설정" 탭에서 좌측 화면을 선택하세요. + 먼저 "화면 설정" 탭에서 좌측/우측 화면을 모두 선택하세요.

- ) : isLoadingColumns ? ( + ) : isLoadingLeftColumns || isLoadingRightColumns ? (
컬럼 정보 로딩 중...
- ) : leftScreenColumns.length === 0 ? ( + ) : leftScreenColumns.length === 0 || rightScreenTables.length === 0 ? (

- 좌측 화면에 테이블이 설정되지 않았거나 컬럼 정보를 불러올 수 없습니다. + {leftScreenColumns.length === 0 && "좌측 화면에 테이블이 설정되지 않았습니다. "} + {rightScreenTables.length === 0 && "우측 화면에 테이블이 설정되지 않았습니다."}

) : ( <> + {/* 우측 화면 테이블 목록 표시 */} +
+

+ 우측 화면에서 감지된 테이블 ({rightScreenTables.length}개): +

+
    + {rightScreenTables.map((table) => ( +
  • • {table.screenName}: {table.tableName}
  • + ))} +
+
+ {/* 매핑 목록 */}
{(localConfig.parentDataMapping || []).map((mapping: ParentDataMapping, index: number) => ( -
-
-
-
- - -
- -
- - updateParentDataMapping(index, "targetColumn", e.target.value)} - placeholder="저장할 컬럼명" - className="h-8 text-xs" - /> -
+
+
+ 매핑 #{index + 1} + +
+
+
+ + +
+
+ +
+
+ +
-
))}
@@ -473,23 +797,23 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl 매핑 추가 - {/* 안내 메시지 */} + {/* 자동 매핑 안내 */} +
+

+ 자동 매핑: 좌측에서 선택한 데이터의 모든 컬럼이 우측 화면에 자동 전달됩니다. +
+ 동일한 컬럼명(예: equipment_code)이 있으면 별도 설정 없이 자동으로 매핑됩니다. +

+
+ + {/* 수동 매핑 안내 */}

- 사용 예시: + 수동 매핑 (선택사항):
- 좌측: 설비 목록 (equipment_mng) + 컬럼명이 다른 경우에만 위에서 매핑을 추가하세요.
- 우측: 점검항목 추가 화면 -
-
- 매핑 설정: -
- - 소스: equipment_code → 타겟: equipment_code -
-
- 좌측에서 설비를 선택하고 우측에서 점검항목을 추가하면, - 선택한 설비의 equipment_code가 자동으로 저장됩니다. + 예: 좌측 user_id → 우측 created_by

diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index a643e3a9..5b2cff2b 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -1075,7 +1075,68 @@ export const TableListComponent: React.FC = ({ const sortBy = sortColumn || undefined; const sortOrder = sortDirection; const search = searchTerm || undefined; - const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined; + + // 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때) + let linkedFilterValues: Record = {}; + let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부 + let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부 + + console.log("🔍 [TableList] 분할 패널 컨텍스트 확인:", { + hasSplitPanelContext: !!splitPanelContext, + tableName: tableConfig.selectedTable, + selectedLeftData: splitPanelContext?.selectedLeftData, + linkedFilters: splitPanelContext?.linkedFilters, + }); + + if (splitPanelContext) { + // 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지) + const linkedFiltersConfig = splitPanelContext.linkedFilters || []; + hasLinkedFiltersConfigured = linkedFiltersConfig.some( + (filter) => filter.targetColumn?.startsWith(tableConfig.selectedTable + ".") || + filter.targetColumn === tableConfig.selectedTable + ); + + // 좌측 데이터 선택 여부 확인 + hasSelectedLeftData = splitPanelContext.selectedLeftData && + Object.keys(splitPanelContext.selectedLeftData).length > 0; + + const allLinkedFilters = splitPanelContext.getLinkedFilterValues(); + console.log("🔗 [TableList] 연결 필터 원본:", allLinkedFilters); + + // 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서) + for (const [key, value] of Object.entries(allLinkedFilters)) { + if (key.includes(".")) { + const [tableName, columnName] = key.split("."); + if (tableName === tableConfig.selectedTable) { + linkedFilterValues[columnName] = value; + hasLinkedFiltersConfigured = true; // 이 테이블에 대한 필터가 있음 + } + } else { + // 테이블명 없이 컬럼명만 있는 경우 그대로 사용 + linkedFilterValues[key] = value; + } + } + if (Object.keys(linkedFilterValues).length > 0) { + console.log("🔗 [TableList] 연결 필터 적용:", linkedFilterValues); + } + } + + // 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우 + // → 빈 데이터 표시 (모든 데이터를 보여주지 않음) + if (hasLinkedFiltersConfigured && !hasSelectedLeftData) { + console.log("⚠️ [TableList] 연결 필터 설정됨 but 좌측 데이터 미선택 → 빈 데이터 표시"); + setData([]); + setTotalItems(0); + setLoading(false); + return; + } + + // 검색 필터와 연결 필터 병합 + const filters = { + ...(Object.keys(searchValues).length > 0 ? searchValues : {}), + ...linkedFilterValues, + }; + const hasFilters = Object.keys(filters).length > 0; const entityJoinColumns = (tableConfig.columns || []) .filter((col) => col.additionalJoinInfo) @@ -1100,7 +1161,7 @@ export const TableListComponent: React.FC = ({ size: pageSize, sortBy, sortOrder, - search: filters, + search: hasFilters ? filters : undefined, enableEntityJoin: true, additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 @@ -1164,6 +1225,7 @@ export const TableListComponent: React.FC = ({ searchTerm, searchValues, isDesignMode, + splitPanelContext?.selectedLeftData, // 🆕 연결 필터 변경 시 재조회 ]); const fetchTableDataDebounced = useCallback( @@ -2127,6 +2189,7 @@ export const TableListComponent: React.FC = ({ refreshKey, refreshTrigger, // 강제 새로고침 트리거 isDesignMode, + splitPanelContext?.selectedLeftData, // 🆕 좌측 데이터 선택 변경 시 데이터 새로고침 // fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지 ]); diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 4e60536b..1da9d026 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -559,8 +559,7 @@ export class ButtonActionExecutor { // }); // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) - // console.log("🔍 채번 규칙 할당 체크 시작"); - // console.log("📦 현재 formData:", JSON.stringify(formData, null, 2)); + console.log("🔍 채번 규칙 할당 체크 시작"); const fieldsWithNumbering: Record = {}; @@ -569,23 +568,39 @@ export class ButtonActionExecutor { if (key.endsWith("_numberingRuleId") && value) { const fieldName = key.replace("_numberingRuleId", ""); fieldsWithNumbering[fieldName] = value as string; - // console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`); + console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`); } } - // console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering); - // console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length); + console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering); + console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length); - // 사용자 입력 값 유지 (재할당하지 않음) - // 채번 규칙은 TextInputComponent 마운트 시 이미 생성되었으므로 - // 저장 시점에는 사용자가 수정한 값을 그대로 사용 + // 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가 if (Object.keys(fieldsWithNumbering).length > 0) { - console.log("ℹ️ 채번 규칙 필드 감지:", Object.keys(fieldsWithNumbering)); - console.log("ℹ️ 사용자 입력 값 유지 (재할당 하지 않음)"); + console.log("🎯 채번 규칙 할당 시작 (allocateCode 호출)"); + const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); + + for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { + try { + console.log(`🔄 ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`); + const allocateResult = await allocateNumberingCode(ruleId); + + if (allocateResult.success && allocateResult.data?.generatedCode) { + const newCode = allocateResult.data.generatedCode; + console.log(`✅ ${fieldName} 새 코드 할당: ${formData[fieldName]} → ${newCode}`); + formData[fieldName] = newCode; + } else { + console.warn(`⚠️ ${fieldName} 코드 할당 실패, 기존 값 유지:`, allocateResult.error); + } + } catch (allocateError) { + console.error(`❌ ${fieldName} 코드 할당 오류:`, allocateError); + // 오류 시 기존 값 유지 + } + } } - // console.log("✅ 채번 규칙 할당 완료"); - // console.log("📦 최종 formData:", JSON.stringify(formData, null, 2)); + console.log("✅ 채번 규칙 할당 완료"); + console.log("📦 최종 formData:", JSON.stringify(formData, null, 2)); // 🆕 분할 패널 부모 데이터 병합 (좌측 화면에서 선택된 데이터) const splitPanelData = context.splitPanelParentData || {}; @@ -1210,6 +1225,7 @@ export class ButtonActionExecutor { // 🆕 선택된 행 데이터 수집 const selectedData = context.selectedRowsData || []; console.log("📦 [handleModal] 선택된 데이터:", selectedData); + console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData); // 전역 모달 상태 업데이트를 위한 이벤트 발생 const modalEvent = new CustomEvent("openScreenModal", { @@ -1221,6 +1237,8 @@ export class ButtonActionExecutor { // 🆕 선택된 행 데이터 전달 selectedData: selectedData, selectedIds: selectedData.map((row: any) => row.id).filter(Boolean), + // 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용) + splitPanelParentData: context.splitPanelParentData || {}, }, });