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 de886cfd..a048bbe4 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -17,6 +17,7 @@ import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; +import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; interface ScreenModalState { isOpen: boolean; @@ -32,6 +33,7 @@ interface ScreenModalProps { export const ScreenModal: React.FC = ({ className }) => { const { userId, userName, user } = useAuth(); + const splitPanelContext = useSplitPanelContext(); const [modalState, setModalState] = useState({ isOpen: false, @@ -132,7 +134,17 @@ export const ScreenModal: React.FC = ({ className }) => { // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenModal = (event: CustomEvent) => { - const { screenId, title, description, size, urlParams, editData, selectedData: eventSelectedData, selectedIds } = event.detail; + const { + screenId, + title, + description, + size, + urlParams, + editData, + splitPanelParentData, + selectedData: eventSelectedData, + selectedIds, + } = event.detail; console.log("📦 [ScreenModal] 모달 열기 이벤트 수신:", { screenId, @@ -170,6 +182,20 @@ export const ScreenModal: React.FC = ({ className }) => { setFormData(editData); setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용) } else { + // 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정 + // 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); + } else { + setFormData({}); + } setOriginalData(null); // 신규 등록 모드 } 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 2e43fcc6..60b6bf24 100644 --- a/frontend/components/screen-embedding/ScreenSplitPanel.tsx +++ b/frontend/components/screen-embedding/ScreenSplitPanel.tsx @@ -33,6 +33,7 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp leftScreenId: config?.leftScreenId, rightScreenId: config?.rightScreenId, configSplitRatio, + parentDataMapping: config?.parentDataMapping, configKeys: config ? Object.keys(config) : [], }); @@ -125,6 +126,8 @@ export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSp splitPanelId={splitPanelId} 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 bfb9610b..99cccdd8 100644 --- a/frontend/contexts/SplitPanelContext.tsx +++ b/frontend/contexts/SplitPanelContext.tsx @@ -17,6 +17,24 @@ export interface SplitPanelDataReceiver { receiveData: (data: any[], mode: "append" | "replace" | "merge") => Promise; } +/** + * 부모 데이터 매핑 설정 + * 좌측 화면에서 선택한 데이터를 우측 화면 저장 시 자동으로 포함 + */ +export interface ParentDataMapping { + sourceColumn: string; // 좌측 화면의 컬럼명 (예: equipment_code) + targetColumn: string; // 우측 화면 저장 시 사용할 컬럼명 (예: equipment_code) +} + +/** + * 연결 필터 설정 + * 좌측 화면에서 선택한 데이터로 우측 화면의 테이블을 자동 필터링 + */ +export interface LinkedFilter { + sourceColumn: string; // 좌측 화면의 컬럼명 (예: equipment_code) + targetColumn: string; // 우측 화면 필터링에 사용할 컬럼명 (예: equipment_code) +} + /** * 분할 패널 컨텍스트 값 */ @@ -54,6 +72,22 @@ interface SplitPanelContextValue { addItemIds: (ids: string[]) => void; removeItemIds: (ids: string[]) => void; clearItemIds: () => void; + + // 🆕 좌측 선택 데이터 관리 (우측 화면 저장 시 부모 키 전달용) + selectedLeftData: Record | null; + setSelectedLeftData: (data: Record | null) => void; + + // 🆕 부모 데이터 매핑 설정 + parentDataMapping: ParentDataMapping[]; + + // 🆕 매핑된 부모 데이터 가져오기 (우측 화면 저장 시 사용) + getMappedParentData: () => Record; + + // 🆕 연결 필터 설정 (좌측 선택 → 우측 테이블 필터링) + linkedFilters: LinkedFilter[]; + + // 🆕 연결 필터 값 가져오기 (우측 테이블 조회 시 사용) + getLinkedFilterValues: () => Record; } const SplitPanelContext = createContext(null); @@ -62,6 +96,8 @@ interface SplitPanelProviderProps { splitPanelId: string; leftScreenId: number | null; rightScreenId: number | null; + parentDataMapping?: ParentDataMapping[]; // 🆕 부모 데이터 매핑 설정 + linkedFilters?: LinkedFilter[]; // 🆕 연결 필터 설정 children: React.ReactNode; } @@ -72,6 +108,8 @@ export function SplitPanelProvider({ splitPanelId, leftScreenId, rightScreenId, + parentDataMapping = [], + linkedFilters = [], children, }: SplitPanelProviderProps) { // 좌측/우측 화면의 데이터 수신자 맵 @@ -83,6 +121,9 @@ export function SplitPanelProvider({ // 🆕 우측에 추가된 항목 ID 상태 const [addedItemIds, setAddedItemIds] = useState>(new Set()); + + // 🆕 좌측에서 선택된 데이터 상태 + const [selectedLeftData, setSelectedLeftData] = useState | null>(null); /** * 데이터 수신자 등록 @@ -232,6 +273,82 @@ export function SplitPanelProvider({ logger.debug(`[SplitPanelContext] 항목 ID 초기화`); }, []); + /** + * 🆕 좌측 선택 데이터 설정 + */ + const handleSetSelectedLeftData = useCallback((data: Record | null) => { + logger.info(`[SplitPanelContext] 좌측 선택 데이터 설정:`, { + hasData: !!data, + dataKeys: data ? Object.keys(data) : [], + }); + setSelectedLeftData(data); + }, []); + + /** + * 🆕 매핑된 부모 데이터 가져오기 + * 우측 화면에서 저장 시 이 함수를 호출하여 부모 키 값을 가져옴 + * + * 동작 방식: + * 1. 좌측 데이터의 모든 컬럼을 자동으로 전달 (동일한 컬럼명이면 자동 매핑) + * 2. 명시적 매핑이 있으면 소스→타겟 변환 적용 (다른 컬럼명으로 매핑 시) + */ + const getMappedParentData = useCallback((): Record => { + 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) { + // 소스와 타겟이 다른 경우에만 추가 매핑 + if (mapping.sourceColumn !== mapping.targetColumn) { + mappedData[mapping.targetColumn] = value; + logger.debug(`[SplitPanelContext] 명시적 매핑: ${mapping.sourceColumn} → ${mapping.targetColumn} = ${value}`); + } + } + } + + 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, @@ -247,6 +364,14 @@ export function SplitPanelProvider({ addItemIds, removeItemIds, clearItemIds, + // 🆕 좌측 선택 데이터 관련 + selectedLeftData, + setSelectedLeftData: handleSetSelectedLeftData, + parentDataMapping, + getMappedParentData, + // 🆕 연결 필터 관련 + linkedFilters, + getLinkedFilterValues, }), [ splitPanelId, leftScreenId, @@ -260,6 +385,12 @@ export function SplitPanelProvider({ addItemIds, removeItemIds, clearItemIds, + selectedLeftData, + 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 180dacaa..0bf8bea2 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -692,6 +692,25 @@ export const ButtonPrimaryComponent: React.FC = ({ effectiveScreenId, }); + // 🆕 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함) + // 조건 완화: splitPanelContext가 있고 selectedLeftData가 있으면 가져옴 + // (탭 안에서도 분할 패널 컨텍스트에 접근 가능하도록) + let splitPanelParentData: Record | undefined; + if (splitPanelContext) { + // 우측 화면이거나, 탭 안의 화면(splitPanelPosition이 undefined)인 경우 모두 처리 + // 좌측 화면이 아닌 경우에만 부모 데이터 포함 (좌측에서 저장 시 자신의 데이터를 부모로 포함하면 안됨) + if (splitPanelPosition !== "left") { + splitPanelParentData = splitPanelContext.getMappedParentData(); + if (Object.keys(splitPanelParentData).length > 0) { + console.log("🔗 [ButtonPrimaryComponent] 분할 패널 부모 데이터 포함:", { + splitPanelParentData, + splitPanelPosition, + isInTab: !splitPanelPosition, // splitPanelPosition이 없으면 탭 안 + }); + } + } + } + const context: ButtonActionContext = { formData: formData || {}, originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용) @@ -720,6 +739,8 @@ export const ButtonPrimaryComponent: React.FC = ({ flowSelectedStepId, // 🆕 컴포넌트별 설정 (parentDataMapping 등) componentConfigs, + // 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터) + splitPanelParentData, } as ButtonActionContext; // 확인이 필요한 액션인지 확인 diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index 0912afd7..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,11 +41,19 @@ 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); + // 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게) + const [selectedRows, setSelectedRows] = useState>(new Set()); + // 상세보기 모달 상태 const [viewModalOpen, setViewModalOpen] = useState(false); const [selectedData, setSelectedData] = useState(null); @@ -196,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 ( @@ -261,26 +366,19 @@ export const CardDisplayComponent: React.FC = ({ borderRadius: "12px", // 컨테이너 자체도 라운드 처리 }; - // 카드 스타일 - 통일된 디자인 시스템 적용 + // 카드 스타일 - 컴팩트한 디자인 const cardStyle: React.CSSProperties = { backgroundColor: "white", - border: "2px solid #e5e7eb", // 더 명확한 테두리 - borderRadius: "12px", // 통일된 라운드 처리 - padding: "24px", // 더 여유로운 패딩 - boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)", // 더 깊은 그림자 - transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", // 부드러운 트랜지션 + border: "1px solid #e5e7eb", + borderRadius: "8px", + padding: "16px", + boxShadow: "0 1px 3px rgba(0, 0, 0, 0.08)", + transition: "all 0.2s ease", overflow: "hidden", display: "flex", flexDirection: "column", position: "relative", - minHeight: "240px", // 최소 높이 더 증가 cursor: isDesignMode ? "pointer" : "default", - // 호버 효과를 위한 추가 스타일 - "&:hover": { - transform: "translateY(-2px)", - boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)", - borderColor: "#f59e0b", // 호버 시 오렌지 테두리 - } }; // 텍스트 자르기 함수 @@ -327,12 +425,6 @@ export const CardDisplayComponent: React.FC = ({ onClick?.(); }; - const handleCardClick = (data: any) => { - if (componentConfig.onCardClick) { - componentConfig.onCardClick(data); - } - }; - // DOM 안전한 props만 필터링 (filterDOMProps 유틸리티 사용) const safeDomProps = filterDOMProps(props); @@ -421,67 +513,75 @@ export const CardDisplayComponent: React.FC = ({ ? getColumnValue(data, componentConfig.columnMapping.imageColumn) : data.avatar || data.image || ""; + const cardKey = getCardKey(data, index); + const isCardSelected = selectedRows.has(cardKey); + return (
handleCardClick(data)} + key={cardKey} + style={{ + ...cardStyle, + borderColor: isCardSelected ? "#000" : "#e5e7eb", + borderWidth: isCardSelected ? "2px" : "1px", + boxShadow: isCardSelected + ? "0 4px 6px -1px rgba(0, 0, 0, 0.15)" + : "0 1px 3px rgba(0, 0, 0, 0.08)", + }} + className="card-hover group cursor-pointer transition-all duration-150" + onClick={() => handleCardClick(data, index)} > - {/* 카드 이미지 - 통일된 디자인 */} + {/* 카드 이미지 */} {componentConfig.cardStyle?.showImage && componentConfig.columnMapping?.imageColumn && ( -
-
- 👤 +
+
+ 👤
)} - {/* 카드 타이틀 - 통일된 디자인 */} - {componentConfig.cardStyle?.showTitle && ( -
-

{titleValue}

+ {/* 카드 타이틀 + 서브타이틀 (가로 배치) */} + {(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && ( +
+ {componentConfig.cardStyle?.showTitle && ( +

{titleValue}

+ )} + {componentConfig.cardStyle?.showSubtitle && subtitleValue && ( + {subtitleValue} + )}
)} - {/* 카드 서브타이틀 - 통일된 디자인 */} - {componentConfig.cardStyle?.showSubtitle && ( -
-

{subtitleValue}

-
- )} - - {/* 카드 설명 - 통일된 디자인 */} + {/* 카드 설명 */} {componentConfig.cardStyle?.showDescription && ( -
-

+

+

{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}

)} - {/* 추가 표시 컬럼들 - 통일된 디자인 */} + {/* 추가 표시 컬럼들 - 가로 배치 */} {componentConfig.columnMapping?.displayColumns && componentConfig.columnMapping.displayColumns.length > 0 && ( -
+
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => { const value = getColumnValue(data, columnName); if (!value) return null; return ( -
- {getColumnLabel(columnName)}: - {value} +
+ {getColumnLabel(columnName)}: + {value}
); })}
)} - {/* 카드 액션 (선택사항) */} -
+ {/* 카드 액션 */} +
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+ ))} +
+ + {/* 추가 버튼 */} + + + {/* 현재 설정 표시 */} + +
+ {(localConfig.linkedFilters || []).length > 0 + ? `${localConfig.linkedFilters.length}개 필터 설정됨` + : "필터 없음 - 우측 화면에 모든 데이터가 표시됩니다"} +
+ + )} + + + + + {/* 데이터 전달 탭 */} + + + + 부모 데이터 자동 전달 + + 좌측 화면에서 행을 선택하면, 우측 화면의 추가/저장 시 지정된 컬럼 값이 자동으로 포함됩니다. + + + + {!localConfig.leftScreenId || !localConfig.rightScreenId ? ( +
+

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

+
+ ) : isLoadingLeftColumns || isLoadingRightColumns ? ( +
+ + 컬럼 정보 로딩 중... +
+ ) : 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) => ( +
+
+ 매핑 #{index + 1} + +
+
+
+ + +
+
+ +
+
+ + +
+
+
+ ))} +
+ + {/* 매핑 추가 버튼 */} + + + {/* 자동 매핑 안내 */} +
+

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

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

+ 수동 매핑 (선택사항): +
+ 컬럼명이 다른 경우에만 위에서 매핑을 추가하세요. +
+ 예: 좌측 user_id → 우측 created_by +

+
+ + )} +
+
+
{/* 설정 요약 */} @@ -343,6 +858,14 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl 크기 조절: {localConfig.resizable ? "가능" : "불가능"}
+
+ 데이터 매핑: + + {(localConfig.parentDataMapping || []).length > 0 + ? `${localConfig.parentDataMapping.length}개 설정` + : "미설정"} + +
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 411e7b78..261fa108 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; // 🆕 REST API 데이터 소스 처리 const isRestApiTable = tableConfig.selectedTable.startsWith("restapi_") || tableConfig.selectedTable.startsWith("_restapi_"); @@ -1122,18 +1183,25 @@ export const TableListComponent: React.FC = ({ referenceTable: col.additionalJoinInfo!.referenceTable, })); - // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) - response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { - page, - size: pageSize, - sortBy, - sortOrder, - search: filters, - enableEntityJoin: true, - additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, - dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 - }); - } + // console.log("🔍 [TableList] API 호출 시작", { + // tableName: tableConfig.selectedTable, + // page, + // pageSize, + // sortBy, + // sortOrder, + // }); + + // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) + response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { + page, + size: pageSize, + sortBy, + sortOrder, + search: hasFilters ? filters : undefined, + enableEntityJoin: true, + additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, + dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달 + }); // 실제 데이터의 item_number만 추출하여 중복 확인 const itemNumbers = (response.data || []).map((item: any) => item.item_number); @@ -1173,6 +1241,7 @@ export const TableListComponent: React.FC = ({ totalItems: response.total || 0, } ); + } } catch (err: any) { console.error("데이터 가져오기 실패:", err); setData([]); @@ -1193,6 +1262,7 @@ export const TableListComponent: React.FC = ({ searchTerm, searchValues, isDesignMode, + splitPanelContext?.selectedLeftData, // 🆕 연결 필터 변경 시 재조회 ]); const fetchTableDataDebounced = useCallback( @@ -1495,6 +1565,22 @@ export const TableListComponent: React.FC = ({ handleRowSelection(rowKey, !isCurrentlySelected); + // 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우) + if (splitPanelContext && splitPanelPosition === "left") { + 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 }); }; @@ -2140,6 +2226,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 235946ce..522f8651 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -261,6 +261,9 @@ export interface ButtonActionContext { // 🆕 컴포넌트별 설정 (parentDataMapping 등) componentConfigs?: Record; // 컴포넌트 ID → 컴포넌트 설정 + + // 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터) + splitPanelParentData?: Record; } /** @@ -561,8 +564,7 @@ export class ButtonActionExecutor { // }); // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) - // console.log("🔍 채번 규칙 할당 체크 시작"); - // console.log("📦 현재 formData:", JSON.stringify(formData, null, 2)); + console.log("🔍 채번 규칙 할당 체크 시작"); const fieldsWithNumbering: Record = {}; @@ -571,26 +573,49 @@ 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 || {}; + if (Object.keys(splitPanelData).length > 0) { + console.log("🔗 [handleSave] 분할 패널 부모 데이터 병합:", splitPanelData); + } const dataWithUserInfo = { - ...formData, + ...splitPanelData, // 분할 패널 부모 데이터 먼저 적용 + ...formData, // 폼 데이터가 우선 (덮어쓰기 가능) writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId created_by: writerValue, // created_by는 항상 로그인한 사람 updated_by: writerValue, // updated_by는 항상 로그인한 사람 @@ -1316,6 +1341,7 @@ export class ButtonActionExecutor { // 🆕 선택된 행 데이터 수집 const selectedData = context.selectedRowsData || []; console.log("📦 [handleModal] 선택된 데이터:", selectedData); + console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData); // 전역 모달 상태 업데이트를 위한 이벤트 발생 const modalEvent = new CustomEvent("openScreenModal", { @@ -1327,6 +1353,8 @@ export class ButtonActionExecutor { // 🆕 선택된 행 데이터 전달 selectedData: selectedData, selectedIds: selectedData.map((row: any) => row.id).filter(Boolean), + // 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용) + splitPanelParentData: context.splitPanelParentData || {}, }, });