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:
commit
6982635acd
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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); // 신규 등록 모드
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
{/* 좌측 패널 */}
|
||||
|
|
|
|||
|
|
@ -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, 연결필터 추가
|
||||
);
|
||||
|
||||
// 현재 사용자 정보 로드
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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에서 오버라이드됨
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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, // 🆕 화면 정보
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
// 확인이 필요한 액션인지 확인
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 재생성으로 인한 무한 루프 방지
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || {},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue