Merge pull request 'feature/screen-management' (#238) from feature/screen-management into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/238
This commit is contained in:
kjs 2025-12-02 18:08:48 +09:00
commit 6982635acd
14 changed files with 1203 additions and 142 deletions

View File

@ -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}

View File

@ -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<ScreenModalProps> = ({ className }) => {
const { userId, userName, user } = useAuth();
const splitPanelContext = useSplitPanelContext();
const [modalState, setModalState] = useState<ScreenModalState>({
isOpen: false,
@ -132,7 +134,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ 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<ScreenModalProps> = ({ 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); // 신규 등록 모드
}

View File

@ -91,6 +91,21 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
}
}, [initialFormData]);
// 🆕 분할 패널에서 좌측 선택 데이터가 변경되면 우측 화면의 formData에 자동 반영
useEffect(() => {
// 우측 화면인 경우에만 적용
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);

View File

@ -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 || []}
>
<div className="flex h-full">
{/* 좌측 패널 */}

View File

@ -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<InteractiveDataTableProps> = ({
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { user } = useAuth(); // 사용자 정보 가져오기
const { registerTable, unregisterTable } = useTableOptions(); // Context 훅
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
@ -575,12 +577,72 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setLoading(true);
try {
console.log("🔍 데이터 조회 시작:", { tableName: component.tableName, page, pageSize });
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
let linkedFilterValues: Record<string, any> = {};
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<string, any> = {};
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<InteractiveDataTableProps> = ({
setLoading(false);
}
},
[component.tableName, pageSize, component.autoFilter], // 🆕 autoFilter 추가
[component.tableName, pageSize, component.autoFilter, splitPanelContext?.selectedLeftData], // 🆕 autoFilter, 연결필터 추가
);
// 현재 사용자 정보 로드

View File

@ -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<InteractiveScreenViewerPro
}) => {
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<InteractiveScreenViewerPro
// 팝업 전용 formData 상태
const [popupFormData, setPopupFormData] = useState<Record<string, any>>({});
// 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(

View File

@ -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에서 오버라이드됨

View File

@ -17,6 +17,24 @@ export interface SplitPanelDataReceiver {
receiveData: (data: any[], mode: "append" | "replace" | "merge") => Promise<void>;
}
/**
*
*
*/
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<string, any> | null;
setSelectedLeftData: (data: Record<string, any> | null) => void;
// 🆕 부모 데이터 매핑 설정
parentDataMapping: ParentDataMapping[];
// 🆕 매핑된 부모 데이터 가져오기 (우측 화면 저장 시 사용)
getMappedParentData: () => Record<string, any>;
// 🆕 연결 필터 설정 (좌측 선택 → 우측 테이블 필터링)
linkedFilters: LinkedFilter[];
// 🆕 연결 필터 값 가져오기 (우측 테이블 조회 시 사용)
getLinkedFilterValues: () => Record<string, any>;
}
const SplitPanelContext = createContext<SplitPanelContextValue | null>(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<Set<string>>(new Set());
// 🆕 좌측에서 선택된 데이터 상태
const [selectedLeftData, setSelectedLeftData] = useState<Record<string, any> | null>(null);
/**
*
@ -232,6 +273,82 @@ export function SplitPanelProvider({
logger.debug(`[SplitPanelContext] 항목 ID 초기화`);
}, []);
/**
* 🆕
*/
const handleSetSelectedLeftData = useCallback((data: Record<string, any> | null) => {
logger.info(`[SplitPanelContext] 좌측 선택 데이터 설정:`, {
hasData: !!data,
dataKeys: data ? Object.keys(data) : [],
});
setSelectedLeftData(data);
}, []);
/**
* 🆕
*
*
* :
* 1. ( )
* 2. ( )
*/
const getMappedParentData = useCallback((): Record<string, any> => {
if (!selectedLeftData) {
return {};
}
const mappedData: Record<string, any> = {};
// 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<string, any> => {
if (!selectedLeftData || linkedFilters.length === 0) {
return {};
}
const filterValues: Record<string, any> = {};
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<SplitPanelContextValue>(() => ({
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 (

View File

@ -374,6 +374,11 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
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<DynamicComponentRendererProps> =
formData,
onFormDataChange,
onChange: handleChange, // 개선된 onChange 핸들러 전달
tableName,
// 🆕 엔티티 검색 컴포넌트는 componentConfig.tableName 유지, 그 외는 화면 테이블명 사용
tableName: useConfigTableName ? (component.componentConfig?.tableName || tableName) : tableName,
menuId, // 🆕 메뉴 ID
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
selectedScreen, // 🆕 화면 정보

View File

@ -692,6 +692,25 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
effectiveScreenId,
});
// 🆕 분할 패널 부모 데이터 가져오기 (우측 화면에서 저장 시 좌측 선택 데이터 포함)
// 조건 완화: splitPanelContext가 있고 selectedLeftData가 있으면 가져옴
// (탭 안에서도 분할 패널 컨텍스트에 접근 가능하도록)
let splitPanelParentData: Record<string, any> | 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<ButtonPrimaryComponentProps> = ({
flowSelectedStepId,
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
componentConfigs,
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
splitPanelParentData,
} as ButtonActionContext;
// 확인이 필요한 액션인지 확인

View File

@ -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<CardDisplayComponentProps> = ({
tableColumns = [],
...props
}) => {
// 컨텍스트 (선택적 - 디자인 모드에서는 없을 수 있음)
const screenContext = useScreenContextOptional();
const splitPanelContext = useSplitPanelContext();
const splitPanelPosition = screenContext?.splitPanelPosition;
// 테이블 데이터 상태 관리
const [loadedTableData, setLoadedTableData] = useState<any[]>([]);
const [loadedTableColumns, setLoadedTableColumns] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
// 선택된 카드 상태 (Set으로 변경하여 테이블 리스트와 동일하게)
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
// 상세보기 모달 상태
const [viewModalOpen, setViewModalOpen] = useState(false);
const [selectedData, setSelectedData] = useState<any>(null);
@ -196,38 +207,132 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
// 표시할 데이터 결정 (로드된 테이블 데이터 우선 사용)
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<CardDisplayComponentProps> = ({
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<CardDisplayComponentProps> = ({
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<CardDisplayComponentProps> = ({
? getColumnValue(data, componentConfig.columnMapping.imageColumn)
: data.avatar || data.image || "";
const cardKey = getCardKey(data, index);
const isCardSelected = selectedRows.has(cardKey);
return (
<div
key={data.id || index}
style={cardStyle}
className="card-hover group cursor-pointer"
onClick={() => 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 && (
<div className="mb-4 flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-primary/10 to-primary/20 shadow-sm border-2 border-background">
<span className="text-2xl text-primary">👤</span>
<div className="mb-2 flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
<span className="text-lg text-primary">👤</span>
</div>
</div>
)}
{/* 카드 타이틀 - 통일된 디자인 */}
{componentConfig.cardStyle?.showTitle && (
<div className="mb-3">
<h3 className="text-xl font-bold text-foreground leading-tight">{titleValue}</h3>
{/* 카드 타이틀 + 서브타이틀 (가로 배치) */}
{(componentConfig.cardStyle?.showTitle || componentConfig.cardStyle?.showSubtitle) && (
<div className="mb-2 flex items-center gap-2 flex-wrap">
{componentConfig.cardStyle?.showTitle && (
<h3 className="text-base font-semibold text-foreground leading-tight">{titleValue}</h3>
)}
{componentConfig.cardStyle?.showSubtitle && subtitleValue && (
<span className="text-xs font-medium text-primary bg-primary/10 px-2 py-0.5 rounded-full">{subtitleValue}</span>
)}
</div>
)}
{/* 카드 서브타이틀 - 통일된 디자인 */}
{componentConfig.cardStyle?.showSubtitle && (
<div className="mb-3">
<p className="text-sm font-semibold text-primary bg-primary/10 px-3 py-1 rounded-full inline-block">{subtitleValue}</p>
</div>
)}
{/* 카드 설명 - 통일된 디자인 */}
{/* 카드 설명 */}
{componentConfig.cardStyle?.showDescription && (
<div className="mb-4 flex-1">
<p className="text-sm leading-relaxed text-foreground bg-muted p-3 rounded-lg">
<div className="mb-2 flex-1">
<p className="text-xs text-muted-foreground leading-relaxed">
{truncateText(descriptionValue, componentConfig.cardStyle?.maxDescriptionLength || 100)}
</p>
</div>
)}
{/* 추가 표시 컬럼들 - 통일된 디자인 */}
{/* 추가 표시 컬럼들 - 가로 배치 */}
{componentConfig.columnMapping?.displayColumns &&
componentConfig.columnMapping.displayColumns.length > 0 && (
<div className="space-y-2 border-t border-border pt-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-0.5 border-t border-border pt-2 text-xs">
{componentConfig.columnMapping.displayColumns.map((columnName, idx) => {
const value = getColumnValue(data, columnName);
if (!value) return null;
return (
<div key={idx} className="flex justify-between items-center text-sm bg-background/50 px-3 py-2 rounded-lg border border-border">
<span className="text-muted-foreground font-medium capitalize">{getColumnLabel(columnName)}:</span>
<span className="font-semibold text-foreground bg-muted px-2 py-1 rounded-md text-xs">{value}</span>
<div key={idx} className="flex items-center gap-1">
<span className="text-muted-foreground">{getColumnLabel(columnName)}:</span>
<span className="font-medium text-foreground">{value}</span>
</div>
);
})}
</div>
)}
{/* 카드 액션 (선택사항) */}
<div className="mt-3 flex justify-end space-x-2">
{/* 카드 액션 */}
<div className="mt-2 flex justify-end space-x-2">
<button
className="text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
className="text-xs text-blue-600 hover:text-blue-800 transition-colors"
onClick={(e) => {
e.stopPropagation();
handleCardView(data);
@ -490,7 +590,7 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
</button>
<button
className="text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
handleCardEdit(data);

View File

@ -9,11 +9,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Settings, Layout, ArrowRight, Database, Loader2, Check, ChevronsUpDown } from "lucide-react";
import { Settings, Layout, ArrowRight, Database, Loader2, Check, ChevronsUpDown, Plus, Trash2, Link2 } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
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, LinkedFilter } from "@/contexts/SplitPanelContext";
interface ScreenSplitPanelConfigPanelProps {
config: any;
@ -29,6 +31,18 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
const [leftOpen, setLeftOpen] = useState(false);
const [rightOpen, setRightOpen] = useState(false);
// 좌측 화면의 테이블 컬럼 목록
const [leftScreenColumns, setLeftScreenColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
const [isLoadingLeftColumns, setIsLoadingLeftColumns] = useState(false);
// 우측 화면의 테이블 컬럼 목록 (테이블별로 그룹화)
const [rightScreenTables, setRightScreenTables] = useState<Array<{
tableName: string;
screenName: string;
columns: Array<{ columnName: string; columnLabel: string }>
}>>([]);
const [isLoadingRightColumns, setIsLoadingRightColumns] = useState(false);
const [localConfig, setLocalConfig] = useState({
screenId: config.screenId || 0,
leftScreenId: config.leftScreenId || 0,
@ -37,6 +51,8 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
resizable: config.resizable ?? true,
buttonLabel: config.buttonLabel || "데이터 전달",
buttonPosition: config.buttonPosition || "center",
parentDataMapping: config.parentDataMapping || [] as ParentDataMapping[],
linkedFilters: config.linkedFilters || [] as LinkedFilter[],
...config,
});
@ -51,10 +67,165 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
resizable: config.resizable ?? true,
buttonLabel: config.buttonLabel || "데이터 전달",
buttonPosition: config.buttonPosition || "center",
parentDataMapping: config.parentDataMapping || [],
linkedFilters: config.linkedFilters || [],
...config,
});
}, [config]);
// 좌측 화면이 변경되면 해당 화면의 테이블 컬럼 로드
useEffect(() => {
const loadLeftScreenColumns = async () => {
if (!localConfig.leftScreenId) {
setLeftScreenColumns([]);
return;
}
try {
setIsLoadingLeftColumns(true);
// 좌측 화면 정보 조회
const screenData = await screenApi.getScreen(localConfig.leftScreenId);
if (!screenData?.tableName) {
console.warn("좌측 화면에 테이블이 설정되지 않았습니다.");
setLeftScreenColumns([]);
return;
}
// 테이블 컬럼 조회
const columnsResponse = await getTableColumns(screenData.tableName);
if (columnsResponse.success && columnsResponse.data?.columns) {
const columns = columnsResponse.data.columns.map((col: any) => ({
columnName: col.column_name || col.columnName,
columnLabel: col.column_label || col.columnLabel || col.column_name || col.columnName,
}));
setLeftScreenColumns(columns);
console.log("📋 좌측 화면 컬럼 로드 완료:", columns.length);
}
} catch (error) {
console.error("좌측 화면 컬럼 로드 실패:", error);
setLeftScreenColumns([]);
} finally {
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<number>();
// 컴포넌트에서 임베드된 화면 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 () => {
@ -94,17 +265,77 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
}
};
// 부모 데이터 매핑 추가
const addParentDataMapping = () => {
const newMapping: ParentDataMapping = {
sourceColumn: "",
targetColumn: "",
};
const newMappings = [...(localConfig.parentDataMapping || []), newMapping];
updateConfig("parentDataMapping", newMappings);
};
// 부모 데이터 매핑 수정
const updateParentDataMapping = (index: number, field: keyof ParentDataMapping, value: string) => {
const newMappings = [...(localConfig.parentDataMapping || [])];
newMappings[index] = {
...newMappings[index],
[field]: value,
};
updateConfig("parentDataMapping", newMappings);
};
// 부모 데이터 매핑 삭제
const removeParentDataMapping = (index: number) => {
const newMappings = (localConfig.parentDataMapping || []).filter((_: any, i: number) => i !== index);
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 (
<div className="space-y-4">
<Tabs defaultValue="layout" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="layout" className="gap-2">
<Layout className="h-4 w-4" />
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="layout" className="gap-1 text-xs">
<Layout className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="screens" className="gap-2">
<Database className="h-4 w-4" />
<TabsTrigger value="screens" className="gap-1 text-xs">
<Database className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="linkedFilter" className="gap-1 text-xs">
<Link2 className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="dataMapping" className="gap-1 text-xs">
<ArrowRight className="h-3 w-3" />
</TabsTrigger>
</TabsList>
@ -295,7 +526,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
💡 <strong> :</strong> ,
방법: ,
"transferData" .
<br />
(), , .
@ -306,6 +537,290 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
</CardContent>
</Card>
</TabsContent>
{/* 연결 필터 탭 */}
<TabsContent value="linkedFilter" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription className="text-xs">
, .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!localConfig.leftScreenId || !localConfig.rightScreenId ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
"화면" / .
</p>
</div>
) : isLoadingLeftColumns || isLoadingRightColumns ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs"> ...</span>
</div>
) : leftScreenColumns.length === 0 || rightScreenTables.length === 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
{leftScreenColumns.length === 0 && "좌측 화면에 테이블이 설정되지 않았습니다. "}
{rightScreenTables.length === 0 && "우측 화면에 테이블이 설정되지 않았습니다."}
</p>
</div>
) : (
<>
{/* 연결 필터 설명 */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950">
<p className="text-xs text-blue-800 dark:text-blue-200">
: 좌측에서 .
<br />
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">equipment_code</code>
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded">equipment_code</code>
</p>
</div>
{/* 필터 목록 */}
<div className="space-y-3">
{(localConfig.linkedFilters || []).map((filter: LinkedFilter, index: number) => (
<div key={index} className="rounded-lg border bg-gray-50 p-3 dark:bg-gray-900">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-gray-700"> #{index + 1}</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeLinkedFilter(index)}
className="h-6 w-6 p-0 text-red-500 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs text-gray-600"> ( )</Label>
<Select
value={filter.sourceColumn}
onValueChange={(value) => updateLinkedFilter(index, "sourceColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{leftScreenColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-center">
<ArrowRight className="h-4 w-4 rotate-90 text-gray-400" />
</div>
<div>
<Label className="text-xs text-gray-600"> ( )</Label>
<Select
value={filter.targetColumn}
onValueChange={(value) => updateLinkedFilter(index, "targetColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블.컬럼 선택" />
</SelectTrigger>
<SelectContent>
{rightScreenTables.map((table) => (
<React.Fragment key={table.tableName}>
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500 bg-gray-100 dark:bg-gray-800">
{table.screenName} ({table.tableName})
</div>
{table.columns.map((col) => (
<SelectItem
key={`${table.tableName}.${col.columnName}`}
value={`${table.tableName}.${col.columnName}`}
className="text-xs"
>
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</React.Fragment>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
))}
</div>
{/* 추가 버튼 */}
<Button
variant="outline"
size="sm"
onClick={addLinkedFilter}
className="w-full text-xs"
>
<Plus className="mr-2 h-3 w-3" />
</Button>
{/* 현재 설정 표시 */}
<Separator />
<div className="text-xs text-muted-foreground">
{(localConfig.linkedFilters || []).length > 0
? `${localConfig.linkedFilters.length}개 필터 설정됨`
: "필터 없음 - 우측 화면에 모든 데이터가 표시됩니다"}
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
{/* 데이터 전달 탭 */}
<TabsContent value="dataMapping" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription className="text-xs">
, / .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!localConfig.leftScreenId || !localConfig.rightScreenId ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
"화면 설정" / .
</p>
</div>
) : isLoadingLeftColumns || isLoadingRightColumns ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs"> ...</span>
</div>
) : leftScreenColumns.length === 0 || rightScreenTables.length === 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
{leftScreenColumns.length === 0 && "좌측 화면에 테이블이 설정되지 않았습니다. "}
{rightScreenTables.length === 0 && "우측 화면에 테이블이 설정되지 않았습니다."}
</p>
</div>
) : (
<>
{/* 우측 화면 테이블 목록 표시 */}
<div className="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-900 dark:bg-green-950">
<p className="text-xs font-medium text-green-800 dark:text-green-200 mb-1">
({rightScreenTables.length}):
</p>
<ul className="text-xs text-green-700 dark:text-green-300 space-y-0.5">
{rightScreenTables.map((table) => (
<li key={table.tableName}> {table.screenName}: <code className="bg-green-100 dark:bg-green-900 px-1 rounded">{table.tableName}</code></li>
))}
</ul>
</div>
{/* 매핑 목록 */}
<div className="space-y-3">
{(localConfig.parentDataMapping || []).map((mapping: ParentDataMapping, index: number) => (
<div key={index} className="rounded-lg border bg-gray-50 p-3 dark:bg-gray-900">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-gray-700"> #{index + 1}</span>
<Button
variant="ghost"
size="sm"
onClick={() => removeParentDataMapping(index)}
className="h-6 w-6 p-0 text-red-500 hover:bg-red-50 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs text-gray-600"> ( )</Label>
<Select
value={mapping.sourceColumn}
onValueChange={(value) => updateParentDataMapping(index, "sourceColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{leftScreenColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-center">
<ArrowRight className="h-4 w-4 rotate-90 text-gray-400" />
</div>
<div>
<Label className="text-xs text-gray-600"> ( )</Label>
<Select
value={mapping.targetColumn}
onValueChange={(value) => updateParentDataMapping(index, "targetColumn", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블.컬럼 선택" />
</SelectTrigger>
<SelectContent>
{rightScreenTables.map((table) => (
<React.Fragment key={table.tableName}>
<div className="px-2 py-1.5 text-xs font-semibold text-gray-500 bg-gray-100 dark:bg-gray-800">
{table.screenName} ({table.tableName})
</div>
{table.columns.map((col) => (
<SelectItem
key={`${table.tableName}.${col.columnName}`}
value={col.columnName}
className="text-xs pl-4"
>
{col.columnLabel} ({col.columnName})
</SelectItem>
))}
</React.Fragment>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
))}
</div>
{/* 매핑 추가 버튼 */}
<Button
variant="outline"
size="sm"
onClick={addParentDataMapping}
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
</Button>
{/* 자동 매핑 안내 */}
<div className="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-900 dark:bg-green-950">
<p className="text-xs text-green-800 dark:text-green-200">
<strong> :</strong> .
<br />
(: equipment_code) .
</p>
</div>
{/* 수동 매핑 안내 */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-900 dark:bg-blue-950">
<p className="text-xs text-blue-800 dark:text-blue-200">
<strong> ():</strong>
<br />
.
<br />
: 좌측 <code className="bg-blue-100 px-1 rounded">user_id</code> <code className="bg-blue-100 px-1 rounded">created_by</code>
</p>
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 설정 요약 */}
@ -343,6 +858,14 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{localConfig.resizable ? "가능" : "불가능"}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">
{(localConfig.parentDataMapping || []).length > 0
? `${localConfig.parentDataMapping.length}개 설정`
: "미설정"}
</span>
</div>
</div>
</CardContent>
</Card>

View File

@ -1075,7 +1075,68 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const sortBy = sortColumn || undefined;
const sortOrder = sortDirection;
const search = searchTerm || undefined;
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
// 🆕 연결 필터 값 가져오기 (분할 패널 내부일 때)
let linkedFilterValues: Record<string, any> = {};
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<TableListComponentProps> = ({
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<TableListComponentProps> = ({
totalItems: response.total || 0,
}
);
}
} catch (err: any) {
console.error("데이터 가져오기 실패:", err);
setData([]);
@ -1193,6 +1262,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
searchTerm,
searchValues,
isDesignMode,
splitPanelContext?.selectedLeftData, // 🆕 연결 필터 변경 시 재조회
]);
const fetchTableDataDebounced = useCallback(
@ -1495,6 +1565,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
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<TableListComponentProps> = ({
refreshKey,
refreshTrigger, // 강제 새로고침 트리거
isDesignMode,
splitPanelContext?.selectedLeftData, // 🆕 좌측 데이터 선택 변경 시 데이터 새로고침
// fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
]);

View File

@ -261,6 +261,9 @@ export interface ButtonActionContext {
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
componentConfigs?: Record<string, any>; // 컴포넌트 ID → 컴포넌트 설정
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
splitPanelParentData?: Record<string, any>;
}
/**
@ -561,8 +564,7 @@ export class ButtonActionExecutor {
// });
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
// console.log("🔍 채번 규칙 할당 체크 시작");
// console.log("📦 현재 formData:", JSON.stringify(formData, null, 2));
console.log("🔍 채번 규칙 할당 체크 시작");
const fieldsWithNumbering: Record<string, string> = {};
@ -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 || {},
},
});