화면 분할 패널 기능
This commit is contained in:
parent
30dac204c0
commit
f15846fd10
|
|
@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise<number[]>
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 메뉴와 그 하위 메뉴들의 OBJID 조회
|
||||
*
|
||||
* 형제 메뉴는 포함하지 않고, 선택한 메뉴와 그 자식 메뉴들만 반환합니다.
|
||||
* 채번 규칙 필터링 등 특정 메뉴 계층만 필요할 때 사용합니다.
|
||||
*
|
||||
* @param menuObjid 메뉴 OBJID
|
||||
* @returns 선택한 메뉴 + 모든 하위 메뉴 OBJID 배열 (재귀적)
|
||||
*
|
||||
* @example
|
||||
* // 메뉴 구조:
|
||||
* // └── 구매관리 (100)
|
||||
* // ├── 공급업체관리 (101)
|
||||
* // ├── 발주관리 (102)
|
||||
* // └── 입고관리 (103)
|
||||
* // └── 입고상세 (104)
|
||||
*
|
||||
* await getMenuAndChildObjids(100);
|
||||
* // 결과: [100, 101, 102, 103, 104]
|
||||
*/
|
||||
export async function getMenuAndChildObjids(menuObjid: number): Promise<number[]> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.debug("메뉴 및 하위 메뉴 조회 시작", { menuObjid });
|
||||
|
||||
// 재귀 CTE를 사용하여 선택한 메뉴와 모든 하위 메뉴 조회
|
||||
const query = `
|
||||
WITH RECURSIVE menu_tree AS (
|
||||
-- 시작점: 선택한 메뉴
|
||||
SELECT objid, parent_obj_id, 1 AS depth
|
||||
FROM menu_info
|
||||
WHERE objid = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 재귀: 하위 메뉴들
|
||||
SELECT m.objid, m.parent_obj_id, mt.depth + 1
|
||||
FROM menu_info m
|
||||
INNER JOIN menu_tree mt ON m.parent_obj_id = mt.objid
|
||||
WHERE mt.depth < 10 -- 무한 루프 방지
|
||||
)
|
||||
SELECT objid FROM menu_tree ORDER BY depth, objid
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [menuObjid]);
|
||||
const objids = result.rows.map((row) => Number(row.objid));
|
||||
|
||||
logger.debug("메뉴 및 하위 메뉴 조회 완료", {
|
||||
menuObjid,
|
||||
totalCount: objids.length,
|
||||
objids
|
||||
});
|
||||
|
||||
return objids;
|
||||
} catch (error: any) {
|
||||
logger.error("메뉴 및 하위 메뉴 조회 실패", {
|
||||
menuObjid,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
// 에러 발생 시 안전하게 자기 자신만 반환
|
||||
return [menuObjid];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 메뉴의 형제 메뉴 OBJID 합집합 조회
|
||||
*
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { getSiblingMenuObjids } from "./menuService";
|
||||
import { getMenuAndChildObjids } from "./menuService";
|
||||
|
||||
interface NumberingRulePart {
|
||||
id?: number;
|
||||
|
|
@ -161,7 +161,7 @@ class NumberingRuleService {
|
|||
companyCode: string,
|
||||
menuObjid?: number
|
||||
): Promise<NumberingRuleConfig[]> {
|
||||
let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
|
||||
let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
|
||||
|
||||
try {
|
||||
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
|
||||
|
|
@ -171,14 +171,14 @@ class NumberingRuleService {
|
|||
|
||||
const pool = getPool();
|
||||
|
||||
// 1. 형제 메뉴 OBJID 조회
|
||||
// 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외)
|
||||
if (menuObjid) {
|
||||
siblingObjids = await getSiblingMenuObjids(menuObjid);
|
||||
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
||||
menuAndChildObjids = await getMenuAndChildObjids(menuObjid);
|
||||
logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids });
|
||||
}
|
||||
|
||||
// menuObjid가 없으면 global 규칙만 반환
|
||||
if (!menuObjid || siblingObjids.length === 0) {
|
||||
if (!menuObjid || menuAndChildObjids.length === 0) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
|
|
@ -280,7 +280,7 @@ class NumberingRuleService {
|
|||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함)
|
||||
// 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
|
|
@ -301,8 +301,7 @@ class NumberingRuleService {
|
|||
WHERE
|
||||
scope_type = 'global'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($1))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1))
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
|
||||
|
|
@ -311,10 +310,10 @@ class NumberingRuleService {
|
|||
END,
|
||||
created_at DESC
|
||||
`;
|
||||
params = [siblingObjids];
|
||||
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids });
|
||||
params = [menuAndChildObjids];
|
||||
logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids });
|
||||
} else {
|
||||
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링)
|
||||
// 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
|
|
@ -336,8 +335,7 @@ class NumberingRuleService {
|
|||
AND (
|
||||
scope_type = 'global'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($2))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2))
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
|
|
@ -347,8 +345,8 @@ class NumberingRuleService {
|
|||
END,
|
||||
created_at DESC
|
||||
`;
|
||||
params = [companyCode, siblingObjids];
|
||||
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids });
|
||||
params = [companyCode, menuAndChildObjids];
|
||||
logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids });
|
||||
}
|
||||
|
||||
logger.info("🔍 채번 규칙 쿼리 실행", {
|
||||
|
|
@ -420,7 +418,7 @@ class NumberingRuleService {
|
|||
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
siblingCount: siblingObjids.length,
|
||||
menuAndChildCount: menuAndChildObjids.length,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
|
|
@ -432,7 +430,7 @@ class NumberingRuleService {
|
|||
errorStack: error.stack,
|
||||
companyCode,
|
||||
menuObjid,
|
||||
siblingObjids: siblingObjids || [],
|
||||
menuAndChildObjids: menuAndChildObjids || [],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,24 +18,41 @@ import { logger } from "@/lib/utils/logger";
|
|||
import { applyMappingRules, filterDataByCondition } from "@/lib/utils/dataMapping";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
|
||||
interface EmbeddedScreenProps {
|
||||
embedding: ScreenEmbedding;
|
||||
onSelectionChanged?: (selectedRows: any[]) => void;
|
||||
position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right)
|
||||
}
|
||||
|
||||
/**
|
||||
* 임베드된 화면 컴포넌트
|
||||
*/
|
||||
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
|
||||
({ embedding, onSelectionChanged }, ref) => {
|
||||
({ embedding, onSelectionChanged, position }, ref) => {
|
||||
const [layout, setLayout] = useState<ComponentData[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [screenInfo, setScreenInfo] = useState<any>(null);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({}); // 폼 데이터 상태 추가
|
||||
|
||||
// 컴포넌트 참조 맵
|
||||
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
||||
|
||||
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
|
||||
// 필드 값 변경 핸들러
|
||||
const handleFieldChange = useCallback((fieldName: string, value: any) => {
|
||||
console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value });
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 화면 데이터 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -55,6 +72,12 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 화면 정보 로드
|
||||
const screenResponse = await screenApi.getScreen(embedding.childScreenId);
|
||||
if (screenResponse.success && screenResponse.data) {
|
||||
setScreenInfo(screenResponse.data);
|
||||
}
|
||||
|
||||
// 화면 레이아웃 로드 (별도 API)
|
||||
const layoutData = await screenApi.getLayout(embedding.childScreenId);
|
||||
|
||||
|
|
@ -63,6 +86,7 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
mode: embedding.mode,
|
||||
hasLayoutData: !!layoutData,
|
||||
componentsCount: layoutData?.components?.length || 0,
|
||||
position,
|
||||
});
|
||||
|
||||
if (layoutData && layoutData.components && Array.isArray(layoutData.components)) {
|
||||
|
|
@ -274,27 +298,52 @@ export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenPro
|
|||
);
|
||||
}
|
||||
|
||||
// 화면 렌더링 - 레이아웃 기반 렌더링
|
||||
// 화면 렌더링 - 절대 위치 기반 레이아웃 (원본 화면과 동일하게)
|
||||
// position을 ScreenContextProvider에 전달하여 중첩된 화면에서도 위치를 알 수 있게 함
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto p-4">
|
||||
{layout.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">화면에 컴포넌트가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{layout.map((component) => (
|
||||
<DynamicComponentRenderer
|
||||
key={component.id}
|
||||
component={component}
|
||||
isInteractive={true}
|
||||
screenId={embedding.childScreenId}
|
||||
onSelectionChange={embedding.mode === "select" ? handleSelectionChange : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ScreenContextProvider
|
||||
screenId={embedding.childScreenId}
|
||||
tableName={screenInfo?.tableName}
|
||||
splitPanelPosition={position}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-auto">
|
||||
{layout.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">화면에 컴포넌트가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative" style={{ minHeight: "100%", minWidth: "100%" }}>
|
||||
{layout.map((component) => {
|
||||
const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: compPosition.x || 0,
|
||||
top: compPosition.y || 0,
|
||||
width: size.width || 200,
|
||||
height: size.height || 40,
|
||||
zIndex: compPosition.z || 1,
|
||||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isInteractive={true}
|
||||
screenId={embedding.childScreenId}
|
||||
tableName={screenInfo?.tableName}
|
||||
formData={formData}
|
||||
onFormDataChange={handleFieldChange}
|
||||
onSelectionChange={embedding.mode === "select" ? handleSelectionChange : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScreenContextProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@
|
|||
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { EmbeddedScreen } from "./EmbeddedScreen";
|
||||
import { Columns2 } from "lucide-react";
|
||||
import { SplitPanelProvider } from "@/contexts/SplitPanelContext";
|
||||
|
||||
interface ScreenSplitPanelProps {
|
||||
screenId?: number;
|
||||
|
|
@ -22,7 +23,26 @@ interface ScreenSplitPanelProps {
|
|||
* 순수하게 화면 분할 기능만 제공합니다.
|
||||
*/
|
||||
export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) {
|
||||
const [splitRatio, setSplitRatio] = useState(config?.splitRatio || 50);
|
||||
// config에서 splitRatio 추출 (기본값 50)
|
||||
const configSplitRatio = config?.splitRatio ?? 50;
|
||||
|
||||
console.log("🎯 [ScreenSplitPanel] 렌더링됨!", {
|
||||
screenId,
|
||||
config,
|
||||
leftScreenId: config?.leftScreenId,
|
||||
rightScreenId: config?.rightScreenId,
|
||||
configSplitRatio,
|
||||
configKeys: config ? Object.keys(config) : [],
|
||||
});
|
||||
|
||||
// 드래그로 조절 가능한 splitRatio 상태
|
||||
const [splitRatio, setSplitRatio] = useState(configSplitRatio);
|
||||
|
||||
// config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시)
|
||||
React.useEffect(() => {
|
||||
console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio });
|
||||
setSplitRatio(configSplitRatio);
|
||||
}, [configSplitRatio]);
|
||||
|
||||
// 설정 패널에서 오는 간단한 config를 임베딩 설정으로 변환
|
||||
const leftEmbedding = config?.leftScreenId
|
||||
|
|
@ -60,8 +80,8 @@ export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) {
|
|||
setSplitRatio(Math.max(20, Math.min(80, newRatio)));
|
||||
}, []);
|
||||
|
||||
// config가 없거나 화면 설정이 안 된 경우 (디자이너 모드)
|
||||
if (!config || !leftEmbedding || !rightEmbedding) {
|
||||
// config가 없는 경우 (디자이너 모드 또는 초기 상태)
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="border-muted-foreground/25 flex h-full items-center justify-center rounded-lg border-2 border-dashed">
|
||||
<div className="space-y-4 p-6 text-center">
|
||||
|
|
@ -85,46 +105,71 @@ export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) {
|
|||
);
|
||||
}
|
||||
|
||||
// 좌측 또는 우측 화면이 설정되지 않은 경우 안내 메시지 표시
|
||||
const hasLeftScreen = !!leftEmbedding;
|
||||
const hasRightScreen = !!rightEmbedding;
|
||||
|
||||
// 분할 패널 고유 ID 생성
|
||||
const splitPanelId = useMemo(() => `split-panel-${screenId || "unknown"}-${Date.now()}`, [screenId]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* 좌측 패널 */}
|
||||
<div style={{ width: `${splitRatio}%` }} className="flex-1 overflow-hidden border-r">
|
||||
<EmbeddedScreen embedding={leftEmbedding} />
|
||||
</div>
|
||||
|
||||
{/* 리사이저 */}
|
||||
{config?.resizable !== false && (
|
||||
<div
|
||||
className="group bg-border hover:bg-primary/20 relative w-1 cursor-col-resize transition-colors"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startRatio = splitRatio;
|
||||
const containerWidth = e.currentTarget.parentElement!.offsetWidth;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaRatio = (deltaX / containerWidth) * 100;
|
||||
handleResize(startRatio + deltaRatio);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}}
|
||||
>
|
||||
<div className="bg-primary absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
<SplitPanelProvider
|
||||
splitPanelId={splitPanelId}
|
||||
leftScreenId={config?.leftScreenId || null}
|
||||
rightScreenId={config?.rightScreenId || null}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{/* 좌측 패널 */}
|
||||
<div style={{ width: `${splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden border-r">
|
||||
{hasLeftScreen ? (
|
||||
<EmbeddedScreen embedding={leftEmbedding!} position="left" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||
<p className="text-muted-foreground text-sm">좌측 화면을 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우측 패널 */}
|
||||
<div style={{ width: `${100 - splitRatio}%` }} className="flex-1 overflow-hidden">
|
||||
<EmbeddedScreen embedding={rightEmbedding} />
|
||||
{/* 리사이저 */}
|
||||
{config?.resizable !== false && (
|
||||
<div
|
||||
className="group bg-border hover:bg-primary/20 relative w-1 flex-shrink-0 cursor-col-resize transition-colors"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startRatio = splitRatio;
|
||||
const containerWidth = e.currentTarget.parentElement!.offsetWidth;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaRatio = (deltaX / containerWidth) * 100;
|
||||
handleResize(startRatio + deltaRatio);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}}
|
||||
>
|
||||
<div className="bg-primary absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우측 패널 */}
|
||||
<div style={{ width: `${100 - splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden">
|
||||
{hasRightScreen ? (
|
||||
<EmbeddedScreen embedding={rightEmbedding!} position="right" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||
<p className="text-muted-foreground text-sm">우측 화면을 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SplitPanelProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -527,9 +527,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
// 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화)
|
||||
if (path === "size.width" || path === "size.height" || path === "size") {
|
||||
if (!newComp.style) {
|
||||
newComp.style = {};
|
||||
}
|
||||
// 🔧 style 객체를 새로 복사하여 불변성 유지
|
||||
newComp.style = { ...(newComp.style || {}) };
|
||||
|
||||
if (path === "size.width") {
|
||||
newComp.style.width = `${value}px`;
|
||||
|
|
|
|||
|
|
@ -83,6 +83,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
|
||||
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
|
||||
|
||||
// 🆕 데이터 전달 필드 매핑용 상태
|
||||
const [mappingSourceColumns, setMappingSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({});
|
||||
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({});
|
||||
|
||||
// 🎯 플로우 위젯이 화면에 있는지 확인
|
||||
const hasFlowWidget = useMemo(() => {
|
||||
const found = allComponents.some((comp: any) => {
|
||||
|
|
@ -258,6 +266,58 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const sourceTable = config.action?.dataTransfer?.sourceTable;
|
||||
const targetTable = config.action?.dataTransfer?.targetTable;
|
||||
|
||||
const loadColumns = async () => {
|
||||
if (sourceTable) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`);
|
||||
if (response.data.success) {
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setMappingSourceColumns(columns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("소스 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetTable) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`);
|
||||
if (response.data.success) {
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setMappingTargetColumns(columns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("타겟 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
|
||||
|
||||
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
||||
useEffect(() => {
|
||||
const fetchScreens = async () => {
|
||||
|
|
@ -1607,19 +1667,52 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">📦 데이터 전달 설정</h4>
|
||||
|
||||
{/* 소스 컴포넌트 선택 (Combobox) */}
|
||||
<div>
|
||||
<Label htmlFor="source-component-id">
|
||||
소스 컴포넌트 ID <span className="text-destructive">*</span>
|
||||
<Label>
|
||||
소스 컴포넌트 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="source-component-id"
|
||||
placeholder="예: table-list-1"
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.sourceComponentId || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.sourceComponentId", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.sourceComponentId", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 데이터 제공 가능한 컴포넌트 필터링 */}
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
// 데이터를 제공할 수 있는 컴포넌트 타입들
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(t) => type.includes(t)
|
||||
);
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">({compType})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
{allComponents.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => type.includes(t));
|
||||
}).length === 0 && (
|
||||
<SelectItem value="__none__" disabled>
|
||||
데이터 제공 가능한 컴포넌트가 없습니다
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
데이터를 가져올 컴포넌트의 ID (테이블 등)
|
||||
테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -1636,25 +1729,85 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="component">같은 화면의 컴포넌트</SelectItem>
|
||||
<SelectItem value="screen">다른 화면 (구현 예정)</SelectItem>
|
||||
<SelectItem value="splitPanel">분할 패널 반대편 화면</SelectItem>
|
||||
<SelectItem value="screen" disabled>다른 화면 (구현 예정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{config.action?.dataTransfer?.targetType !== "screen" && (
|
||||
{/* 타겟 컴포넌트 선택 (같은 화면의 컴포넌트일 때만) */}
|
||||
{config.action?.dataTransfer?.targetType === "component" && (
|
||||
<div>
|
||||
<Label htmlFor="target-component-id">
|
||||
타겟 컴포넌트 ID <span className="text-destructive">*</span>
|
||||
<Label>
|
||||
타겟 컴포넌트 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 데이터 수신 가능한 컴포넌트 필터링 (소스와 다른 컴포넌트만) */}
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
// 데이터를 받을 수 있는 컴포넌트 타입들
|
||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(t) => type.includes(t)
|
||||
);
|
||||
// 소스와 다른 컴포넌트만
|
||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">({compType})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
{allComponents.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => type.includes(t));
|
||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||
}).length === 0 && (
|
||||
<SelectItem value="__none__" disabled>
|
||||
데이터 수신 가능한 컴포넌트가 없습니다
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분할 패널 반대편 타겟 설정 */}
|
||||
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
||||
<div>
|
||||
<Label>
|
||||
타겟 컴포넌트 ID (선택사항)
|
||||
</Label>
|
||||
<Input
|
||||
id="target-component-id"
|
||||
placeholder="예: table-list-2"
|
||||
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)}
|
||||
placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
데이터를 받을 컴포넌트의 ID
|
||||
반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -1748,15 +1901,382 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>추가 데이터 소스 (선택사항)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다
|
||||
</p>
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div>
|
||||
<Label className="text-xs">추가 컴포넌트</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.additionalSources?.[0]?.componentId || ""}
|
||||
onValueChange={(value) => {
|
||||
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||
const newSources = [...currentSources];
|
||||
if (newSources.length === 0) {
|
||||
newSources.push({ componentId: value, fieldName: "" });
|
||||
} else {
|
||||
newSources[0] = { ...newSources[0], componentId: value };
|
||||
}
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="추가 데이터 컴포넌트 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__clear__">
|
||||
<span className="text-muted-foreground">선택 안 함</span>
|
||||
</SelectItem>
|
||||
{/* 추가 데이터 제공 가능한 컴포넌트 (조건부 컨테이너, 셀렉트박스 등) */}
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
// 소스/타겟과 다른 컴포넌트 중 값을 제공할 수 있는 타입
|
||||
return ["conditional-container", "select-basic", "select", "combobox"].some(
|
||||
(t) => type.includes(t)
|
||||
);
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.controlLabel || comp.id;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">({compType})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="additional-field-name" className="text-xs">
|
||||
필드명 (선택사항)
|
||||
</Label>
|
||||
<Input
|
||||
id="additional-field-name"
|
||||
placeholder="예: inbound_type (비워두면 전체 데이터)"
|
||||
value={config.action?.dataTransfer?.additionalSources?.[0]?.fieldName || ""}
|
||||
onChange={(e) => {
|
||||
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||
const newSources = [...currentSources];
|
||||
if (newSources.length === 0) {
|
||||
newSources.push({ componentId: "", fieldName: e.target.value });
|
||||
} else {
|
||||
newSources[0] = { ...newSources[0], fieldName: e.target.value };
|
||||
}
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
타겟 테이블에 저장될 필드명
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 규칙 */}
|
||||
<div className="space-y-3">
|
||||
<Label>필드 매핑 설정</Label>
|
||||
|
||||
{/* 소스/타겟 테이블 선택 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">소스 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{config.action?.dataTransfer?.sourceTable
|
||||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.sourceTable)?.label ||
|
||||
config.action?.dataTransfer?.sourceTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={() => {
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.action?.dataTransfer?.sourceTable === table.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="ml-1 text-muted-foreground">({table.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{config.action?.dataTransfer?.targetTable
|
||||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
|
||||
config.action?.dataTransfer?.targetTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={() => {
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="ml-1 text-muted-foreground">({table.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">필드 매핑 규칙</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => {
|
||||
const currentRules = config.action?.dataTransfer?.mappingRules || [];
|
||||
const newRule = { sourceField: "", targetField: "", transform: "" };
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", [...currentRules, newRule]);
|
||||
}}
|
||||
disabled={!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다.
|
||||
</p>
|
||||
|
||||
{(!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable) ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
먼저 소스 테이블과 타겟 테이블을 선택하세요.
|
||||
</p>
|
||||
</div>
|
||||
) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded-md border bg-background p-2">
|
||||
{/* 소스 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={mappingSourcePopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{rule.sourceField
|
||||
? mappingSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
|
||||
: "소스 필드"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
className="h-8 text-xs"
|
||||
value={mappingSourceSearch[index] || ""}
|
||||
onValueChange={(value) => setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mappingSourceColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
||||
rules[index] = { ...rules[index], sourceField: col.name };
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
||||
setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
rule.sourceField === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="ml-1 text-muted-foreground">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
|
||||
{/* 타겟 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={mappingTargetPopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{rule.targetField
|
||||
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
|
||||
: "타겟 필드"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
className="h-8 text-xs"
|
||||
value={mappingTargetSearch[index] || ""}
|
||||
onValueChange={(value) => setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mappingTargetColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
||||
rules[index] = { ...rules[index], targetField: col.name };
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
||||
setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
rule.targetField === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="ml-1 text-muted-foreground">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
||||
rules.splice(index, 1);
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 방법:</strong>
|
||||
<br />
|
||||
1. 소스 컴포넌트에서 데이터를 선택합니다
|
||||
<br />
|
||||
2. 이 버튼을 클릭하면 선택된 데이터가 타겟으로 전달됩니다
|
||||
2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드)
|
||||
<br />
|
||||
3. 매핑 규칙은 추후 고급 설정에서 추가 예정입니다
|
||||
3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -740,6 +740,12 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
const handleConfigChange = (newConfig: WebTypeConfig) => {
|
||||
// 강제 새 객체 생성으로 React 변경 감지 보장
|
||||
const freshConfig = { ...newConfig };
|
||||
console.log("🔧 [DetailSettingsPanel] handleConfigChange 호출:", {
|
||||
widgetId: widget.id,
|
||||
widgetLabel: widget.label,
|
||||
widgetType: widget.widgetType,
|
||||
newConfig: freshConfig,
|
||||
});
|
||||
onUpdateProperty(widget.id, "webTypeConfig", freshConfig);
|
||||
|
||||
// TextTypeConfig의 자동입력 설정을 autoGeneration으로도 매핑
|
||||
|
|
|
|||
|
|
@ -325,41 +325,46 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
currentConfig,
|
||||
});
|
||||
|
||||
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
||||
const ConfigPanelWrapper = () => {
|
||||
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
// componentConfig 전체를 업데이트
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
|
||||
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
|
||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||
|
||||
const handlePanelConfigChange = (newConfig: any) => {
|
||||
// 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합
|
||||
const mergedConfig = {
|
||||
...currentConfig, // 기존 설정 유지
|
||||
...newConfig, // 새 설정 병합
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-sm text-muted-foreground">설정 패널 로딩 중...</div>
|
||||
</div>
|
||||
}>
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onChange={handleConfigChange}
|
||||
tables={tables} // 테이블 정보 전달
|
||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
console.log("🔧 [ConfigPanel] handleConfigChange:", {
|
||||
componentId: selectedComponent.id,
|
||||
currentConfig,
|
||||
newConfig,
|
||||
mergedConfig,
|
||||
});
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", mergedConfig);
|
||||
};
|
||||
|
||||
return <ConfigPanelWrapper key={selectedComponent.id} />;
|
||||
return (
|
||||
<div key={selectedComponent.id} className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-sm text-muted-foreground">설정 패널 로딩 중...</div>
|
||||
</div>
|
||||
}>
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onChange={handlePanelConfigChange}
|
||||
tables={tables} // 테이블 정보 전달
|
||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", {
|
||||
componentId,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -21,6 +22,7 @@ export interface RepeaterInputProps {
|
|||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
menuObjid?: number; // 카테고리 조회용 메뉴 ID
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -34,6 +36,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
disabled = false,
|
||||
readonly = false,
|
||||
className,
|
||||
menuObjid,
|
||||
}) => {
|
||||
// 현재 브레이크포인트 감지
|
||||
const globalBreakpoint = useBreakpoint();
|
||||
|
|
@ -42,6 +45,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
// 미리보기 모달 내에서는 previewBreakpoint 우선 사용
|
||||
const breakpoint = previewBreakpoint || globalBreakpoint;
|
||||
|
||||
// 카테고리 매핑 데이터 (값 -> {label, color})
|
||||
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color: string }>>>({});
|
||||
|
||||
// 설정 기본값
|
||||
const {
|
||||
fields = [],
|
||||
|
|
@ -194,20 +200,77 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
// 개별 필드 렌더링
|
||||
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
||||
const isReadonly = disabled || readonly || field.readonly;
|
||||
|
||||
const commonProps = {
|
||||
value: value || "",
|
||||
disabled: disabled || readonly,
|
||||
disabled: isReadonly,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
};
|
||||
|
||||
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
|
||||
if (field.type === "category") {
|
||||
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
|
||||
|
||||
// field.name을 키로 사용 (테이블 리스트와 동일)
|
||||
const mapping = categoryMappings[field.name];
|
||||
const valueStr = String(value); // 값을 문자열로 변환
|
||||
const categoryData = mapping?.[valueStr];
|
||||
const displayLabel = categoryData?.label || valueStr;
|
||||
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
|
||||
|
||||
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
||||
fieldName: field.name,
|
||||
value: valueStr,
|
||||
mapping,
|
||||
categoryData,
|
||||
displayLabel,
|
||||
displayColor,
|
||||
});
|
||||
|
||||
// 색상이 "none"이면 일반 텍스트로 표시
|
||||
if (displayColor === "none") {
|
||||
return <span className="text-sm">{displayLabel}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: displayColor,
|
||||
borderColor: displayColor,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// 읽기 전용 모드: 텍스트로 표시
|
||||
// displayMode가 "readonly"이면 isReadonly 여부와 관계없이 텍스트로 표시
|
||||
if (field.displayMode === "readonly") {
|
||||
// select 타입인 경우 옵션에서 라벨 찾기
|
||||
if (field.type === "select" && value && field.options) {
|
||||
const option = field.options.find(opt => opt.value === value);
|
||||
return <span className="text-sm">{option?.label || value}</span>;
|
||||
}
|
||||
|
||||
// 일반 텍스트
|
||||
return (
|
||||
<span className="text-sm text-foreground">
|
||||
{value || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={value || ""}
|
||||
onValueChange={(val) => handleFieldChange(itemIndex, field.name, val)}
|
||||
disabled={disabled || readonly}
|
||||
disabled={isReadonly}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
||||
|
|
@ -282,6 +345,63 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 카테고리 매핑 로드 (카테고리 필드가 있을 때 자동 로드)
|
||||
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
||||
useEffect(() => {
|
||||
const categoryFields = fields.filter(f => f.type === "category");
|
||||
if (categoryFields.length === 0) return;
|
||||
|
||||
const loadCategoryMappings = async () => {
|
||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||
|
||||
for (const field of categoryFields) {
|
||||
const columnName = field.name; // 실제 컬럼명
|
||||
const categoryCode = field.categoryCode || columnName;
|
||||
|
||||
// 이미 로드된 경우 스킵
|
||||
if (categoryMappings[columnName]) continue;
|
||||
|
||||
try {
|
||||
// config에서 targetTable 가져오기, 없으면 스킵
|
||||
const tableName = config.targetTable;
|
||||
if (!tableName) {
|
||||
console.warn(`[RepeaterInput] targetTable이 설정되지 않아 카테고리 매핑을 로드할 수 없습니다.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
||||
|
||||
// 테이블 리스트와 동일한 API 사용
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color: string }> = {};
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
// valueCode를 문자열로 변환하여 키로 사용 (테이블 리스트와 동일)
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
label: item.valueLabel || key,
|
||||
color: item.color || "#64748b", // color 필드 사용 (DB 컬럼명과 동일)
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||
|
||||
setCategoryMappings(prev => ({
|
||||
...prev,
|
||||
[columnName]: mapping,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryMappings();
|
||||
}, [fields, config.targetTable]);
|
||||
|
||||
// 필드가 정의되지 않았을 때
|
||||
if (fields.length === 0) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -235,10 +235,23 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
|
||||
const col = column as any;
|
||||
const fieldType = col.input_type || col.inputType || col.webType || col.widgetType || "text";
|
||||
|
||||
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
|
||||
columnName: column.columnName,
|
||||
input_type: col.input_type,
|
||||
inputType: col.inputType,
|
||||
webType: col.webType,
|
||||
widgetType: col.widgetType,
|
||||
finalType: fieldType,
|
||||
});
|
||||
|
||||
updateField(index, {
|
||||
name: column.columnName,
|
||||
label: column.columnLabel || column.columnName,
|
||||
type: (column.widgetType as RepeaterFieldType) || "text",
|
||||
type: fieldType as RepeaterFieldType,
|
||||
});
|
||||
// 로컬 입력 상태도 업데이트
|
||||
setLocalInputs(prev => ({
|
||||
|
|
@ -293,13 +306,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">텍스트</SelectItem>
|
||||
<SelectItem value="number">숫자</SelectItem>
|
||||
<SelectItem value="email">이메일</SelectItem>
|
||||
<SelectItem value="tel">전화번호</SelectItem>
|
||||
<SelectItem value="date">날짜</SelectItem>
|
||||
<SelectItem value="select">선택박스</SelectItem>
|
||||
<SelectItem value="textarea">텍스트영역</SelectItem>
|
||||
{/* 테이블 타입 관리에서 사용하는 input_type 목록 */}
|
||||
<SelectItem value="text">텍스트 (text)</SelectItem>
|
||||
<SelectItem value="number">숫자 (number)</SelectItem>
|
||||
<SelectItem value="textarea">텍스트영역 (textarea)</SelectItem>
|
||||
<SelectItem value="date">날짜 (date)</SelectItem>
|
||||
<SelectItem value="select">선택박스 (select)</SelectItem>
|
||||
<SelectItem value="checkbox">체크박스 (checkbox)</SelectItem>
|
||||
<SelectItem value="radio">라디오 (radio)</SelectItem>
|
||||
<SelectItem value="category">카테고리 (category)</SelectItem>
|
||||
<SelectItem value="entity">엔티티 참조 (entity)</SelectItem>
|
||||
<SelectItem value="code">공통코드 (code)</SelectItem>
|
||||
<SelectItem value="image">이미지 (image)</SelectItem>
|
||||
<SelectItem value="direct">직접입력 (direct)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -316,16 +335,69 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
{/* 카테고리 타입일 때 카테고리 코드 입력 */}
|
||||
{field.type === "category" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">카테고리 코드</Label>
|
||||
<Input
|
||||
value={field.categoryCode || field.name || ""}
|
||||
onChange={(e) => updateField(index, { categoryCode: e.target.value })}
|
||||
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
카테고리 관리에서 설정한 색상으로 배지가 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 타입이 아닐 때만 표시 모드 선택 */}
|
||||
{field.type !== "category" && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">표시 모드</Label>
|
||||
<Select
|
||||
value={field.displayMode || "input"}
|
||||
onValueChange={(value) => updateField(index, { displayMode: value as any })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="input">입력 (편집 가능)</SelectItem>
|
||||
<SelectItem value="readonly">읽기전용 (텍스트)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 pt-5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 타입일 때는 필수만 표시 */}
|
||||
{field.type === "category" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,12 @@
|
|||
import React, { createContext, useContext, useCallback, useRef } from "react";
|
||||
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
||||
import { logger } from "@/lib/utils/logger";
|
||||
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
|
||||
interface ScreenContextValue {
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
||||
|
||||
// 컴포넌트 등록
|
||||
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
|
||||
|
|
@ -33,13 +35,14 @@ const ScreenContext = createContext<ScreenContextValue | null>(null);
|
|||
interface ScreenContextProviderProps {
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 컨텍스트 프로바이더
|
||||
*/
|
||||
export function ScreenContextProvider({ screenId, tableName, children }: ScreenContextProviderProps) {
|
||||
export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, children }: ScreenContextProviderProps) {
|
||||
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
||||
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
||||
|
||||
|
|
@ -79,9 +82,11 @@ export function ScreenContextProvider({ screenId, tableName, children }: ScreenC
|
|||
return new Map(dataReceiversRef.current);
|
||||
}, []);
|
||||
|
||||
const value: ScreenContextValue = {
|
||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||
const value = React.useMemo<ScreenContextValue>(() => ({
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
|
|
@ -90,7 +95,19 @@ export function ScreenContextProvider({ screenId, tableName, children }: ScreenC
|
|||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
};
|
||||
}), [
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
]);
|
||||
|
||||
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
||||
import { logger } from "@/lib/utils/logger";
|
||||
|
||||
/**
|
||||
* 분할 패널 내 화면 위치
|
||||
*/
|
||||
export type SplitPanelPosition = "left" | "right";
|
||||
|
||||
/**
|
||||
* 데이터 수신자 인터페이스
|
||||
*/
|
||||
export interface SplitPanelDataReceiver {
|
||||
componentId: string;
|
||||
componentType: string;
|
||||
receiveData: (data: any[], mode: "append" | "replace" | "merge") => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 컨텍스트 값
|
||||
*/
|
||||
interface SplitPanelContextValue {
|
||||
// 분할 패널 ID
|
||||
splitPanelId: string;
|
||||
|
||||
// 좌측/우측 화면 ID
|
||||
leftScreenId: number | null;
|
||||
rightScreenId: number | null;
|
||||
|
||||
// 데이터 수신자 등록/해제
|
||||
registerReceiver: (position: SplitPanelPosition, componentId: string, receiver: SplitPanelDataReceiver) => void;
|
||||
unregisterReceiver: (position: SplitPanelPosition, componentId: string) => void;
|
||||
|
||||
// 반대편 화면으로 데이터 전달
|
||||
transferToOtherSide: (
|
||||
fromPosition: SplitPanelPosition,
|
||||
data: any[],
|
||||
targetComponentId?: string, // 특정 컴포넌트 지정 (없으면 첫 번째 수신자)
|
||||
mode?: "append" | "replace" | "merge"
|
||||
) => Promise<{ success: boolean; message: string }>;
|
||||
|
||||
// 반대편 화면의 수신자 목록 가져오기
|
||||
getOtherSideReceivers: (fromPosition: SplitPanelPosition) => SplitPanelDataReceiver[];
|
||||
|
||||
// 현재 위치 확인
|
||||
isInSplitPanel: boolean;
|
||||
|
||||
// screenId로 위치 찾기
|
||||
getPositionByScreenId: (screenId: number) => SplitPanelPosition | null;
|
||||
}
|
||||
|
||||
const SplitPanelContext = createContext<SplitPanelContextValue | null>(null);
|
||||
|
||||
interface SplitPanelProviderProps {
|
||||
splitPanelId: string;
|
||||
leftScreenId: number | null;
|
||||
rightScreenId: number | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 컨텍스트 프로바이더
|
||||
*/
|
||||
export function SplitPanelProvider({
|
||||
splitPanelId,
|
||||
leftScreenId,
|
||||
rightScreenId,
|
||||
children,
|
||||
}: SplitPanelProviderProps) {
|
||||
// 좌측/우측 화면의 데이터 수신자 맵
|
||||
const leftReceiversRef = useRef<Map<string, SplitPanelDataReceiver>>(new Map());
|
||||
const rightReceiversRef = useRef<Map<string, SplitPanelDataReceiver>>(new Map());
|
||||
|
||||
// 강제 리렌더링용 상태
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
/**
|
||||
* 데이터 수신자 등록
|
||||
*/
|
||||
const registerReceiver = useCallback(
|
||||
(position: SplitPanelPosition, componentId: string, receiver: SplitPanelDataReceiver) => {
|
||||
const receiversRef = position === "left" ? leftReceiversRef : rightReceiversRef;
|
||||
receiversRef.current.set(componentId, receiver);
|
||||
|
||||
logger.debug(`[SplitPanelContext] 수신자 등록: ${position} - ${componentId}`, {
|
||||
componentType: receiver.componentType,
|
||||
});
|
||||
|
||||
forceUpdate((n) => n + 1);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 데이터 수신자 해제
|
||||
*/
|
||||
const unregisterReceiver = useCallback(
|
||||
(position: SplitPanelPosition, componentId: string) => {
|
||||
const receiversRef = position === "left" ? leftReceiversRef : rightReceiversRef;
|
||||
receiversRef.current.delete(componentId);
|
||||
|
||||
logger.debug(`[SplitPanelContext] 수신자 해제: ${position} - ${componentId}`);
|
||||
|
||||
forceUpdate((n) => n + 1);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 반대편 화면의 수신자 목록 가져오기
|
||||
*/
|
||||
const getOtherSideReceivers = useCallback(
|
||||
(fromPosition: SplitPanelPosition): SplitPanelDataReceiver[] => {
|
||||
const receiversRef = fromPosition === "left" ? rightReceiversRef : leftReceiversRef;
|
||||
return Array.from(receiversRef.current.values());
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 반대편 화면으로 데이터 전달
|
||||
*/
|
||||
const transferToOtherSide = useCallback(
|
||||
async (
|
||||
fromPosition: SplitPanelPosition,
|
||||
data: any[],
|
||||
targetComponentId?: string,
|
||||
mode: "append" | "replace" | "merge" = "append"
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const toPosition = fromPosition === "left" ? "right" : "left";
|
||||
const receiversRef = fromPosition === "left" ? rightReceiversRef : leftReceiversRef;
|
||||
|
||||
logger.info(`[SplitPanelContext] 데이터 전달 시작: ${fromPosition} → ${toPosition}`, {
|
||||
dataCount: data.length,
|
||||
targetComponentId,
|
||||
mode,
|
||||
availableReceivers: Array.from(receiversRef.current.keys()),
|
||||
});
|
||||
|
||||
if (receiversRef.current.size === 0) {
|
||||
const message = `${toPosition === "left" ? "좌측" : "우측"} 화면에 데이터를 받을 수 있는 컴포넌트가 없습니다.`;
|
||||
logger.warn(`[SplitPanelContext] ${message}`);
|
||||
return { success: false, message };
|
||||
}
|
||||
|
||||
try {
|
||||
let targetReceiver: SplitPanelDataReceiver | undefined;
|
||||
|
||||
if (targetComponentId) {
|
||||
// 특정 컴포넌트 지정
|
||||
targetReceiver = receiversRef.current.get(targetComponentId);
|
||||
if (!targetReceiver) {
|
||||
const message = `타겟 컴포넌트 '${targetComponentId}'를 찾을 수 없습니다.`;
|
||||
logger.warn(`[SplitPanelContext] ${message}`);
|
||||
return { success: false, message };
|
||||
}
|
||||
} else {
|
||||
// 첫 번째 수신자 사용
|
||||
targetReceiver = receiversRef.current.values().next().value;
|
||||
}
|
||||
|
||||
if (!targetReceiver) {
|
||||
return { success: false, message: "데이터 수신자를 찾을 수 없습니다." };
|
||||
}
|
||||
|
||||
await targetReceiver.receiveData(data, mode);
|
||||
|
||||
const message = `${data.length}개 항목이 ${toPosition === "left" ? "좌측" : "우측"} 화면으로 전달되었습니다.`;
|
||||
logger.info(`[SplitPanelContext] ${message}`);
|
||||
|
||||
return { success: true, message };
|
||||
} catch (error: any) {
|
||||
const message = error.message || "데이터 전달 중 오류가 발생했습니다.";
|
||||
logger.error(`[SplitPanelContext] 데이터 전달 실패`, error);
|
||||
return { success: false, message };
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* screenId로 위치 찾기
|
||||
*/
|
||||
const getPositionByScreenId = useCallback(
|
||||
(screenId: number): SplitPanelPosition | null => {
|
||||
if (leftScreenId === screenId) return "left";
|
||||
if (rightScreenId === screenId) return "right";
|
||||
return null;
|
||||
},
|
||||
[leftScreenId, rightScreenId]
|
||||
);
|
||||
|
||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||
const value = React.useMemo<SplitPanelContextValue>(() => ({
|
||||
splitPanelId,
|
||||
leftScreenId,
|
||||
rightScreenId,
|
||||
registerReceiver,
|
||||
unregisterReceiver,
|
||||
transferToOtherSide,
|
||||
getOtherSideReceivers,
|
||||
isInSplitPanel: true,
|
||||
getPositionByScreenId,
|
||||
}), [
|
||||
splitPanelId,
|
||||
leftScreenId,
|
||||
rightScreenId,
|
||||
registerReceiver,
|
||||
unregisterReceiver,
|
||||
transferToOtherSide,
|
||||
getOtherSideReceivers,
|
||||
getPositionByScreenId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SplitPanelContext.Provider value={value}>
|
||||
{children}
|
||||
</SplitPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 컨텍스트 훅
|
||||
*/
|
||||
export function useSplitPanelContext() {
|
||||
return useContext(SplitPanelContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 내부인지 확인하는 훅
|
||||
*/
|
||||
export function useIsInSplitPanel(): boolean {
|
||||
const context = useContext(SplitPanelContext);
|
||||
return context?.isInSplitPanel ?? false;
|
||||
}
|
||||
|
||||
|
|
@ -234,6 +234,20 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
});
|
||||
}
|
||||
|
||||
// 🔍 디버깅: text-input 컴포넌트 조회 결과 확인
|
||||
if (componentType === "text-input" || component.id?.includes("text") || (component as any).webType === "text") {
|
||||
console.log("🔍 [DynamicComponentRenderer] text-input 조회:", {
|
||||
componentType,
|
||||
componentId: component.id,
|
||||
componentLabel: component.label,
|
||||
componentConfig: component.componentConfig,
|
||||
webTypeConfig: (component as any).webTypeConfig,
|
||||
autoGeneration: (component as any).autoGeneration,
|
||||
found: !!newComponent,
|
||||
registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id),
|
||||
});
|
||||
}
|
||||
|
||||
if (newComponent) {
|
||||
// 새 컴포넌트 시스템으로 렌더링
|
||||
try {
|
||||
|
|
@ -422,8 +436,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
if (!renderer) {
|
||||
console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, {
|
||||
component: component,
|
||||
componentId: component.id,
|
||||
componentLabel: component.label,
|
||||
componentType: componentType,
|
||||
originalType: component.type,
|
||||
originalComponentType: (component as any).componentType,
|
||||
componentConfig: component.componentConfig,
|
||||
webTypeConfig: (component as any).webTypeConfig,
|
||||
autoGeneration: (component as any).autoGeneration,
|
||||
availableNewComponents: ComponentRegistry.getAllComponents().map((c) => c.id),
|
||||
availableLegacyComponents: legacyComponentRegistry.getRegisteredTypes(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
|||
import { useCurrentFlowStep } from "@/stores/flowStepStore";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||
|
||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||
|
|
@ -100,6 +101,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const screenContext = useScreenContextOptional(); // 화면 컨텍스트
|
||||
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
||||
// 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출)
|
||||
const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined;
|
||||
|
|
@ -395,20 +399,128 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
try {
|
||||
// 1. 소스 컴포넌트에서 데이터 가져오기
|
||||
const sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
||||
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
||||
|
||||
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
|
||||
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
|
||||
if (!sourceProvider) {
|
||||
toast.error(`소스 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.sourceComponentId}`);
|
||||
return;
|
||||
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
|
||||
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
|
||||
|
||||
const allProviders = screenContext.getAllDataProviders();
|
||||
|
||||
// 테이블 리스트 우선 탐색
|
||||
for (const [id, provider] of allProviders) {
|
||||
if (provider.componentType === "table-list") {
|
||||
sourceProvider = provider;
|
||||
console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
|
||||
if (!sourceProvider && allProviders.size > 0) {
|
||||
const firstEntry = allProviders.entries().next().value;
|
||||
if (firstEntry) {
|
||||
sourceProvider = firstEntry[1];
|
||||
console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceProvider) {
|
||||
toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceData = sourceProvider.getSelectedData();
|
||||
const rawSourceData = sourceProvider.getSelectedData();
|
||||
|
||||
// 🆕 배열이 아닌 경우 배열로 변환
|
||||
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : (rawSourceData ? [rawSourceData] : []);
|
||||
|
||||
console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) });
|
||||
|
||||
if (!sourceData || sourceData.length === 0) {
|
||||
toast.warning("선택된 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값)
|
||||
let additionalData: Record<string, any> = {};
|
||||
|
||||
// 방법 1: additionalSources 설정에서 가져오기
|
||||
if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) {
|
||||
for (const additionalSource of dataTransferConfig.additionalSources) {
|
||||
const additionalProvider = screenContext.getDataProvider(additionalSource.componentId);
|
||||
|
||||
if (additionalProvider) {
|
||||
const additionalValues = additionalProvider.getSelectedData();
|
||||
|
||||
if (additionalValues && additionalValues.length > 0) {
|
||||
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
|
||||
const firstValue = additionalValues[0];
|
||||
|
||||
// fieldName이 지정되어 있으면 그 필드만 추출
|
||||
if (additionalSource.fieldName) {
|
||||
additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
||||
} else {
|
||||
// fieldName이 없으면 전체 객체 병합
|
||||
additionalData = { ...additionalData, ...firstValue };
|
||||
}
|
||||
|
||||
console.log("📦 추가 데이터 수집 (additionalSources):", {
|
||||
sourceId: additionalSource.componentId,
|
||||
fieldName: additionalSource.fieldName,
|
||||
value: additionalData[additionalSource.fieldName || 'all'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 방법 2: formData에서 조건부 컨테이너 값 가져오기 (자동)
|
||||
// ConditionalSectionViewer가 __conditionalContainerValue, __conditionalContainerControlField를 formData에 포함시킴
|
||||
if (formData && formData.__conditionalContainerValue) {
|
||||
// includeConditionalValue 설정이 true이거나 설정이 없으면 자동 포함
|
||||
if (dataTransferConfig.includeConditionalValue !== false) {
|
||||
const conditionalValue = formData.__conditionalContainerValue;
|
||||
const conditionalLabel = formData.__conditionalContainerLabel;
|
||||
const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용
|
||||
|
||||
// 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!)
|
||||
if (controlField) {
|
||||
additionalData[controlField] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 자동 매핑:", {
|
||||
controlField,
|
||||
value: conditionalValue,
|
||||
label: conditionalLabel,
|
||||
});
|
||||
} else {
|
||||
// controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
if (value === conditionalValue && !key.startsWith('__')) {
|
||||
additionalData[key] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 자동 포함:", {
|
||||
fieldName: key,
|
||||
value: conditionalValue,
|
||||
label: conditionalLabel,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 못 찾았으면 기본 필드명 사용
|
||||
if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) {
|
||||
additionalData['condition_type'] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 (기본 필드명):", {
|
||||
fieldName: 'condition_type',
|
||||
value: conditionalValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 검증
|
||||
const validation = dataTransferConfig.validation;
|
||||
if (validation) {
|
||||
|
|
@ -430,9 +542,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 4. 매핑 규칙 적용
|
||||
// 4. 매핑 규칙 적용 + 추가 데이터 병합
|
||||
const mappedData = sourceData.map((row) => {
|
||||
return applyMappingRules(row, dataTransferConfig.mappingRules || []);
|
||||
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
|
||||
|
||||
// 추가 데이터를 모든 행에 포함
|
||||
return {
|
||||
...mappedRow,
|
||||
...additionalData,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("📦 데이터 전달:", {
|
||||
|
|
@ -459,13 +577,54 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
mode: dataTransferConfig.mode || "append",
|
||||
mappingRules: dataTransferConfig.mappingRules || [],
|
||||
});
|
||||
|
||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||
} else if (dataTransferConfig.targetType === "splitPanel") {
|
||||
// 🆕 분할 패널의 반대편 화면으로 전달
|
||||
if (!splitPanelContext) {
|
||||
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
|
||||
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
|
||||
// SplitPanelPositionProvider로 전달된 위치를 우선 사용
|
||||
const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
|
||||
|
||||
if (!currentPosition) {
|
||||
toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📦 분할 패널 데이터 전달:", {
|
||||
currentPosition,
|
||||
splitPanelPositionFromHook: splitPanelPosition,
|
||||
screenId,
|
||||
leftScreenId: splitPanelContext.leftScreenId,
|
||||
rightScreenId: splitPanelContext.rightScreenId,
|
||||
});
|
||||
|
||||
const result = await splitPanelContext.transferToOtherSide(
|
||||
currentPosition,
|
||||
mappedData,
|
||||
dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항)
|
||||
dataTransferConfig.mode || "append"
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
return;
|
||||
}
|
||||
} else if (dataTransferConfig.targetType === "screen") {
|
||||
// 다른 화면으로 전달 (구현 예정)
|
||||
toast.info("다른 화면으로의 데이터 전달은 추후 구현 예정입니다.");
|
||||
return;
|
||||
} else {
|
||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||
}
|
||||
|
||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||
|
||||
// 6. 전달 후 정리
|
||||
if (dataTransferConfig.clearAfterTransfer) {
|
||||
sourceProvider.clearSelection();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
import { ConditionalContainerProps, ConditionalSection } from "./types";
|
||||
import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import type { DataProvidable } from "@/types/data-transfer";
|
||||
|
||||
/**
|
||||
* 조건부 컨테이너 컴포넌트
|
||||
|
|
@ -42,6 +44,9 @@ export function ConditionalContainerComponent({
|
|||
onSave, // 🆕 EditModal의 handleSave 콜백
|
||||
}: ConditionalContainerProps) {
|
||||
|
||||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||
const screenContext = useScreenContextOptional();
|
||||
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const controlField = config?.controlField || propControlField || "condition";
|
||||
const controlLabel = config?.controlLabel || propControlLabel || "조건 선택";
|
||||
|
|
@ -50,30 +55,86 @@ export function ConditionalContainerComponent({
|
|||
const showBorder = config?.showBorder ?? propShowBorder ?? true;
|
||||
const spacing = config?.spacing || propSpacing || "normal";
|
||||
|
||||
// 초기값 계산 (한 번만)
|
||||
const initialValue = React.useMemo(() => {
|
||||
return value || formData?.[controlField] || defaultValue || "";
|
||||
}, []); // 의존성 없음 - 마운트 시 한 번만 계산
|
||||
|
||||
// 현재 선택된 값
|
||||
const [selectedValue, setSelectedValue] = useState<string>(
|
||||
value || formData?.[controlField] || defaultValue || ""
|
||||
);
|
||||
const [selectedValue, setSelectedValue] = useState<string>(initialValue);
|
||||
|
||||
// 최신 값을 ref로 유지 (클로저 문제 방지)
|
||||
const selectedValueRef = React.useRef(selectedValue);
|
||||
selectedValueRef.current = selectedValue; // 렌더링마다 업데이트 (useEffect 대신)
|
||||
|
||||
// formData 변경 시 동기화
|
||||
useEffect(() => {
|
||||
if (formData?.[controlField]) {
|
||||
setSelectedValue(formData[controlField]);
|
||||
}
|
||||
}, [formData, controlField]);
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleValueChange = (newValue: string) => {
|
||||
// 콜백 refs (의존성 제거)
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const onFormDataChangeRef = React.useRef(onFormDataChange);
|
||||
onChangeRef.current = onChange;
|
||||
onFormDataChangeRef.current = onFormDataChange;
|
||||
|
||||
// 값 변경 핸들러 - 의존성 없음
|
||||
const handleValueChange = React.useCallback((newValue: string) => {
|
||||
// 같은 값이면 무시
|
||||
if (newValue === selectedValueRef.current) return;
|
||||
|
||||
setSelectedValue(newValue);
|
||||
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
if (onChangeRef.current) {
|
||||
onChangeRef.current(newValue);
|
||||
}
|
||||
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(controlField, newValue);
|
||||
if (onFormDataChangeRef.current) {
|
||||
onFormDataChangeRef.current(controlField, newValue);
|
||||
}
|
||||
};
|
||||
}, [controlField]);
|
||||
|
||||
// sectionsRef 추가 (dataProvider에서 사용)
|
||||
const sectionsRef = React.useRef(sections);
|
||||
React.useEffect(() => {
|
||||
sectionsRef.current = sections;
|
||||
}, [sections]);
|
||||
|
||||
// dataProvider를 useMemo로 감싸서 불필요한 재생성 방지
|
||||
const dataProvider = React.useMemo<DataProvidable>(() => ({
|
||||
componentId: componentId || "conditional-container",
|
||||
componentType: "conditional-container",
|
||||
|
||||
getSelectedData: () => {
|
||||
// ref를 통해 최신 값 참조 (클로저 문제 방지)
|
||||
const currentValue = selectedValueRef.current;
|
||||
const currentSections = sectionsRef.current;
|
||||
return [{
|
||||
[controlField]: currentValue,
|
||||
condition: currentValue,
|
||||
label: currentSections.find(s => s.condition === currentValue)?.label || currentValue,
|
||||
}];
|
||||
},
|
||||
|
||||
getAllData: () => {
|
||||
const currentSections = sectionsRef.current;
|
||||
return currentSections.map(section => ({
|
||||
condition: section.condition,
|
||||
label: section.label,
|
||||
}));
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
// 조건부 컨테이너는 초기화하지 않음
|
||||
console.log("조건부 컨테이너는 선택 초기화를 지원하지 않습니다.");
|
||||
},
|
||||
}), [componentId, controlField]); // selectedValue, sections는 ref로 참조
|
||||
|
||||
// 화면 컨텍스트에 데이터 제공자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && componentId) {
|
||||
screenContext.registerDataProvider(componentId, dataProvider);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataProvider(componentId);
|
||||
};
|
||||
}
|
||||
}, [screenContext, componentId, dataProvider]);
|
||||
|
||||
// 컨테이너 높이 측정용 ref
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -158,6 +219,8 @@ export function ConditionalContainerComponent({
|
|||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
onSave={onSave}
|
||||
controlField={controlField}
|
||||
selectedCondition={selectedValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -179,6 +242,8 @@ export function ConditionalContainerComponent({
|
|||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
onSave={onSave}
|
||||
controlField={controlField}
|
||||
selectedCondition={selectedValue}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,19 +12,38 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Plus, Trash2, GripVertical, Loader2, Check, ChevronsUpDown, Database } from "lucide-react";
|
||||
import { ConditionalContainerConfig, ConditionalSection } from "./types";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getCategoryColumnsByMenu, getCategoryValues, getSecondLevelMenus } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
interface ConditionalContainerConfigPanelProps {
|
||||
config: ConditionalContainerConfig;
|
||||
onConfigChange: (config: ConditionalContainerConfig) => void;
|
||||
onChange?: (config: ConditionalContainerConfig) => void;
|
||||
onConfigChange?: (config: ConditionalContainerConfig) => void;
|
||||
}
|
||||
|
||||
export function ConditionalContainerConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
onConfigChange,
|
||||
}: ConditionalContainerConfigPanelProps) {
|
||||
// onChange 또는 onConfigChange 둘 다 지원
|
||||
const handleConfigChange = onChange || onConfigChange;
|
||||
const [localConfig, setLocalConfig] = useState<ConditionalContainerConfig>({
|
||||
controlField: config.controlField || "condition",
|
||||
controlLabel: config.controlLabel || "조건 선택",
|
||||
|
|
@ -38,6 +57,21 @@ export function ConditionalContainerConfigPanel({
|
|||
const [screens, setScreens] = useState<any[]>([]);
|
||||
const [screensLoading, setScreensLoading] = useState(false);
|
||||
|
||||
// 🆕 메뉴 기반 카테고리 관련 상태
|
||||
const [availableMenus, setAvailableMenus] = useState<Array<{ menuObjid: number; menuName: string; parentMenuName: string; screenCode?: string }>>([]);
|
||||
const [menusLoading, setMenusLoading] = useState(false);
|
||||
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | null>(null);
|
||||
const [menuPopoverOpen, setMenuPopoverOpen] = useState(false);
|
||||
|
||||
const [categoryColumns, setCategoryColumns] = useState<Array<{ columnName: string; columnLabel: string; tableName: string }>>([]);
|
||||
const [categoryColumnsLoading, setCategoryColumnsLoading] = useState(false);
|
||||
const [selectedCategoryColumn, setSelectedCategoryColumn] = useState<string>("");
|
||||
const [selectedCategoryTableName, setSelectedCategoryTableName] = useState<string>("");
|
||||
const [columnPopoverOpen, setColumnPopoverOpen] = useState(false);
|
||||
|
||||
const [categoryValues, setCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [categoryValuesLoading, setCategoryValuesLoading] = useState(false);
|
||||
|
||||
// 화면 목록 로드
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
|
|
@ -56,11 +90,122 @@ export function ConditionalContainerConfigPanel({
|
|||
loadScreens();
|
||||
}, []);
|
||||
|
||||
// 🆕 2레벨 메뉴 목록 로드
|
||||
useEffect(() => {
|
||||
const loadMenus = async () => {
|
||||
setMenusLoading(true);
|
||||
try {
|
||||
const response = await getSecondLevelMenus();
|
||||
console.log("🔍 [ConditionalContainer] 메뉴 목록 응답:", response);
|
||||
if (response.success && response.data) {
|
||||
setAvailableMenus(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("메뉴 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setMenusLoading(false);
|
||||
}
|
||||
};
|
||||
loadMenus();
|
||||
}, []);
|
||||
|
||||
// 🆕 선택된 메뉴의 카테고리 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (!selectedMenuObjid) {
|
||||
setCategoryColumns([]);
|
||||
setSelectedCategoryColumn("");
|
||||
setSelectedCategoryTableName("");
|
||||
setCategoryValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCategoryColumns = async () => {
|
||||
setCategoryColumnsLoading(true);
|
||||
try {
|
||||
console.log("🔍 [ConditionalContainer] 메뉴별 카테고리 컬럼 로드:", selectedMenuObjid);
|
||||
const response = await getCategoryColumnsByMenu(selectedMenuObjid);
|
||||
console.log("✅ [ConditionalContainer] 카테고리 컬럼 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setCategoryColumns(response.data.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
tableName: col.tableName || col.table_name,
|
||||
})));
|
||||
} else {
|
||||
setCategoryColumns([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 컬럼 로드 실패:", error);
|
||||
setCategoryColumns([]);
|
||||
} finally {
|
||||
setCategoryColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
loadCategoryColumns();
|
||||
}, [selectedMenuObjid]);
|
||||
|
||||
// 🆕 선택된 카테고리 컬럼의 값 목록 로드
|
||||
useEffect(() => {
|
||||
if (!selectedCategoryTableName || !selectedCategoryColumn || !selectedMenuObjid) {
|
||||
setCategoryValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCategoryValues = async () => {
|
||||
setCategoryValuesLoading(true);
|
||||
try {
|
||||
console.log("🔍 [ConditionalContainer] 카테고리 값 로드:", selectedCategoryTableName, selectedCategoryColumn, selectedMenuObjid);
|
||||
const response = await getCategoryValues(selectedCategoryTableName, selectedCategoryColumn, false, selectedMenuObjid);
|
||||
console.log("✅ [ConditionalContainer] 카테고리 값 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const values = response.data.map((v: any) => ({
|
||||
value: v.valueCode || v.value_code,
|
||||
label: v.valueLabel || v.value_label || v.valueCode || v.value_code,
|
||||
}));
|
||||
setCategoryValues(values);
|
||||
} else {
|
||||
setCategoryValues([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 값 로드 실패:", error);
|
||||
setCategoryValues([]);
|
||||
} finally {
|
||||
setCategoryValuesLoading(false);
|
||||
}
|
||||
};
|
||||
loadCategoryValues();
|
||||
}, [selectedCategoryTableName, selectedCategoryColumn, selectedMenuObjid]);
|
||||
|
||||
// 🆕 테이블 카테고리에서 섹션 자동 생성
|
||||
const generateSectionsFromCategory = () => {
|
||||
if (categoryValues.length === 0) {
|
||||
alert("먼저 테이블과 카테고리 컬럼을 선택하고 값을 로드해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const newSections: ConditionalSection[] = categoryValues.map((option, index) => ({
|
||||
id: `section_${Date.now()}_${index}`,
|
||||
condition: option.value,
|
||||
label: option.label,
|
||||
screenId: null,
|
||||
screenName: undefined,
|
||||
}));
|
||||
|
||||
updateConfig({
|
||||
sections: newSections,
|
||||
controlField: selectedCategoryColumn, // 카테고리 컬럼명을 제어 필드로 사용
|
||||
});
|
||||
|
||||
alert(`${newSections.length}개의 섹션이 생성되었습니다.`);
|
||||
};
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = (updates: Partial<ConditionalContainerConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
handleConfigChange?.(newConfig);
|
||||
};
|
||||
|
||||
// 새 섹션 추가
|
||||
|
|
@ -134,6 +279,207 @@ export function ConditionalContainerConfigPanel({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 메뉴별 카테고리에서 섹션 자동 생성 */}
|
||||
<div className="space-y-3 p-3 border rounded-lg bg-blue-50/50 dark:bg-blue-950/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-blue-600" />
|
||||
<Label className="text-xs font-semibold text-blue-700 dark:text-blue-400">
|
||||
메뉴 카테고리에서 자동 생성
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 1. 메뉴 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
1. 메뉴 선택
|
||||
</Label>
|
||||
<Popover open={menuPopoverOpen} onOpenChange={setMenuPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={menuPopoverOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={menusLoading}
|
||||
>
|
||||
{menusLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</>
|
||||
) : selectedMenuObjid ? (
|
||||
(() => {
|
||||
const menu = availableMenus.find((m) => m.menuObjid === selectedMenuObjid);
|
||||
return menu ? `${menu.parentMenuName} > ${menu.menuName}` : `메뉴 ${selectedMenuObjid}`;
|
||||
})()
|
||||
) : (
|
||||
"메뉴 선택..."
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[350px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="메뉴 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-xs">메뉴를 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableMenus.map((menu) => (
|
||||
<CommandItem
|
||||
key={menu.menuObjid}
|
||||
value={`${menu.parentMenuName} ${menu.menuName}`}
|
||||
onSelect={() => {
|
||||
setSelectedMenuObjid(menu.menuObjid);
|
||||
setSelectedCategoryColumn("");
|
||||
setSelectedCategoryTableName("");
|
||||
setMenuPopoverOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
selectedMenuObjid === menu.menuObjid ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{menu.parentMenuName} > {menu.menuName}</span>
|
||||
{menu.screenCode && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{menu.screenCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 2. 카테고리 컬럼 선택 */}
|
||||
{selectedMenuObjid && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
2. 카테고리 컬럼 선택
|
||||
</Label>
|
||||
{categoryColumnsLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground h-8 px-3 border rounded">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : categoryColumns.length > 0 ? (
|
||||
<Popover open={columnPopoverOpen} onOpenChange={setColumnPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={columnPopoverOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{selectedCategoryColumn ? (
|
||||
categoryColumns.find((c) => c.columnName === selectedCategoryColumn)?.columnLabel || selectedCategoryColumn
|
||||
) : (
|
||||
"카테고리 컬럼 선택..."
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-xs">카테고리 컬럼이 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{categoryColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={`${col.tableName}.${col.columnName}`}
|
||||
value={col.columnName}
|
||||
onSelect={() => {
|
||||
setSelectedCategoryColumn(col.columnName);
|
||||
setSelectedCategoryTableName(col.tableName);
|
||||
setColumnPopoverOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
selectedCategoryColumn === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{col.columnLabel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{col.tableName}.{col.columnName}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<p className="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
이 메뉴에 설정된 카테고리 컬럼이 없습니다.
|
||||
카테고리 관리에서 먼저 설정해주세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. 카테고리 값 미리보기 */}
|
||||
{selectedCategoryColumn && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
3. 카테고리 값 미리보기
|
||||
</Label>
|
||||
{categoryValuesLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : categoryValues.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categoryValues.map((option) => (
|
||||
<span
|
||||
key={option.value}
|
||||
className="px-2 py-0.5 text-[10px] bg-blue-100 text-blue-800 rounded dark:bg-blue-900 dark:text-blue-200"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
이 컬럼에 등록된 카테고리 값이 없습니다.
|
||||
카테고리 관리에서 값을 먼저 등록해주세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={generateSectionsFromCategory}
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={!selectedCategoryColumn || categoryValues.length === 0 || categoryValuesLoading}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{categoryValues.length > 0 ? `${categoryValues.length}개 섹션 자동 생성` : "섹션 자동 생성"}
|
||||
</Button>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
선택한 메뉴의 카테고리 값들로 조건별 섹션을 자동으로 생성합니다.
|
||||
각 섹션에 표시할 화면은 아래에서 개별 설정하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 조건별 섹션 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export function ConditionalSectionViewer({
|
|||
onFormDataChange,
|
||||
groupedData, // 🆕 그룹 데이터
|
||||
onSave, // 🆕 EditModal의 handleSave 콜백
|
||||
controlField, // 🆕 조건부 컨테이너의 제어 필드명
|
||||
selectedCondition, // 🆕 현재 선택된 조건 값
|
||||
}: ConditionalSectionViewerProps) {
|
||||
const { userId, userName, user } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -34,6 +36,24 @@ export function ConditionalSectionViewer({
|
|||
const [screenInfo, setScreenInfo] = useState<{ id: number; tableName?: string } | null>(null);
|
||||
const [screenResolution, setScreenResolution] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
// 🆕 조건 값을 포함한 formData 생성
|
||||
const enhancedFormData = React.useMemo(() => {
|
||||
const base = formData || {};
|
||||
|
||||
// 조건부 컨테이너의 현재 선택 값을 formData에 포함
|
||||
if (controlField && selectedCondition) {
|
||||
return {
|
||||
...base,
|
||||
[controlField]: selectedCondition,
|
||||
__conditionalContainerValue: selectedCondition,
|
||||
__conditionalContainerLabel: label,
|
||||
__conditionalContainerControlField: controlField, // 🆕 제어 필드명도 포함
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
}, [formData, controlField, selectedCondition, label]);
|
||||
|
||||
// 화면 로드
|
||||
useEffect(() => {
|
||||
if (!screenId) {
|
||||
|
|
@ -154,18 +174,18 @@ export function ConditionalSectionViewer({
|
|||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
component={component}
|
||||
isInteractive={true}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
formData={enhancedFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
onSave={onSave}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -79,5 +79,8 @@ export interface ConditionalSectionViewerProps {
|
|||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터
|
||||
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
|
||||
// 🆕 조건부 컨테이너 정보 (자식 화면에 전달)
|
||||
controlField?: string; // 제어 필드명 (예: "inbound_type")
|
||||
selectedCondition?: string; // 현재 선택된 조건 값 (예: "PURCHASE_IN")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,33 +1,169 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Layers } from "lucide-react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { ComponentDefinition, ComponentCategory, ComponentRendererProps } from "@/types/component";
|
||||
import { RepeaterInput } from "@/components/webtypes/RepeaterInput";
|
||||
import { RepeaterConfigPanel } from "@/components/webtypes/config/RepeaterConfigPanel";
|
||||
import { useScreenContextOptional, DataReceivable } from "@/contexts/ScreenContext";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* Repeater Field Group 컴포넌트
|
||||
*/
|
||||
const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) => {
|
||||
const { component, value, onChange, readonly, disabled } = props;
|
||||
const { component, value, onChange, readonly, disabled, formData, onFormDataChange, menuObjid } = props;
|
||||
const screenContext = useScreenContextOptional();
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
const receiverRef = useRef<DataReceivable | null>(null);
|
||||
|
||||
// 컴포넌트의 필드명 (formData 키)
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
|
||||
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
||||
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||
|
||||
// formData에서 값 가져오기 (value prop보다 우선)
|
||||
const rawValue = formData?.[fieldName] ?? value;
|
||||
|
||||
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
|
||||
fieldName,
|
||||
hasFormData: !!formData,
|
||||
formDataValue: formData?.[fieldName],
|
||||
propsValue: value,
|
||||
rawValue,
|
||||
});
|
||||
|
||||
// 값이 JSON 문자열인 경우 파싱
|
||||
let parsedValue: any[] = [];
|
||||
if (typeof value === "string") {
|
||||
if (typeof rawValue === "string") {
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
parsedValue = JSON.parse(rawValue);
|
||||
} catch {
|
||||
parsedValue = [];
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
parsedValue = value;
|
||||
} else if (Array.isArray(rawValue)) {
|
||||
parsedValue = rawValue;
|
||||
}
|
||||
|
||||
// parsedValue를 ref로 관리하여 최신 값 유지
|
||||
const parsedValueRef = useRef(parsedValue);
|
||||
parsedValueRef.current = parsedValue;
|
||||
|
||||
// onChange를 ref로 관리
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
// onFormDataChange를 ref로 관리
|
||||
const onFormDataChangeRef = useRef(onFormDataChange);
|
||||
onFormDataChangeRef.current = onFormDataChange;
|
||||
|
||||
// fieldName을 ref로 관리
|
||||
const fieldNameRef = useRef(fieldName);
|
||||
fieldNameRef.current = fieldName;
|
||||
|
||||
// 데이터 수신 핸들러
|
||||
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
|
||||
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
toast.warning("전달할 데이터가 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
// 매핑 규칙이 배열인 경우에만 적용
|
||||
let processedData = data;
|
||||
if (Array.isArray(mappingRulesOrMode) && mappingRulesOrMode.length > 0) {
|
||||
processedData = applyMappingRules(data, mappingRulesOrMode);
|
||||
}
|
||||
|
||||
// 데이터 정규화: 각 항목에서 실제 데이터 추출
|
||||
// 데이터가 {0: {...}, inbound_type: "..."} 형태인 경우 처리
|
||||
const normalizedData = processedData.map((item: any) => {
|
||||
// item이 {0: {...실제데이터...}, 추가필드: 값} 형태인 경우
|
||||
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
|
||||
// 0번 인덱스의 데이터와 나머지 필드를 병합
|
||||
const { 0: originalData, ...additionalFields } = item;
|
||||
return { ...originalData, ...additionalFields };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] 정규화된 데이터:", normalizedData);
|
||||
|
||||
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
|
||||
const currentValue = parsedValueRef.current;
|
||||
|
||||
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
||||
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
|
||||
const newItems = mode === "replace" ? normalizedData : [...currentValue, ...normalizedData];
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { currentValue, newItems, mode });
|
||||
|
||||
// JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(newItems);
|
||||
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
|
||||
jsonValue,
|
||||
hasOnChange: !!onChangeRef.current,
|
||||
hasOnFormDataChange: !!onFormDataChangeRef.current,
|
||||
fieldName: fieldNameRef.current,
|
||||
});
|
||||
|
||||
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
|
||||
if (onFormDataChangeRef.current) {
|
||||
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
|
||||
}
|
||||
// 그렇지 않으면 onChange 사용
|
||||
else if (onChangeRef.current) {
|
||||
onChangeRef.current(jsonValue);
|
||||
}
|
||||
|
||||
toast.success(`${normalizedData.length}개 항목이 추가되었습니다`);
|
||||
}, []);
|
||||
|
||||
// DataReceivable 인터페이스 구현
|
||||
const dataReceiver = useMemo<DataReceivable>(() => ({
|
||||
componentId: component.id,
|
||||
componentType: "repeater-field-group",
|
||||
receiveData: handleReceiveData,
|
||||
}), [component.id, handleReceiveData]);
|
||||
|
||||
// ScreenContext에 데이터 수신자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && component.id) {
|
||||
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
|
||||
screenContext.registerDataReceiver(component.id, dataReceiver);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataReceiver(component.id);
|
||||
};
|
||||
}
|
||||
}, [screenContext, component.id, dataReceiver]);
|
||||
|
||||
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
|
||||
useEffect(() => {
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
||||
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
|
||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
|
||||
componentId: component.id,
|
||||
position: splitPanelPosition,
|
||||
});
|
||||
|
||||
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
|
||||
receiverRef.current = dataReceiver;
|
||||
|
||||
return () => {
|
||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
|
||||
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
|
||||
receiverRef.current = null;
|
||||
};
|
||||
}
|
||||
}, [splitPanelContext, screenContext?.splitPanelPosition, component.id, dataReceiver]);
|
||||
|
||||
return (
|
||||
<RepeaterInput
|
||||
value={parsedValue}
|
||||
|
|
@ -39,6 +175,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
config={config}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
menuObjid={menuObjid}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,21 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
|
|||
...config,
|
||||
});
|
||||
|
||||
// config prop이 변경되면 localConfig 동기화
|
||||
useEffect(() => {
|
||||
console.log("🔄 [ScreenSplitPanelConfigPanel] config prop 변경 감지:", config);
|
||||
setLocalConfig({
|
||||
screenId: config.screenId || 0,
|
||||
leftScreenId: config.leftScreenId || 0,
|
||||
rightScreenId: config.rightScreenId || 0,
|
||||
splitRatio: config.splitRatio || 50,
|
||||
resizable: config.resizable ?? true,
|
||||
buttonLabel: config.buttonLabel || "데이터 전달",
|
||||
buttonPosition: config.buttonPosition || "center",
|
||||
...config,
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
// 화면 목록 로드
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
|
|
@ -66,6 +81,13 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
|
|||
};
|
||||
setLocalConfig(newConfig);
|
||||
|
||||
console.log("📝 [ScreenSplitPanelConfigPanel] 설정 변경:", {
|
||||
key,
|
||||
value,
|
||||
newConfig,
|
||||
hasOnChange: !!onChange,
|
||||
});
|
||||
|
||||
// 변경 즉시 부모에게 전달
|
||||
if (onChange) {
|
||||
onChange(newConfig);
|
||||
|
|
|
|||
|
|
@ -66,11 +66,28 @@ class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
|
|||
};
|
||||
|
||||
render() {
|
||||
const { config = {}, style = {} } = this.props;
|
||||
console.log("🚀 [ScreenSplitPanelRenderer] render() 호출됨!", this.props);
|
||||
|
||||
const { component, style = {}, componentConfig, config, screenId } = this.props as any;
|
||||
|
||||
// componentConfig 또는 config 또는 component.componentConfig 사용
|
||||
const finalConfig = componentConfig || config || component?.componentConfig || {};
|
||||
|
||||
console.log("🔍 [ScreenSplitPanelRenderer] 설정 분석:", {
|
||||
hasComponentConfig: !!componentConfig,
|
||||
hasConfig: !!config,
|
||||
hasComponentComponentConfig: !!component?.componentConfig,
|
||||
finalConfig,
|
||||
splitRatio: finalConfig.splitRatio, // 🆕 splitRatio 확인
|
||||
leftScreenId: finalConfig.leftScreenId,
|
||||
rightScreenId: finalConfig.rightScreenId,
|
||||
componentType: component?.componentType,
|
||||
componentId: component?.id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%", ...style }}>
|
||||
<ScreenSplitPanel screenId={config.screenId} config={config} />
|
||||
<ScreenSplitPanel screenId={screenId || finalConfig.screenId} config={finalConfig} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef, useMemo } from "react";
|
|||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
|
||||
import { cn } from "@/lib/registry/components/common/inputStyles";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import type { DataProvidable } from "@/types/data-transfer";
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
|
|
@ -50,6 +52,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
menuObjid, // 🆕 메뉴 OBJID
|
||||
...props
|
||||
}) => {
|
||||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||
const screenContext = useScreenContextOptional();
|
||||
|
||||
// 🚨 최초 렌더링 확인용 (테스트 후 제거)
|
||||
console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", {
|
||||
componentId: component.id,
|
||||
|
|
@ -249,6 +254,47 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
// - 중복 요청 방지: 동일한 queryKey에 대해 자동 중복 제거
|
||||
// - 상태 동기화: 모든 컴포넌트가 같은 캐시 공유
|
||||
|
||||
// 📦 DataProvidable 인터페이스 구현 (데이터 전달 시 셀렉트 값 제공)
|
||||
const dataProvider: DataProvidable = {
|
||||
componentId: component.id,
|
||||
componentType: "select",
|
||||
|
||||
getSelectedData: () => {
|
||||
// 현재 선택된 값을 배열로 반환
|
||||
const fieldName = component.columnName || "selectedValue";
|
||||
return [{
|
||||
[fieldName]: selectedValue,
|
||||
value: selectedValue,
|
||||
label: selectedLabel,
|
||||
}];
|
||||
},
|
||||
|
||||
getAllData: () => {
|
||||
// 모든 옵션 반환
|
||||
const configOptions = config.options || [];
|
||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
setSelectedValue("");
|
||||
setSelectedLabel("");
|
||||
if (isMultiple) {
|
||||
setSelectedValues([]);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 화면 컨텍스트에 데이터 제공자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && component.id) {
|
||||
screenContext.registerDataProvider(component.id, dataProvider);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataProvider(component.id);
|
||||
};
|
||||
}
|
||||
}, [screenContext, component.id, selectedValue, selectedLabel, selectedValues]);
|
||||
|
||||
// 선택된 값에 따른 라벨 업데이트
|
||||
useEffect(() => {
|
||||
const getAllOptions = () => {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import { useTableOptions } from "@/contexts/TableOptionsContext";
|
|||
import { TableFilter, ColumnVisibility } from "@/types/table-options";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
|
||||
|
||||
// ========================================
|
||||
|
|
@ -255,6 +256,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||
const screenContext = useScreenContextOptional();
|
||||
|
||||
// 분할 패널 컨텍스트 (분할 패널 내부에서 데이터 수신자로 등록)
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
||||
// 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
|
||||
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
|
||||
|
||||
// TableOptions Context
|
||||
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
|
||||
|
|
@ -364,6 +373,65 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
||||
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
||||
|
||||
// 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링)
|
||||
useEffect(() => {
|
||||
const linkedFilters = tableConfig.linkedFilters;
|
||||
|
||||
if (!linkedFilters || linkedFilters.length === 0 || !screenContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 연결된 소스 컴포넌트들의 값을 주기적으로 확인
|
||||
const checkLinkedFilters = () => {
|
||||
const newFilterValues: Record<string, any> = {};
|
||||
let hasChanges = false;
|
||||
|
||||
linkedFilters.forEach((filter) => {
|
||||
if (filter.enabled === false) return;
|
||||
|
||||
const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId);
|
||||
if (sourceProvider) {
|
||||
const selectedData = sourceProvider.getSelectedData();
|
||||
if (selectedData && selectedData.length > 0) {
|
||||
const sourceField = filter.sourceField || "value";
|
||||
const value = selectedData[0][sourceField];
|
||||
|
||||
if (value !== linkedFilterValues[filter.targetColumn]) {
|
||||
newFilterValues[filter.targetColumn] = value;
|
||||
hasChanges = true;
|
||||
} else {
|
||||
newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
console.log("🔗 [TableList] 연결된 필터 값 변경:", newFilterValues);
|
||||
setLinkedFilterValues(newFilterValues);
|
||||
|
||||
// searchValues에 연결된 필터 값 병합
|
||||
setSearchValues(prev => ({
|
||||
...prev,
|
||||
...newFilterValues
|
||||
}));
|
||||
|
||||
// 첫 페이지로 이동
|
||||
setCurrentPage(1);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 체크
|
||||
checkLinkedFilters();
|
||||
|
||||
// 주기적으로 체크 (500ms마다)
|
||||
const intervalId = setInterval(checkLinkedFilters, 500);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [screenContext, tableConfig.linkedFilters, linkedFilterValues]);
|
||||
|
||||
// DataProvidable 인터페이스 구현
|
||||
const dataProvider: DataProvidable = {
|
||||
componentId: component.id,
|
||||
|
|
@ -464,6 +532,39 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
};
|
||||
}
|
||||
}, [screenContext, component.id, data, selectedRows]);
|
||||
|
||||
// 분할 패널 컨텍스트에 데이터 수신자로 등록
|
||||
// useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
|
||||
const currentSplitPosition = splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null;
|
||||
|
||||
useEffect(() => {
|
||||
if (splitPanelContext && component.id && currentSplitPosition) {
|
||||
const splitPanelReceiver = {
|
||||
componentId: component.id,
|
||||
componentType: "table-list",
|
||||
receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => {
|
||||
console.log("📥 [TableListComponent] 분할 패널에서 데이터 수신:", {
|
||||
count: incomingData.length,
|
||||
mode,
|
||||
position: currentSplitPosition,
|
||||
});
|
||||
|
||||
await dataReceiver.receiveData(incomingData, {
|
||||
targetComponentId: component.id,
|
||||
targetComponentType: "table-list",
|
||||
mode,
|
||||
mappingRules: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
splitPanelContext.registerReceiver(currentSplitPosition, component.id, splitPanelReceiver);
|
||||
|
||||
return () => {
|
||||
splitPanelContext.unregisterReceiver(currentSplitPosition, component.id);
|
||||
};
|
||||
}
|
||||
}, [splitPanelContext, component.id, currentSplitPosition, dataReceiver]);
|
||||
|
||||
// 테이블 등록 (Context에 등록)
|
||||
const tableId = `table-list-${component.id}`;
|
||||
|
|
|
|||
|
|
@ -1214,6 +1214,114 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 🆕 연결된 필터 설정 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">연결된 필터</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
셀렉트박스 등 다른 컴포넌트의 값으로 테이블 데이터를 실시간 필터링합니다
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* 연결된 필터 목록 */}
|
||||
<div className="space-y-2">
|
||||
{(config.linkedFilters || []).map((filter, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border p-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="소스 컴포넌트 ID"
|
||||
value={filter.sourceComponentId || ""}
|
||||
onChange={(e) => {
|
||||
const newFilters = [...(config.linkedFilters || [])];
|
||||
newFilters[index] = { ...filter, sourceComponentId: e.target.value };
|
||||
handleChange("linkedFilters", newFilters);
|
||||
}}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 flex-1 justify-between text-xs"
|
||||
>
|
||||
{filter.targetColumn || "필터링할 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs py-2">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
onSelect={() => {
|
||||
const newFilters = [...(config.linkedFilters || [])];
|
||||
newFilters[index] = { ...filter, targetColumn: col.columnName };
|
||||
handleChange("linkedFilters", newFilters);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
filter.targetColumn === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{col.label || col.columnName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newFilters = (config.linkedFilters || []).filter((_, i) => i !== index);
|
||||
handleChange("linkedFilters", newFilters);
|
||||
}}
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 연결된 필터 추가 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newFilters = [
|
||||
...(config.linkedFilters || []),
|
||||
{ sourceComponentId: "", targetColumn: "", operator: "equals" as const, enabled: true }
|
||||
];
|
||||
handleChange("linkedFilters", newFilters);
|
||||
}}
|
||||
className="h-7 w-full text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
연결된 필터 추가
|
||||
</Button>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
예: 셀렉트박스(ID: select-basic-123)의 값으로 테이블의 inbound_type 컬럼을 필터링
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -170,6 +170,18 @@ export interface CheckboxConfig {
|
|||
selectAll: boolean; // 전체 선택/해제 버튼 표시 여부
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결된 필터 설정
|
||||
* 다른 컴포넌트(셀렉트박스 등)의 값으로 테이블 데이터를 필터링
|
||||
*/
|
||||
export interface LinkedFilterConfig {
|
||||
sourceComponentId: string; // 소스 컴포넌트 ID (셀렉트박스 등)
|
||||
sourceField?: string; // 소스 컴포넌트에서 가져올 필드명 (기본: value)
|
||||
targetColumn: string; // 필터링할 테이블 컬럼명
|
||||
operator?: "equals" | "contains" | "in"; // 필터 연산자 (기본: equals)
|
||||
enabled?: boolean; // 활성화 여부 (기본: true)
|
||||
}
|
||||
|
||||
/**
|
||||
* TableList 컴포넌트 설정 타입
|
||||
*/
|
||||
|
|
@ -231,6 +243,9 @@ export interface TableListConfig extends ComponentConfig {
|
|||
// 🆕 컬럼 값 기반 데이터 필터링
|
||||
dataFilter?: DataFilterConfig;
|
||||
|
||||
// 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링)
|
||||
linkedFilters?: LinkedFilterConfig[];
|
||||
|
||||
// 이벤트 핸들러
|
||||
onRowClick?: (row: any) => void;
|
||||
onRowDoubleClick?: (row: any) => void;
|
||||
|
|
|
|||
|
|
@ -12,28 +12,56 @@ import { logger } from "./logger";
|
|||
|
||||
/**
|
||||
* 매핑 규칙 적용
|
||||
* @param data 배열 또는 단일 객체
|
||||
* @param rules 매핑 규칙 배열
|
||||
* @returns 매핑된 배열
|
||||
*/
|
||||
export function applyMappingRules(data: any[], rules: MappingRule[]): any[] {
|
||||
if (!data || data.length === 0) {
|
||||
export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[] {
|
||||
// 빈 데이터 처리
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 🆕 배열이 아닌 경우 배열로 변환
|
||||
const dataArray = Array.isArray(data) ? data : [data];
|
||||
|
||||
if (dataArray.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 규칙이 없으면 원본 데이터 반환
|
||||
if (!rules || rules.length === 0) {
|
||||
return dataArray;
|
||||
}
|
||||
|
||||
// 변환 함수가 있는 규칙 확인
|
||||
const hasTransform = rules.some((rule) => rule.transform && rule.transform !== "none");
|
||||
|
||||
if (hasTransform) {
|
||||
// 변환 함수가 있으면 단일 값 또는 집계 결과 반환
|
||||
return [applyTransformRules(data, rules)];
|
||||
return [applyTransformRules(dataArray, rules)];
|
||||
}
|
||||
|
||||
// 일반 매핑 (각 행에 대해 매핑)
|
||||
return data.map((row) => {
|
||||
const mappedRow: any = {};
|
||||
// 🆕 원본 데이터를 복사한 후 매핑 규칙 적용 (매핑되지 않은 필드도 유지)
|
||||
return dataArray.map((row) => {
|
||||
// 원본 데이터 복사
|
||||
const mappedRow: any = { ...row };
|
||||
|
||||
for (const rule of rules) {
|
||||
// sourceField와 targetField가 모두 있어야 매핑 적용
|
||||
if (!rule.sourceField || !rule.targetField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceValue = getNestedValue(row, rule.sourceField);
|
||||
const targetValue = sourceValue ?? rule.defaultValue;
|
||||
|
||||
// 소스 필드와 타겟 필드가 다르면 소스 필드 제거 후 타겟 필드에 설정
|
||||
if (rule.sourceField !== rule.targetField) {
|
||||
delete mappedRow[rule.sourceField];
|
||||
}
|
||||
|
||||
setNestedValue(mappedRow, rule.targetField, targetValue);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,31 @@
|
|||
* 반복 필드 그룹(Repeater) 타입 정의
|
||||
*/
|
||||
|
||||
export type RepeaterFieldType = "text" | "number" | "email" | "tel" | "date" | "select" | "textarea";
|
||||
/**
|
||||
* 테이블 타입 관리(table_type_columns)에서 사용하는 input_type 값들
|
||||
*/
|
||||
export type RepeaterFieldType =
|
||||
| "text" // 텍스트
|
||||
| "number" // 숫자
|
||||
| "textarea" // 텍스트영역
|
||||
| "date" // 날짜
|
||||
| "select" // 선택박스
|
||||
| "checkbox" // 체크박스
|
||||
| "radio" // 라디오
|
||||
| "category" // 카테고리
|
||||
| "entity" // 엔티티 참조
|
||||
| "code" // 공통코드
|
||||
| "image" // 이미지
|
||||
| "direct" // 직접입력
|
||||
| string; // 기타 커스텀 타입 허용
|
||||
|
||||
/**
|
||||
* 필드 표시 모드
|
||||
* - input: 입력 필드로 표시 (편집 가능)
|
||||
* - readonly: 읽기 전용 텍스트로 표시
|
||||
* - (카테고리 타입은 자동으로 배지로 표시됨)
|
||||
*/
|
||||
export type RepeaterFieldDisplayMode = "input" | "readonly";
|
||||
|
||||
/**
|
||||
* 반복 그룹 내 개별 필드 정의
|
||||
|
|
@ -13,8 +37,11 @@ export interface RepeaterFieldDefinition {
|
|||
type: RepeaterFieldType; // 입력 타입
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
readonly?: boolean; // 읽기 전용 여부
|
||||
options?: Array<{ label: string; value: string }>; // select용
|
||||
width?: string; // 필드 너비 (예: "200px", "50%")
|
||||
displayMode?: RepeaterFieldDisplayMode; // 표시 모드: input(입력), readonly(읽기전용)
|
||||
categoryCode?: string; // category 타입일 때 사용할 카테고리 코드
|
||||
validation?: {
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
|
|
|
|||
Loading…
Reference in New Issue