Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
09d574fb8a
|
|
@ -107,14 +107,88 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
|||
}
|
||||
|
||||
// 추가 필터 조건 (존재하는 컬럼만)
|
||||
// 지원 연산자: =, !=, >, <, >=, <=, in, notIn, like
|
||||
// 특수 키 형식: column__operator (예: division__in, name__like)
|
||||
const additionalFilter = JSON.parse(filterCondition as string);
|
||||
for (const [key, value] of Object.entries(additionalFilter)) {
|
||||
if (existingColumns.has(key)) {
|
||||
whereConditions.push(`${key} = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
} else {
|
||||
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key });
|
||||
// 특수 키 형식 파싱: column__operator
|
||||
let columnName = key;
|
||||
let operator = "=";
|
||||
|
||||
if (key.includes("__")) {
|
||||
const parts = key.split("__");
|
||||
columnName = parts[0];
|
||||
operator = parts[1] || "=";
|
||||
}
|
||||
|
||||
if (!existingColumns.has(columnName)) {
|
||||
logger.warn("엔티티 검색: 필터 조건에 존재하지 않는 컬럼 제외", { tableName, key, columnName });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 연산자별 WHERE 조건 생성
|
||||
switch (operator) {
|
||||
case "=":
|
||||
whereConditions.push(`"${columnName}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "!=":
|
||||
whereConditions.push(`"${columnName}" != $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case ">":
|
||||
whereConditions.push(`"${columnName}" > $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "<":
|
||||
whereConditions.push(`"${columnName}" < $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case ">=":
|
||||
whereConditions.push(`"${columnName}" >= $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "<=":
|
||||
whereConditions.push(`"${columnName}" <= $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "in":
|
||||
// IN 연산자: 값이 배열이거나 쉼표로 구분된 문자열
|
||||
const inValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||
if (inValues.length > 0) {
|
||||
const placeholders = inValues.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||
whereConditions.push(`"${columnName}" IN (${placeholders})`);
|
||||
params.push(...inValues);
|
||||
paramIndex += inValues.length;
|
||||
}
|
||||
break;
|
||||
case "notIn":
|
||||
// NOT IN 연산자
|
||||
const notInValues = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||
if (notInValues.length > 0) {
|
||||
const placeholders = notInValues.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||
whereConditions.push(`"${columnName}" NOT IN (${placeholders})`);
|
||||
params.push(...notInValues);
|
||||
paramIndex += notInValues.length;
|
||||
}
|
||||
break;
|
||||
case "like":
|
||||
whereConditions.push(`"${columnName}"::text ILIKE $${paramIndex}`);
|
||||
params.push(`%${value}%`);
|
||||
paramIndex++;
|
||||
break;
|
||||
default:
|
||||
// 알 수 없는 연산자는 등호로 처리
|
||||
whereConditions.push(`"${columnName}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -775,7 +775,8 @@ export async function getTableData(
|
|||
const userField = autoFilter?.userField || "companyCode";
|
||||
const userValue = (req.user as any)[userField];
|
||||
|
||||
if (userValue) {
|
||||
// 🆕 최고 관리자(company_code = '*')는 모든 회사 데이터 조회 가능
|
||||
if (userValue && userValue !== "*") {
|
||||
enhancedSearch[filterColumn] = userValue;
|
||||
|
||||
logger.info("🔍 현재 사용자 필터 적용:", {
|
||||
|
|
@ -784,6 +785,10 @@ export async function getTableData(
|
|||
userValue,
|
||||
tableName,
|
||||
});
|
||||
} else if (userValue === "*") {
|
||||
logger.info("🔓 최고 관리자 - 회사 필터 미적용 (모든 회사 데이터 조회)", {
|
||||
tableName,
|
||||
});
|
||||
} else {
|
||||
logger.warn("⚠️ 사용자 정보 필드 값 없음:", {
|
||||
userField,
|
||||
|
|
@ -792,6 +797,9 @@ export async function getTableData(
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 최종 검색 조건 로그
|
||||
logger.info(`🔍 최종 검색 조건 (enhancedSearch):`, JSON.stringify(enhancedSearch));
|
||||
|
||||
// 데이터 조회
|
||||
const result = await tableManagementService.getTableData(tableName, {
|
||||
page: parseInt(page),
|
||||
|
|
|
|||
|
|
@ -55,3 +55,4 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -51,3 +51,4 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -67,3 +67,4 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -55,3 +55,4 @@ export default router;
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,13 @@ export class AdminService {
|
|||
}
|
||||
);
|
||||
|
||||
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
||||
// TODO: 권한 체크 다시 활성화 필요
|
||||
logger.info(
|
||||
`⚠️ [임시 비활성화] 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
||||
);
|
||||
|
||||
/* [원본 코드 - 권한 그룹 체크]
|
||||
if (userType === "COMPANY_ADMIN") {
|
||||
// 회사 관리자: 권한 그룹 기반 필터링 적용
|
||||
if (userRoleGroups.length > 0) {
|
||||
|
|
@ -141,6 +148,7 @@ export class AdminService {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
*/
|
||||
} else if (
|
||||
menuType !== undefined &&
|
||||
userType === "SUPER_ADMIN" &&
|
||||
|
|
@ -412,6 +420,15 @@ export class AdminService {
|
|||
let queryParams: any[] = [userLang];
|
||||
let paramIndex = 2;
|
||||
|
||||
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
||||
// TODO: 권한 체크 다시 활성화 필요
|
||||
logger.info(
|
||||
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
||||
);
|
||||
authFilter = "";
|
||||
unionFilter = "";
|
||||
|
||||
/* [원본 코드 - getUserMenuList 권한 그룹 체크]
|
||||
if (userType === "SUPER_ADMIN") {
|
||||
// SUPER_ADMIN: 권한 그룹 체크 없이 해당 회사의 모든 메뉴 표시
|
||||
logger.info(`✅ 좌측 사이드바 (SUPER_ADMIN): 회사 ${userCompanyCode}의 모든 메뉴 표시`);
|
||||
|
|
@ -471,6 +488,7 @@ export class AdminService {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// 2. 회사별 필터링 조건 생성
|
||||
let companyFilter = "";
|
||||
|
|
|
|||
|
|
@ -587,3 +587,4 @@ const result = await executeNodeFlow(flowId, {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -360,3 +360,4 @@
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -346,3 +346,4 @@ const getComponentValue = (componentId: string) => {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -127,3 +127,4 @@ export default function ScreenManagementPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ function ScreenViewPage() {
|
|||
// 편집 모달 이벤트 리스너 등록
|
||||
useEffect(() => {
|
||||
const handleOpenEditModal = (event: CustomEvent) => {
|
||||
console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
||||
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
|
||||
|
||||
setEditModalConfig({
|
||||
screenId: event.detail.screenId,
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// dataSourceId 파라미터 제거
|
||||
currentUrl.searchParams.delete("dataSourceId");
|
||||
window.history.pushState({}, "", currentUrl.toString());
|
||||
console.log("🧹 URL 파라미터 제거");
|
||||
// console.log("🧹 URL 파라미터 제거");
|
||||
}
|
||||
|
||||
setModalState({
|
||||
|
|
@ -277,7 +277,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
setSelectedData([]); // 🆕 선택된 데이터 초기화
|
||||
setContinuousMode(false);
|
||||
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
||||
console.log("🔄 연속 모드 초기화: false");
|
||||
// console.log("🔄 연속 모드 초기화: false");
|
||||
};
|
||||
|
||||
// 저장 성공 이벤트 처리 (연속 등록 모드 지원)
|
||||
|
|
@ -285,36 +285,36 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지)
|
||||
const timeSinceOpen = Date.now() - modalOpenedAtRef.current;
|
||||
if (timeSinceOpen < 500) {
|
||||
console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen });
|
||||
// console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen });
|
||||
return;
|
||||
}
|
||||
|
||||
const isContinuousMode = continuousMode;
|
||||
console.log("💾 저장 성공 이벤트 수신");
|
||||
console.log("📌 현재 연속 모드 상태:", isContinuousMode);
|
||||
console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
|
||||
// console.log("💾 저장 성공 이벤트 수신");
|
||||
// console.log("📌 현재 연속 모드 상태:", isContinuousMode);
|
||||
// console.log("📌 localStorage:", localStorage.getItem("screenModal_continuousMode"));
|
||||
|
||||
if (isContinuousMode) {
|
||||
// 연속 모드: 폼만 초기화하고 모달은 유지
|
||||
console.log("✅ 연속 모드 활성화 - 폼 초기화 및 화면 리셋");
|
||||
// console.log("✅ 연속 모드 활성화 - 폼 초기화 및 화면 리셋");
|
||||
|
||||
// 1. 폼 데이터 초기화
|
||||
setFormData({});
|
||||
|
||||
// 2. 리셋 키 변경 (컴포넌트 강제 리마운트)
|
||||
setResetKey((prev) => prev + 1);
|
||||
console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
|
||||
// console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
|
||||
|
||||
// 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성)
|
||||
if (modalState.screenId) {
|
||||
console.log("🔄 화면 데이터 다시 로드:", modalState.screenId);
|
||||
// console.log("🔄 화면 데이터 다시 로드:", modalState.screenId);
|
||||
loadScreenData(modalState.screenId);
|
||||
}
|
||||
|
||||
toast.success("저장되었습니다. 계속 입력하세요.");
|
||||
} else {
|
||||
// 일반 모드: 모달 닫기
|
||||
console.log("❌ 일반 모드 - 모달 닫기");
|
||||
// console.log("❌ 일반 모드 - 모달 닫기");
|
||||
handleCloseModal();
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -190,14 +190,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
const innerLayoutData = await screenApi.getLayout(section.screenId);
|
||||
saveButton = findSaveButtonInComponents(innerLayoutData?.components || []);
|
||||
if (saveButton) {
|
||||
console.log("[EditModal] 조건부 컨테이너 내부에서 저장 버튼 발견:", {
|
||||
sectionScreenId: section.screenId,
|
||||
sectionLabel: section.label,
|
||||
});
|
||||
// console.log("[EditModal] 조건부 컨테이너 내부에서 저장 버튼 발견:", {
|
||||
// sectionScreenId: section.screenId,
|
||||
// sectionLabel: section.label,
|
||||
// });
|
||||
break;
|
||||
}
|
||||
} catch (innerError) {
|
||||
console.warn("[EditModal] 내부 화면 레이아웃 조회 실패:", section.screenId);
|
||||
// console.warn("[EditModal] 내부 화면 레이아웃 조회 실패:", section.screenId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -207,7 +207,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
if (!saveButton) {
|
||||
console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId);
|
||||
// console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -219,14 +219,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
dataflowConfig: webTypeConfig.dataflowConfig,
|
||||
dataflowTiming: webTypeConfig.dataflowConfig?.flowConfig?.executionTiming || "after",
|
||||
};
|
||||
console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config);
|
||||
// console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config);
|
||||
return config;
|
||||
}
|
||||
|
||||
console.log("[EditModal] 저장 버튼에 제어로직 설정 없음");
|
||||
// console.log("[EditModal] 저장 버튼에 제어로직 설정 없음");
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn("[EditModal] 저장 버튼 설정 조회 실패:", error);
|
||||
// console.warn("[EditModal] 저장 버튼 설정 조회 실패:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -309,16 +309,16 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 🆕 그룹 데이터 조회 함수
|
||||
const loadGroupData = async () => {
|
||||
if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) {
|
||||
console.warn("테이블명 또는 그룹핑 컬럼이 없습니다.");
|
||||
// console.warn("테이블명 또는 그룹핑 컬럼이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("🔍 그룹 데이터 조회 시작:", {
|
||||
tableName: modalState.tableName,
|
||||
groupByColumns: modalState.groupByColumns,
|
||||
editData: modalState.editData,
|
||||
});
|
||||
// console.log("🔍 그룹 데이터 조회 시작:", {
|
||||
// tableName: modalState.tableName,
|
||||
// groupByColumns: modalState.groupByColumns,
|
||||
// editData: modalState.editData,
|
||||
// });
|
||||
|
||||
// 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001")
|
||||
const groupValues: Record<string, any> = {};
|
||||
|
|
@ -329,14 +329,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
});
|
||||
|
||||
if (Object.keys(groupValues).length === 0) {
|
||||
console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns);
|
||||
// console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🔍 그룹 조회 요청:", {
|
||||
tableName: modalState.tableName,
|
||||
groupValues,
|
||||
});
|
||||
// console.log("🔍 그룹 조회 요청:", {
|
||||
// tableName: modalState.tableName,
|
||||
// groupValues,
|
||||
// });
|
||||
|
||||
// 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용)
|
||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||
|
|
@ -347,13 +347,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
enableEntityJoin: true,
|
||||
});
|
||||
|
||||
console.log("🔍 그룹 조회 응답:", response);
|
||||
// console.log("🔍 그룹 조회 응답:", response);
|
||||
|
||||
// entityJoinApi는 배열 또는 { data: [] } 형식으로 반환
|
||||
const dataArray = Array.isArray(response) ? response : response?.data || [];
|
||||
|
||||
if (dataArray.length > 0) {
|
||||
console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
|
||||
// console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
|
||||
setGroupData(dataArray);
|
||||
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
|
||||
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
|
||||
|
|
@ -374,7 +374,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
try {
|
||||
setLoading(true);
|
||||
|
||||
console.log("화면 데이터 로딩 시작:", screenId);
|
||||
// console.log("화면 데이터 로딩 시작:", screenId);
|
||||
|
||||
// 화면 정보와 레이아웃 데이터 로딩
|
||||
const [screenInfo, layoutData] = await Promise.all([
|
||||
|
|
@ -382,7 +382,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
screenApi.getLayout(screenId),
|
||||
]);
|
||||
|
||||
console.log("API 응답:", { screenInfo, layoutData });
|
||||
// console.log("API 응답:", { screenInfo, layoutData });
|
||||
|
||||
if (screenInfo && layoutData) {
|
||||
const components = layoutData.components || [];
|
||||
|
|
@ -395,11 +395,11 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
components,
|
||||
screenInfo: screenInfo,
|
||||
});
|
||||
console.log("화면 데이터 설정 완료:", {
|
||||
componentsCount: components.length,
|
||||
dimensions,
|
||||
screenInfo,
|
||||
});
|
||||
// console.log("화면 데이터 설정 완료:", {
|
||||
// componentsCount: components.length,
|
||||
// dimensions,
|
||||
// screenInfo,
|
||||
// });
|
||||
} else {
|
||||
throw new Error("화면 데이터가 없습니다");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,3 +140,4 @@ export const useActiveTabOptional = () => {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -197,3 +197,4 @@ export function applyAutoFillToFormData(
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -299,6 +299,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 🆕 modalDataStore에서 선택된 데이터 확인 (분할 패널 등에서 저장됨)
|
||||
const [modalStoreData, setModalStoreData] = useState<Record<string, any[]>>({});
|
||||
|
||||
// 🆕 splitPanelContext?.selectedLeftData를 로컬 상태로 추적 (리렌더링 보장)
|
||||
const [trackedSelectedLeftData, setTrackedSelectedLeftData] = useState<Record<string, any> | null>(null);
|
||||
|
||||
// splitPanelContext?.selectedLeftData 변경 감지 및 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
const newData = splitPanelContext?.selectedLeftData ?? null;
|
||||
setTrackedSelectedLeftData(newData);
|
||||
// console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", {
|
||||
// label: component.label,
|
||||
// hasData: !!newData,
|
||||
// dataKeys: newData ? Object.keys(newData) : [],
|
||||
// });
|
||||
}, [splitPanelContext?.selectedLeftData, component.label]);
|
||||
|
||||
// modalDataStore 상태 구독 (실시간 업데이트)
|
||||
useEffect(() => {
|
||||
const actionConfig = component.componentConfig?.action;
|
||||
|
|
@ -357,8 +371,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 2. 분할 패널 좌측 선택 데이터 확인
|
||||
if (rowSelectionSource === "auto" || rowSelectionSource === "splitPanelLeft") {
|
||||
// SplitPanelContext에서 확인
|
||||
if (splitPanelContext?.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0) {
|
||||
// SplitPanelContext에서 확인 (trackedSelectedLeftData 사용으로 리렌더링 보장)
|
||||
if (trackedSelectedLeftData && Object.keys(trackedSelectedLeftData).length > 0) {
|
||||
if (!hasSelection) {
|
||||
hasSelection = true;
|
||||
selectionCount = 1;
|
||||
|
|
@ -397,7 +411,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
selectionCount,
|
||||
selectionSource,
|
||||
hasSplitPanelContext: !!splitPanelContext,
|
||||
selectedLeftData: splitPanelContext?.selectedLeftData,
|
||||
trackedSelectedLeftData: trackedSelectedLeftData,
|
||||
selectedRowsData: selectedRowsData?.length,
|
||||
selectedRows: selectedRows?.length,
|
||||
flowSelectedData: flowSelectedData?.length,
|
||||
|
|
@ -429,7 +443,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
component.label,
|
||||
selectedRows,
|
||||
selectedRowsData,
|
||||
splitPanelContext?.selectedLeftData,
|
||||
trackedSelectedLeftData,
|
||||
flowSelectedData,
|
||||
splitPanelContext,
|
||||
modalStoreData,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ export function useEntitySearch({
|
|||
limit: pagination.limit.toString(),
|
||||
});
|
||||
|
||||
// console.log("[useEntitySearch] 검색 실행:", {
|
||||
// tableName,
|
||||
// filterCondition: filterConditionRef.current,
|
||||
// searchText: text,
|
||||
// });
|
||||
|
||||
const response = await apiClient.get<EntitySearchResponse>(
|
||||
`/entity-search/${tableName}?${params.toString()}`
|
||||
);
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export function ItemSelectionModal({
|
|||
onSelect,
|
||||
columnLabels = {},
|
||||
modalFilters = [],
|
||||
categoryColumns = [],
|
||||
}: ItemSelectionModalProps) {
|
||||
const [localSearchText, setLocalSearchText] = useState("");
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
|
|
@ -41,6 +42,9 @@ export function ItemSelectionModal({
|
|||
|
||||
// 카테고리 옵션 상태 (categoryRef별로 로드된 옵션)
|
||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, { value: string; label: string }[]>>({});
|
||||
|
||||
// 카테고리 코드 → 라벨 매핑 (테이블 데이터 표시용)
|
||||
const [categoryLabelMap, setCategoryLabelMap] = useState<Record<string, string>>({});
|
||||
|
||||
// 모달 필터 값과 기본 filterCondition을 합친 최종 필터 조건
|
||||
const combinedFilterCondition = useMemo(() => {
|
||||
|
|
@ -152,6 +156,54 @@ export function ItemSelectionModal({
|
|||
}
|
||||
}, [modalFilterValues]);
|
||||
|
||||
// 검색 결과가 변경되면 카테고리 값들의 라벨 조회
|
||||
useEffect(() => {
|
||||
const loadCategoryLabels = async () => {
|
||||
if (!open || categoryColumns.length === 0 || results.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 결과에서 카테고리 컬럼의 모든 고유한 값 수집
|
||||
// 쉼표로 구분된 다중 값도 개별적으로 수집
|
||||
const allCodes = new Set<string>();
|
||||
for (const row of results) {
|
||||
for (const col of categoryColumns) {
|
||||
const val = row[col];
|
||||
if (val && typeof val === "string") {
|
||||
// 쉼표로 구분된 다중 값 처리
|
||||
const codes = val.split(",").map((c) => c.trim()).filter(Boolean);
|
||||
for (const code of codes) {
|
||||
if (!categoryLabelMap[code]) {
|
||||
allCodes.add(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allCodes.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post("/table-categories/labels-by-codes", {
|
||||
valueCodes: Array.from(allCodes),
|
||||
});
|
||||
|
||||
if (response.data?.success && response.data.data) {
|
||||
setCategoryLabelMap((prev) => ({
|
||||
...prev,
|
||||
...response.data.data,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 라벨 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryLabels();
|
||||
}, [open, results, categoryColumns]);
|
||||
|
||||
// 모달 필터 값 변경 핸들러
|
||||
const handleModalFilterChange = (column: string, value: any) => {
|
||||
setModalFilterValues((prev) => ({
|
||||
|
|
@ -450,11 +502,25 @@ export function ItemSelectionModal({
|
|||
</div>
|
||||
</td>
|
||||
)}
|
||||
{validColumns.map((col) => (
|
||||
<td key={col} className="px-4 py-2">
|
||||
{item[col] || "-"}
|
||||
</td>
|
||||
))}
|
||||
{validColumns.map((col) => {
|
||||
const rawValue = item[col];
|
||||
// 카테고리 컬럼이면 라벨로 변환
|
||||
const isCategory = categoryColumns.includes(col);
|
||||
let displayValue = rawValue;
|
||||
|
||||
if (isCategory && rawValue && typeof rawValue === "string") {
|
||||
// 쉼표로 구분된 다중 값 처리
|
||||
const codes = rawValue.split(",").map((c) => c.trim()).filter(Boolean);
|
||||
const labels = codes.map((code) => categoryLabelMap[code] || code);
|
||||
displayValue = labels.join(", ");
|
||||
}
|
||||
|
||||
return (
|
||||
<td key={col} className="px-4 py-2">
|
||||
{displayValue || "-"}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -202,4 +202,7 @@ export interface ItemSelectionModalProps {
|
|||
|
||||
// 모달 내부 필터 (사용자 선택 가능)
|
||||
modalFilters?: ModalFilterConfig[];
|
||||
|
||||
// 카테고리 타입 컬럼 목록 (해당 컬럼은 코드 → 라벨로 변환하여 표시)
|
||||
categoryColumns?: string[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { RepeatScreenModalDefinition } from "./index";
|
|||
// 컴포넌트 자동 등록
|
||||
if (typeof window !== "undefined") {
|
||||
ComponentRegistry.registerComponent(RepeatScreenModalDefinition);
|
||||
console.log("✅ RepeatScreenModal 컴포넌트 등록 완료");
|
||||
// console.log("✅ RepeatScreenModal 컴포넌트 등록 완료");
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -205,12 +205,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const splitPanelId = `split-panel-${component.id}`;
|
||||
|
||||
// 디버깅: Context 연결 상태 확인
|
||||
console.log("🔗 [SplitPanelLayout] Context 연결 상태:", {
|
||||
componentId: component.id,
|
||||
splitPanelId,
|
||||
hasRegisterFunc: typeof ctxRegisterSplitPanel === "function",
|
||||
splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음",
|
||||
});
|
||||
// console.log("🔗 [SplitPanelLayout] Context 연결 상태:", {
|
||||
// componentId: component.id,
|
||||
// splitPanelId,
|
||||
// hasRegisterFunc: typeof ctxRegisterSplitPanel === "function",
|
||||
// splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음",
|
||||
// });
|
||||
|
||||
// Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행
|
||||
const ctxRegisterRef = useRef(ctxRegisterSplitPanel);
|
||||
|
|
@ -235,15 +235,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
isDragging: false,
|
||||
};
|
||||
|
||||
console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", {
|
||||
splitPanelId,
|
||||
panelInfo,
|
||||
});
|
||||
// console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", {
|
||||
// splitPanelId,
|
||||
// panelInfo,
|
||||
// });
|
||||
|
||||
ctxRegisterRef.current(splitPanelId, panelInfo);
|
||||
|
||||
return () => {
|
||||
console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId);
|
||||
// console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId);
|
||||
ctxUnregisterRef.current(splitPanelId);
|
||||
};
|
||||
// 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리
|
||||
|
|
@ -311,11 +311,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
// 🆕 그룹별 합산된 데이터 계산
|
||||
const summedLeftData = useMemo(() => {
|
||||
console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig);
|
||||
// console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig);
|
||||
|
||||
// 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환
|
||||
if (!leftGroupSumConfig?.enabled || !leftGroupSumConfig?.groupByColumn) {
|
||||
console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환");
|
||||
// console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환");
|
||||
return leftData;
|
||||
}
|
||||
|
||||
|
|
@ -756,8 +756,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
});
|
||||
|
||||
console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns);
|
||||
console.log("🔗 [분할패널] configuredColumns:", configuredColumns);
|
||||
// console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns);
|
||||
// console.log("🔗 [분할패널] configuredColumns:", configuredColumns);
|
||||
|
||||
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
|
||||
page: 1,
|
||||
|
|
@ -769,10 +769,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
});
|
||||
|
||||
// 🔍 디버깅: API 응답 데이터의 키 확인
|
||||
if (result.data && result.data.length > 0) {
|
||||
console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0]));
|
||||
console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
|
||||
}
|
||||
// if (result.data && result.data.length > 0) {
|
||||
// console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0]));
|
||||
// console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
|
||||
// }
|
||||
|
||||
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
|
||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||
|
|
@ -1000,7 +1000,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
if (leftTableName && !isDesignMode) {
|
||||
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||
useModalDataStore.getState().setData(leftTableName, [item]);
|
||||
console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item);
|
||||
// console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
@ -1198,7 +1198,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
});
|
||||
setLeftColumnLabels(labels);
|
||||
console.log("✅ 좌측 컬럼 라벨 로드:", labels);
|
||||
// console.log("✅ 좌측 컬럼 라벨 로드:", labels);
|
||||
} catch (error) {
|
||||
console.error("좌측 테이블 컬럼 라벨 로드 실패:", error);
|
||||
}
|
||||
|
|
@ -1227,7 +1227,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
});
|
||||
setRightColumnLabels(labels);
|
||||
console.log("✅ 우측 컬럼 라벨 로드:", labels);
|
||||
// console.log("✅ 우측 컬럼 라벨 로드:", labels);
|
||||
} catch (error) {
|
||||
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
|
||||
}
|
||||
|
|
@ -1269,7 +1269,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
};
|
||||
});
|
||||
mappings[columnName] = valueMap;
|
||||
console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
|
||||
// console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`좌측 카테고리 값 조회 실패 [${columnName}]:`, error);
|
||||
|
|
@ -1307,7 +1307,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
});
|
||||
|
||||
console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
|
||||
// console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad));
|
||||
|
||||
// 각 테이블에 대해 카테고리 매핑 로드
|
||||
for (const tableName of tablesToLoad) {
|
||||
|
|
@ -1940,7 +1940,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
useEffect(() => {
|
||||
const handleRefreshTable = () => {
|
||||
if (!isDesignMode) {
|
||||
console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침");
|
||||
// console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침");
|
||||
loadLeftData();
|
||||
// 선택된 항목이 있으면 우측 패널도 새로고침
|
||||
if (selectedLeftItem) {
|
||||
|
|
@ -2104,12 +2104,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
(() => {
|
||||
// 🆕 그룹별 합산된 데이터 사용
|
||||
const dataSource = summedLeftData;
|
||||
console.log(
|
||||
"🔍 [테이블모드 렌더링] dataSource 개수:",
|
||||
dataSource.length,
|
||||
"leftGroupSumConfig:",
|
||||
leftGroupSumConfig,
|
||||
);
|
||||
// console.log(
|
||||
// "🔍 [테이블모드 렌더링] dataSource 개수:",
|
||||
// dataSource.length,
|
||||
// "leftGroupSumConfig:",
|
||||
// leftGroupSumConfig,
|
||||
// );
|
||||
|
||||
// 🔧 로컬 검색 필터 적용
|
||||
const filteredData = leftSearchQuery
|
||||
|
|
|
|||
|
|
@ -2043,7 +2043,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return row.id || row.uuid || `row-${index}`;
|
||||
};
|
||||
|
||||
const handleRowSelection = (rowKey: string, checked: boolean) => {
|
||||
const handleRowSelection = (rowKey: string, checked: boolean, rowData?: any) => {
|
||||
const newSelectedRows = new Set(selectedRows);
|
||||
if (checked) {
|
||||
newSelectedRows.add(rowKey);
|
||||
|
|
@ -2086,6 +2086,31 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장/해제 (체크박스 선택 시에도 작동)
|
||||
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
|
||||
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||
if (checked && selectedRowsData.length > 0) {
|
||||
// 선택된 경우: 첫 번째 선택된 데이터 저장 (또는 전달된 rowData)
|
||||
const dataToStore = rowData || selectedRowsData[selectedRowsData.length - 1];
|
||||
splitPanelContext.setSelectedLeftData(dataToStore);
|
||||
console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 저장:", {
|
||||
rowKey,
|
||||
dataToStore,
|
||||
});
|
||||
} else if (!checked && selectedRowsData.length === 0) {
|
||||
// 모든 선택이 해제된 경우: 데이터 초기화
|
||||
splitPanelContext.setSelectedLeftData(null);
|
||||
console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 초기화");
|
||||
} else if (selectedRowsData.length > 0) {
|
||||
// 일부 선택 해제된 경우: 남은 첫 번째 데이터로 업데이트
|
||||
splitPanelContext.setSelectedLeftData(selectedRowsData[0]);
|
||||
console.log("🔗 [TableList] handleRowSelection - 분할 패널 좌측 데이터 업데이트:", {
|
||||
remainingCount: selectedRowsData.length,
|
||||
firstData: selectedRowsData[0],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const allRowsSelected = filteredData.every((row, index) => newSelectedRows.has(getRowKey(row, index)));
|
||||
setIsAllSelected(allRowsSelected && filteredData.length > 0);
|
||||
};
|
||||
|
|
@ -2155,35 +2180,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const rowKey = getRowKey(row, index);
|
||||
const isCurrentlySelected = selectedRows.has(rowKey);
|
||||
|
||||
handleRowSelection(rowKey, !isCurrentlySelected);
|
||||
|
||||
// 🆕 분할 패널 컨텍스트에 선택된 데이터 저장 (좌측 화면인 경우)
|
||||
// disableAutoDataTransfer가 true이면 자동 전달 비활성화 (버튼 클릭으로만 전달)
|
||||
// currentSplitPosition을 사용하여 정확한 위치 확인 (splitPanelPosition이 없을 수 있음)
|
||||
const effectiveSplitPosition = splitPanelPosition || currentSplitPosition;
|
||||
|
||||
console.log("🔗 [TableList] 행 클릭 - 분할 패널 위치 확인:", {
|
||||
splitPanelPosition,
|
||||
currentSplitPosition,
|
||||
effectiveSplitPosition,
|
||||
hasSplitPanelContext: !!splitPanelContext,
|
||||
disableAutoDataTransfer: splitPanelContext?.disableAutoDataTransfer,
|
||||
});
|
||||
|
||||
if (splitPanelContext && effectiveSplitPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
|
||||
if (!isCurrentlySelected) {
|
||||
// 선택된 경우: 데이터 저장
|
||||
splitPanelContext.setSelectedLeftData(row);
|
||||
console.log("🔗 [TableList] 분할 패널 좌측 데이터 저장:", {
|
||||
row,
|
||||
parentDataMapping: splitPanelContext.parentDataMapping,
|
||||
});
|
||||
} else {
|
||||
// 선택 해제된 경우: 데이터 초기화
|
||||
splitPanelContext.setSelectedLeftData(null);
|
||||
console.log("🔗 [TableList] 분할 패널 좌측 데이터 초기화");
|
||||
}
|
||||
}
|
||||
// handleRowSelection에서 분할 패널 데이터 처리도 함께 수행됨
|
||||
handleRowSelection(rowKey, !isCurrentlySelected, row);
|
||||
|
||||
console.log("행 클릭:", { row, index, isSelected: !isCurrentlySelected });
|
||||
};
|
||||
|
|
@ -3936,7 +3934,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (enterRow) {
|
||||
const rowKey = getRowKey(enterRow, rowIndex);
|
||||
const isCurrentlySelected = selectedRows.has(rowKey);
|
||||
handleRowSelection(rowKey, !isCurrentlySelected);
|
||||
handleRowSelection(rowKey, !isCurrentlySelected, enterRow);
|
||||
}
|
||||
break;
|
||||
case " ": // Space
|
||||
|
|
@ -3946,7 +3944,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
if (spaceRow) {
|
||||
const currentRowKey = getRowKey(spaceRow, rowIndex);
|
||||
const isChecked = selectedRows.has(currentRowKey);
|
||||
handleRowSelection(currentRowKey, !isChecked);
|
||||
handleRowSelection(currentRowKey, !isChecked, spaceRow);
|
||||
}
|
||||
break;
|
||||
case "F2":
|
||||
|
|
@ -4160,7 +4158,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return (
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
|
||||
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean, row)}
|
||||
aria-label={`행 ${index + 1} 선택`}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -381,6 +381,75 @@ export function TableSectionRenderer({
|
|||
const [dynamicOptions, setDynamicOptions] = useState<{ id: string; value: string; label: string }[]>([]);
|
||||
const [dynamicOptionsLoading, setDynamicOptionsLoading] = useState(false);
|
||||
const dynamicOptionsLoadedRef = React.useRef(false);
|
||||
|
||||
// 소스 테이블의 카테고리 타입 컬럼 목록
|
||||
const [sourceCategoryColumns, setSourceCategoryColumns] = useState<string[]>([]);
|
||||
|
||||
// 소스 테이블의 컬럼 라벨 (API에서 동적 로드)
|
||||
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
|
||||
|
||||
// 소스 테이블의 카테고리 타입 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
const loadCategoryColumns = async () => {
|
||||
if (!tableConfig.source.tableName) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${tableConfig.source.tableName}/columns`
|
||||
);
|
||||
|
||||
if (response.data?.success && Array.isArray(response.data.data)) {
|
||||
const categoryColNames = response.data.data.map(
|
||||
(col: { columnName?: string; column_name?: string }) =>
|
||||
col.columnName || col.column_name || ""
|
||||
).filter(Boolean);
|
||||
setSourceCategoryColumns(categoryColNames);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 컬럼 목록 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryColumns();
|
||||
}, [tableConfig.source.tableName]);
|
||||
|
||||
// 소스 테이블의 컬럼 라벨 로드 (source.columnLabels가 비어있을 때만)
|
||||
useEffect(() => {
|
||||
const loadColumnLabels = async () => {
|
||||
const sourceTableName = tableConfig.source.tableName;
|
||||
if (!sourceTableName) return;
|
||||
|
||||
// 이미 source.columnLabels가 설정되어 있으면 스킵
|
||||
if (tableConfig.source.columnLabels && Object.keys(tableConfig.source.columnLabels).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${sourceTableName}/columns`);
|
||||
|
||||
if (response.data?.success && response.data.data) {
|
||||
const columnsData = response.data.data.columns || response.data.data || [];
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
for (const col of columnsData) {
|
||||
const colName = col.column_name || col.columnName;
|
||||
// displayName: API에서 반환하는 라벨 (COALESCE(cl.column_label, c.column_name))
|
||||
const colLabel = col.displayName || col.column_label || col.columnLabel || col.comment;
|
||||
// 라벨이 컬럼명과 다를 때만 저장 (의미있는 라벨인 경우)
|
||||
if (colName && colLabel && colLabel !== colName) {
|
||||
labels[colName] = colLabel;
|
||||
}
|
||||
}
|
||||
|
||||
setSourceColumnLabels(labels);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("소스 테이블 컬럼 라벨 조회 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumnLabels();
|
||||
}, [tableConfig.source.tableName, tableConfig.source.columnLabels]);
|
||||
|
||||
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
|
||||
useEffect(() => {
|
||||
|
|
@ -788,13 +857,22 @@ export function TableSectionRenderer({
|
|||
// 이미 초기화되었으면 스킵
|
||||
if (initialDataLoadedRef.current) return;
|
||||
|
||||
const tableSectionKey = `_tableSection_${sectionId}`;
|
||||
const tableSectionKey = `__tableSection_${sectionId}`;
|
||||
const initialData = formData[tableSectionKey];
|
||||
|
||||
console.log("[TableSectionRenderer] 초기 데이터 확인:", {
|
||||
sectionId,
|
||||
tableSectionKey,
|
||||
hasInitialData: !!initialData,
|
||||
initialDataLength: Array.isArray(initialData) ? initialData.length : 0,
|
||||
formDataKeys: Object.keys(formData).filter(k => k.startsWith("__tableSection_")),
|
||||
});
|
||||
|
||||
if (Array.isArray(initialData) && initialData.length > 0) {
|
||||
console.log("[TableSectionRenderer] 초기 데이터 로드:", {
|
||||
sectionId,
|
||||
itemCount: initialData.length,
|
||||
firstItem: initialData[0],
|
||||
});
|
||||
setTableData(initialData);
|
||||
initialDataLoadedRef.current = true;
|
||||
|
|
@ -1268,7 +1346,12 @@ export function TableSectionRenderer({
|
|||
const sourceTable = source.tableName;
|
||||
const sourceColumns = source.displayColumns;
|
||||
const sourceSearchFields = source.searchColumns;
|
||||
const columnLabels = source.columnLabels || {};
|
||||
// 컬럼 라벨: source.columnLabels가 있으면 우선 사용, 없으면 동적 로드된 라벨 사용
|
||||
const columnLabels = useMemo(() => {
|
||||
const configLabels = source.columnLabels || {};
|
||||
// 설정된 라벨이 있으면 설정 우선, 없으면 API에서 로드한 라벨 사용
|
||||
return { ...sourceColumnLabels, ...configLabels };
|
||||
}, [source.columnLabels, sourceColumnLabels]);
|
||||
const modalTitle = uiConfig?.modalTitle || "항목 검색 및 선택";
|
||||
const multiSelect = uiConfig?.multiSelect ?? true;
|
||||
|
||||
|
|
@ -1281,16 +1364,25 @@ export function TableSectionRenderer({
|
|||
const addRowButtonText = uiConfig?.addRowButtonText || "직접 입력";
|
||||
|
||||
// 기본 필터 조건 생성 (사전 필터만 - 모달 필터는 ItemSelectionModal에서 처리)
|
||||
// 연산자별로 특수 키 형식 사용: column__operator (예: division__in)
|
||||
const baseFilterCondition: Record<string, any> = useMemo(() => {
|
||||
const condition: Record<string, any> = {};
|
||||
if (filters?.preFilters) {
|
||||
for (const filter of filters.preFilters) {
|
||||
// 간단한 "=" 연산자만 처리 (확장 가능)
|
||||
if (filter.operator === "=") {
|
||||
if (!filter.column || filter.value === undefined || filter.value === "") continue;
|
||||
|
||||
const operator = filter.operator || "=";
|
||||
|
||||
if (operator === "=") {
|
||||
// 기본 등호 연산자는 그대로 전달
|
||||
condition[filter.column] = filter.value;
|
||||
} else {
|
||||
// 다른 연산자는 특수 키 형식 사용: column__operator
|
||||
condition[`${filter.column}__${operator}`] = filter.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
// console.log("[TableSectionRenderer] baseFilterCondition:", condition, "preFilters:", filters?.preFilters);
|
||||
return condition;
|
||||
}, [filters?.preFilters]);
|
||||
|
||||
|
|
@ -1892,6 +1984,7 @@ export function TableSectionRenderer({
|
|||
onSelect={handleConditionalAddItems}
|
||||
columnLabels={columnLabels}
|
||||
modalFilters={modalFiltersForModal}
|
||||
categoryColumns={sourceCategoryColumns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -2000,6 +2093,7 @@ export function TableSectionRenderer({
|
|||
onSelect={handleAddItems}
|
||||
columnLabels={columnLabels}
|
||||
modalFilters={modalFiltersForModal}
|
||||
categoryColumns={sourceCategoryColumns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,17 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
Database,
|
||||
Layout,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings, Database, Layout, Table } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { getNumberingRules } from "@/lib/api/numberingRule";
|
||||
|
|
@ -31,11 +21,7 @@ import {
|
|||
MODAL_SIZE_OPTIONS,
|
||||
SECTION_TYPE_OPTIONS,
|
||||
} from "./types";
|
||||
import {
|
||||
defaultSectionConfig,
|
||||
defaultTableSectionConfig,
|
||||
generateSectionId,
|
||||
} from "./config";
|
||||
import { defaultSectionConfig, defaultTableSectionConfig, generateSectionId } from "./config";
|
||||
|
||||
// 모달 import
|
||||
import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
|
||||
|
|
@ -45,22 +31,26 @@ import { TableSectionSettingsModal } from "./modals/TableSectionSettingsModal";
|
|||
|
||||
// 도움말 텍스트 컴포넌트
|
||||
const HelpText = ({ children }: { children: React.ReactNode }) => (
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">{children}</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">{children}</p>
|
||||
);
|
||||
|
||||
// 부모 화면에서 전달 가능한 필드 타입
|
||||
interface AvailableParentField {
|
||||
name: string; // 필드명 (columnName)
|
||||
label: string; // 표시 라벨
|
||||
name: string; // 필드명 (columnName)
|
||||
label: string; // 표시 라벨
|
||||
sourceComponent?: string; // 출처 컴포넌트 (예: "TableList", "SplitPanelLayout2")
|
||||
sourceTable?: string; // 출처 테이블명
|
||||
sourceTable?: string; // 출처 테이블명
|
||||
}
|
||||
|
||||
export function UniversalFormModalConfigPanel({ config, onChange, allComponents = [] }: UniversalFormModalConfigPanelProps) {
|
||||
export function UniversalFormModalConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
allComponents = [],
|
||||
}: UniversalFormModalConfigPanelProps) {
|
||||
// 테이블 목록
|
||||
const [tables, setTables] = useState<{ name: string; label: string }[]>([]);
|
||||
const [tableColumns, setTableColumns] = useState<{
|
||||
[tableName: string]: { name: string; type: string; label: string }[];
|
||||
[tableName: string]: { name: string; type: string; label: string; inputType?: string }[];
|
||||
}>({});
|
||||
|
||||
// 부모 화면에서 전달 가능한 필드 목록
|
||||
|
|
@ -140,7 +130,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 좌측 패널 테이블 컬럼도 추출
|
||||
const leftTableName = compConfig.leftPanel?.tableName;
|
||||
if (leftTableName) {
|
||||
|
|
@ -152,7 +142,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
const colName = col.columnName || col.column_name;
|
||||
const colLabel = col.displayName || col.columnComment || col.column_comment || colName;
|
||||
// 중복 방지
|
||||
if (!fields.some(f => f.name === colName && f.sourceTable === leftTableName)) {
|
||||
if (!fields.some((f) => f.name === colName && f.sourceTable === leftTableName)) {
|
||||
fields.push({
|
||||
name: colName,
|
||||
label: colLabel,
|
||||
|
|
@ -179,7 +169,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
columns.forEach((col: any) => {
|
||||
const colName = col.columnName || col.column_name;
|
||||
const colLabel = col.displayName || col.columnComment || col.column_comment || colName;
|
||||
if (!fields.some(f => f.name === colName && f.sourceTable === tableName)) {
|
||||
if (!fields.some((f) => f.name === colName && f.sourceTable === tableName)) {
|
||||
fields.push({
|
||||
name: colName,
|
||||
label: colLabel,
|
||||
|
|
@ -198,11 +188,11 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
// 4. 버튼 컴포넌트 - openModalWithData의 fieldMappings/dataMapping에서 소스 컬럼 추출
|
||||
if (compType === "button-primary" || compType === "button" || compType === "button-secondary") {
|
||||
const action = compConfig.action || {};
|
||||
|
||||
|
||||
// fieldMappings에서 소스 컬럼 추출
|
||||
const fieldMappings = action.fieldMappings || [];
|
||||
fieldMappings.forEach((mapping: any) => {
|
||||
if (mapping.sourceColumn && !fields.some(f => f.name === mapping.sourceColumn)) {
|
||||
if (mapping.sourceColumn && !fields.some((f) => f.name === mapping.sourceColumn)) {
|
||||
fields.push({
|
||||
name: mapping.sourceColumn,
|
||||
label: mapping.sourceColumn,
|
||||
|
|
@ -211,11 +201,11 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// dataMapping에서 소스 컬럼 추출
|
||||
const dataMapping = action.dataMapping || [];
|
||||
dataMapping.forEach((mapping: any) => {
|
||||
if (mapping.sourceColumn && !fields.some(f => f.name === mapping.sourceColumn)) {
|
||||
if (mapping.sourceColumn && !fields.some((f) => f.name === mapping.sourceColumn)) {
|
||||
fields.push({
|
||||
name: mapping.sourceColumn,
|
||||
label: mapping.sourceColumn,
|
||||
|
|
@ -237,7 +227,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
columns.forEach((col: any) => {
|
||||
const colName = col.columnName || col.column_name;
|
||||
const colLabel = col.displayName || col.columnComment || col.column_comment || colName;
|
||||
if (!fields.some(f => f.name === colName)) {
|
||||
if (!fields.some((f) => f.name === colName)) {
|
||||
fields.push({
|
||||
name: colName,
|
||||
label: colLabel,
|
||||
|
|
@ -253,8 +243,8 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
}
|
||||
|
||||
// 중복 제거 (같은 name이면 첫 번째만 유지)
|
||||
const uniqueFields = fields.filter((field, index, self) =>
|
||||
index === self.findIndex(f => f.name === field.name)
|
||||
const uniqueFields = fields.filter(
|
||||
(field, index, self) => index === self.findIndex((f) => f.name === field.name),
|
||||
);
|
||||
|
||||
setAvailableParentFields(uniqueFields);
|
||||
|
|
@ -276,11 +266,19 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
const data = response.data?.data;
|
||||
if (response.data?.success && Array.isArray(data)) {
|
||||
setTables(
|
||||
data.map((t: { tableName?: string; table_name?: string; displayName?: string; tableLabel?: string; table_label?: string }) => ({
|
||||
name: t.tableName || t.table_name || "",
|
||||
// displayName 우선, 없으면 tableLabel, 그것도 없으면 테이블명
|
||||
label: t.displayName || t.tableLabel || t.table_label || "",
|
||||
})),
|
||||
data.map(
|
||||
(t: {
|
||||
tableName?: string;
|
||||
table_name?: string;
|
||||
displayName?: string;
|
||||
tableLabel?: string;
|
||||
table_label?: string;
|
||||
}) => ({
|
||||
name: t.tableName || t.table_name || "",
|
||||
// displayName 우선, 없으면 tableLabel, 그것도 없으면 테이블명
|
||||
label: t.displayName || t.tableLabel || t.table_label || "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -308,10 +306,13 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
displayName?: string;
|
||||
columnComment?: string;
|
||||
column_comment?: string;
|
||||
inputType?: string;
|
||||
input_type?: string;
|
||||
}) => ({
|
||||
name: c.columnName || c.column_name || "",
|
||||
type: c.dataType || c.data_type || "text",
|
||||
label: c.displayName || c.columnComment || c.column_comment || c.columnName || c.column_name || "",
|
||||
inputType: c.inputType || c.input_type || "text",
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
|
@ -359,21 +360,24 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
);
|
||||
|
||||
// 섹션 관리
|
||||
const addSection = useCallback((type: "fields" | "table" = "fields") => {
|
||||
const newSection: FormSectionConfig = {
|
||||
...defaultSectionConfig,
|
||||
id: generateSectionId(),
|
||||
title: type === "table" ? `테이블 섹션 ${config.sections.length + 1}` : `섹션 ${config.sections.length + 1}`,
|
||||
type,
|
||||
fields: type === "fields" ? [] : undefined,
|
||||
tableConfig: type === "table" ? { ...defaultTableSectionConfig } : undefined,
|
||||
};
|
||||
onChange({
|
||||
...config,
|
||||
sections: [...config.sections, newSection],
|
||||
});
|
||||
}, [config, onChange]);
|
||||
|
||||
const addSection = useCallback(
|
||||
(type: "fields" | "table" = "fields") => {
|
||||
const newSection: FormSectionConfig = {
|
||||
...defaultSectionConfig,
|
||||
id: generateSectionId(),
|
||||
title: type === "table" ? `테이블 섹션 ${config.sections.length + 1}` : `섹션 ${config.sections.length + 1}`,
|
||||
type,
|
||||
fields: type === "fields" ? [] : undefined,
|
||||
tableConfig: type === "table" ? { ...defaultTableSectionConfig } : undefined,
|
||||
};
|
||||
onChange({
|
||||
...config,
|
||||
sections: [...config.sections, newSection],
|
||||
});
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
// 섹션 타입 변경
|
||||
const changeSectionType = useCallback(
|
||||
(sectionId: string, newType: "fields" | "table") => {
|
||||
|
|
@ -381,7 +385,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
...config,
|
||||
sections: config.sections.map((s) => {
|
||||
if (s.id !== sectionId) return s;
|
||||
|
||||
|
||||
if (newType === "table") {
|
||||
return {
|
||||
...s,
|
||||
|
|
@ -400,9 +404,9 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
}),
|
||||
});
|
||||
},
|
||||
[config, onChange]
|
||||
[config, onChange],
|
||||
);
|
||||
|
||||
|
||||
// 테이블 섹션 설정 모달 열기
|
||||
const handleOpenTableSectionSettings = (section: FormSectionConfig) => {
|
||||
setSelectedSection(section);
|
||||
|
|
@ -487,293 +491,310 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden w-full min-w-0">
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden w-full min-w-0">
|
||||
<div className="space-y-4 p-4 w-full min-w-0 max-w-full">
|
||||
{/* 모달 기본 설정 */}
|
||||
<Accordion type="single" collapsible defaultValue="modal-settings" className="w-full min-w-0">
|
||||
<AccordionItem value="modal-settings" className="border rounded-lg w-full min-w-0">
|
||||
<AccordionTrigger className="px-4 py-3 text-sm font-medium hover:no-underline w-full min-w-0">
|
||||
<div className="flex items-center gap-2 w-full min-w-0">
|
||||
<Settings className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">모달 기본 설정</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4 space-y-4 w-full min-w-0">
|
||||
<div className="w-full min-w-0">
|
||||
<Label className="text-xs font-medium mb-1.5 block">모달 제목</Label>
|
||||
<Input
|
||||
value={config.modal.title}
|
||||
onChange={(e) => updateModalConfig({ title: e.target.value })}
|
||||
className="h-9 text-sm w-full max-w-full"
|
||||
/>
|
||||
<HelpText>모달 상단에 표시될 제목입니다</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="w-full min-w-0">
|
||||
<Label className="text-xs font-medium mb-1.5 block">모달 크기</Label>
|
||||
<Select value={config.modal.size} onValueChange={(value: any) => updateModalConfig({ size: value })}>
|
||||
<SelectTrigger className="h-9 text-sm w-full max-w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODAL_SIZE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>모달 창의 크기를 선택하세요</HelpText>
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 표시 설정 */}
|
||||
<div className="w-full min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="show-save-button"
|
||||
checked={config.modal.showSaveButton !== false}
|
||||
onCheckedChange={(checked) => updateModalConfig({ showSaveButton: checked === true })}
|
||||
/>
|
||||
<Label htmlFor="show-save-button" className="text-xs font-medium cursor-pointer">
|
||||
저장 버튼 표시
|
||||
</Label>
|
||||
<div className="flex h-full w-full min-w-0 flex-col overflow-hidden">
|
||||
<div className="w-full min-w-0 flex-1 overflow-x-hidden overflow-y-auto">
|
||||
<div className="w-full max-w-full min-w-0 space-y-4 p-4">
|
||||
{/* 모달 기본 설정 */}
|
||||
<Accordion type="single" collapsible defaultValue="modal-settings" className="w-full min-w-0">
|
||||
<AccordionItem value="modal-settings" className="w-full min-w-0 rounded-lg border">
|
||||
<AccordionTrigger className="w-full min-w-0 px-4 py-3 text-sm font-medium hover:no-underline">
|
||||
<div className="flex w-full min-w-0 items-center gap-2">
|
||||
<Settings className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">모달 기본 설정</span>
|
||||
</div>
|
||||
<HelpText>체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 w-full min-w-0">
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="w-full min-w-0 space-y-4 px-4 pb-4">
|
||||
<div className="w-full min-w-0">
|
||||
<Label className="text-xs font-medium mb-1.5 block">저장 버튼 텍스트</Label>
|
||||
<Label className="mb-1.5 block text-xs font-medium">모달 제목</Label>
|
||||
<Input
|
||||
value={config.modal.saveButtonText || "저장"}
|
||||
onChange={(e) => updateModalConfig({ saveButtonText: e.target.value })}
|
||||
className="h-9 text-sm w-full max-w-full"
|
||||
value={config.modal.title}
|
||||
onChange={(e) => updateModalConfig({ title: e.target.value })}
|
||||
className="h-9 w-full max-w-full text-sm"
|
||||
/>
|
||||
<HelpText>모달 상단에 표시될 제목입니다</HelpText>
|
||||
</div>
|
||||
|
||||
<div className="w-full min-w-0">
|
||||
<Label className="text-xs font-medium mb-1.5 block">취소 버튼 텍스트</Label>
|
||||
<Input
|
||||
value={config.modal.cancelButtonText || "취소"}
|
||||
onChange={(e) => updateModalConfig({ cancelButtonText: e.target.value })}
|
||||
className="h-9 text-sm w-full max-w-full"
|
||||
/>
|
||||
<Label className="mb-1.5 block text-xs font-medium">모달 크기</Label>
|
||||
<Select value={config.modal.size} onValueChange={(value: any) => updateModalConfig({ size: value })}>
|
||||
<SelectTrigger className="h-9 w-full max-w-full text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MODAL_SIZE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<HelpText>모달 창의 크기를 선택하세요</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* 저장 설정 */}
|
||||
<Accordion type="single" collapsible defaultValue="save-settings" className="w-full min-w-0">
|
||||
<AccordionItem value="save-settings" className="border rounded-lg w-full min-w-0">
|
||||
<AccordionTrigger className="px-4 py-3 text-sm font-medium hover:no-underline w-full min-w-0">
|
||||
<div className="flex items-center gap-2 w-full min-w-0">
|
||||
<Database className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">저장 설정</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4 space-y-4 w-full min-w-0">
|
||||
<div className="space-y-3 w-full min-w-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<Label className="text-xs font-medium mb-1.5 block">저장 테이블</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{config.saveConfig.tableName || "(미설정)"}
|
||||
</p>
|
||||
{config.saveConfig.customApiSave?.enabled && config.saveConfig.customApiSave?.multiTable?.enabled && (
|
||||
<Badge variant="secondary" className="text-xs px-2 py-0.5 mt-2">
|
||||
다중 테이블 모드
|
||||
</Badge>
|
||||
)}
|
||||
{/* 저장 버튼 표시 설정 */}
|
||||
<div className="w-full min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="show-save-button"
|
||||
checked={config.modal.showSaveButton !== false}
|
||||
onCheckedChange={(checked) => updateModalConfig({ showSaveButton: checked === true })}
|
||||
/>
|
||||
<Label htmlFor="show-save-button" className="cursor-pointer text-xs font-medium">
|
||||
저장 버튼 표시
|
||||
</Label>
|
||||
</div>
|
||||
<HelpText>체크 해제 시 모달 하단의 저장 버튼이 숨겨집니다</HelpText>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSaveSettingsModalOpen(true)}
|
||||
className="h-9 text-xs w-full"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
저장 설정 열기
|
||||
</Button>
|
||||
</div>
|
||||
<HelpText>
|
||||
데이터를 저장할 테이블과 방식을 설정합니다.
|
||||
<br />
|
||||
"저장 설정 열기"를 클릭하여 상세 설정을 변경하세요.
|
||||
</HelpText>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* 섹션 구성 */}
|
||||
<Accordion type="single" collapsible defaultValue="sections" className="w-full min-w-0">
|
||||
<AccordionItem value="sections" className="border rounded-lg w-full min-w-0">
|
||||
<AccordionTrigger className="px-4 py-3 text-sm font-medium hover:no-underline w-full min-w-0">
|
||||
<div className="flex items-center gap-2 w-full min-w-0">
|
||||
<Layout className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">섹션 구성</span>
|
||||
<Badge variant="secondary" className="text-xs px-2 py-0.5 shrink-0">
|
||||
{config.sections.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-4 pb-4 space-y-4 w-full min-w-0">
|
||||
{/* 섹션 추가 버튼들 */}
|
||||
<div className="flex gap-2 w-full min-w-0">
|
||||
<Button size="sm" variant="outline" onClick={() => addSection("fields")} className="h-9 text-xs flex-1 min-w-0">
|
||||
<Plus className="h-4 w-4 mr-1 shrink-0" />
|
||||
<span className="truncate">필드 섹션</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => addSection("table")} className="h-9 text-xs flex-1 min-w-0">
|
||||
<Table className="h-4 w-4 mr-1 shrink-0" />
|
||||
<span className="truncate">테이블 섹션</span>
|
||||
</Button>
|
||||
</div>
|
||||
<HelpText>
|
||||
필드 섹션: 일반 입력 필드들을 배치합니다.
|
||||
<br />
|
||||
테이블 섹션: 품목 목록 등 반복 테이블 형식 데이터를 관리합니다.
|
||||
</HelpText>
|
||||
|
||||
{config.sections.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed rounded-lg w-full bg-muted/20">
|
||||
<p className="text-sm text-muted-foreground mb-2 font-medium">섹션이 없습니다</p>
|
||||
<p className="text-xs text-muted-foreground">위 버튼으로 섹션을 추가하세요</p>
|
||||
<div className="w-full min-w-0 space-y-3">
|
||||
<div className="w-full min-w-0">
|
||||
<Label className="mb-1.5 block text-xs font-medium">저장 버튼 텍스트</Label>
|
||||
<Input
|
||||
value={config.modal.saveButtonText || "저장"}
|
||||
onChange={(e) => updateModalConfig({ saveButtonText: e.target.value })}
|
||||
className="h-9 w-full max-w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full min-w-0">
|
||||
<Label className="mb-1.5 block text-xs font-medium">취소 버튼 텍스트</Label>
|
||||
<Input
|
||||
value={config.modal.cancelButtonText || "취소"}
|
||||
onChange={(e) => updateModalConfig({ cancelButtonText: e.target.value })}
|
||||
className="h-9 w-full max-w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 w-full min-w-0">
|
||||
{config.sections.map((section, index) => (
|
||||
<div key={section.id} className="border rounded-lg p-3 bg-card w-full min-w-0 overflow-hidden space-y-3">
|
||||
{/* 헤더: 제목 + 타입 배지 + 삭제 */}
|
||||
<div className="flex items-start justify-between gap-3 w-full min-w-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-sm font-medium truncate">{section.title}</span>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* 저장 설정 */}
|
||||
<Accordion type="single" collapsible defaultValue="save-settings" className="w-full min-w-0">
|
||||
<AccordionItem value="save-settings" className="w-full min-w-0 rounded-lg border">
|
||||
<AccordionTrigger className="w-full min-w-0 px-4 py-3 text-sm font-medium hover:no-underline">
|
||||
<div className="flex w-full min-w-0 items-center gap-2">
|
||||
<Database className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">저장 설정</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="w-full min-w-0 space-y-4 px-4 pb-4">
|
||||
<div className="w-full min-w-0 space-y-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label className="mb-1.5 block text-xs font-medium">저장 테이블</Label>
|
||||
<p className="text-muted-foreground text-sm">{config.saveConfig.tableName || "(미설정)"}</p>
|
||||
{config.saveConfig.customApiSave?.enabled &&
|
||||
config.saveConfig.customApiSave?.multiTable?.enabled && (
|
||||
<Badge variant="secondary" className="mt-2 px-2 py-0.5 text-xs">
|
||||
다중 테이블 모드
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSaveSettingsModalOpen(true)}
|
||||
className="h-9 w-full text-xs"
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
저장 설정 열기
|
||||
</Button>
|
||||
</div>
|
||||
<HelpText>
|
||||
데이터를 저장할 테이블과 방식을 설정합니다.
|
||||
<br />
|
||||
"저장 설정 열기"를 클릭하여 상세 설정을 변경하세요.
|
||||
</HelpText>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* 섹션 구성 */}
|
||||
<Accordion type="single" collapsible defaultValue="sections" className="w-full min-w-0">
|
||||
<AccordionItem value="sections" className="w-full min-w-0 rounded-lg border">
|
||||
<AccordionTrigger className="w-full min-w-0 px-4 py-3 text-sm font-medium hover:no-underline">
|
||||
<div className="flex w-full min-w-0 items-center gap-2">
|
||||
<Layout className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">섹션 구성</span>
|
||||
<Badge variant="secondary" className="shrink-0 px-2 py-0.5 text-xs">
|
||||
{config.sections.length}개
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="w-full min-w-0 space-y-4 px-4 pb-4">
|
||||
{/* 섹션 추가 버튼들 */}
|
||||
<div className="flex w-full min-w-0 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addSection("fields")}
|
||||
className="h-9 min-w-0 flex-1 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">필드 섹션</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addSection("table")}
|
||||
className="h-9 min-w-0 flex-1 text-xs"
|
||||
>
|
||||
<Table className="mr-1 h-4 w-4 shrink-0" />
|
||||
<span className="truncate">테이블 섹션</span>
|
||||
</Button>
|
||||
</div>
|
||||
<HelpText>
|
||||
필드 섹션: 일반 입력 필드들을 배치합니다.
|
||||
<br />
|
||||
테이블 섹션: 품목 목록 등 반복 테이블 형식 데이터를 관리합니다.
|
||||
</HelpText>
|
||||
|
||||
{config.sections.length === 0 ? (
|
||||
<div className="bg-muted/20 w-full rounded-lg border border-dashed py-12 text-center">
|
||||
<p className="text-muted-foreground mb-2 text-sm font-medium">섹션이 없습니다</p>
|
||||
<p className="text-muted-foreground text-xs">위 버튼으로 섹션을 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full min-w-0 space-y-3">
|
||||
{config.sections.map((section, index) => (
|
||||
<div
|
||||
key={section.id}
|
||||
className="bg-card w-full min-w-0 space-y-3 overflow-hidden rounded-lg border p-3"
|
||||
>
|
||||
{/* 헤더: 제목 + 타입 배지 + 삭제 */}
|
||||
<div className="flex w-full min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{section.title}</span>
|
||||
{section.type === "table" ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-200 bg-purple-50 px-1.5 py-0.5 text-xs text-purple-600"
|
||||
>
|
||||
테이블
|
||||
</Badge>
|
||||
) : section.repeatable ? (
|
||||
<Badge variant="outline" className="px-1.5 py-0.5 text-xs">
|
||||
반복
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{section.type === "table" ? (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0.5 text-purple-600 bg-purple-50 border-purple-200">
|
||||
테이블
|
||||
<Badge variant="secondary" className="px-2 py-0.5 text-xs">
|
||||
{section.tableConfig?.source?.tableName || "(소스 미설정)"}
|
||||
</Badge>
|
||||
) : section.repeatable ? (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0.5">
|
||||
반복
|
||||
) : (
|
||||
<Badge variant="secondary" className="px-2 py-0.5 text-xs">
|
||||
{(section.fields || []).length}개 필드
|
||||
</Badge>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
{section.type === "table" ? (
|
||||
<Badge variant="secondary" className="text-xs px-2 py-0.5">
|
||||
{section.tableConfig?.source?.tableName || "(소스 미설정)"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs px-2 py-0.5">
|
||||
{(section.fields || []).length}개 필드
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeSection(section.id)}
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive shrink-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 순서 조정 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeSection(section.id)}
|
||||
className="text-destructive hover:text-destructive h-7 w-7 shrink-0 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 순서 조정 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => moveSectionUp(index)}
|
||||
disabled={index === 0}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => moveSectionDown(index)}
|
||||
disabled={index === config.sections.length - 1}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 (필드 타입만) */}
|
||||
{section.type !== "table" && (section.fields || []).length > 0 && (
|
||||
<div className="flex max-w-full flex-wrap gap-1.5 overflow-hidden pt-1">
|
||||
{(section.fields || []).slice(0, 4).map((field) => (
|
||||
<Badge
|
||||
key={field.id}
|
||||
variant="outline"
|
||||
className={cn("shrink-0 px-2 py-0.5 text-xs", getFieldTypeColor(field.fieldType))}
|
||||
>
|
||||
{field.label}
|
||||
</Badge>
|
||||
))}
|
||||
{(section.fields || []).length > 4 && (
|
||||
<Badge variant="outline" className="shrink-0 px-2 py-0.5 text-xs">
|
||||
+{(section.fields || []).length - 4}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 컬럼 목록 (테이블 타입만) */}
|
||||
{section.type === "table" &&
|
||||
section.tableConfig?.columns &&
|
||||
section.tableConfig.columns.length > 0 && (
|
||||
<div className="flex max-w-full flex-wrap gap-1.5 overflow-hidden pt-1">
|
||||
{section.tableConfig.columns.slice(0, 4).map((col, idx) => (
|
||||
<Badge
|
||||
key={col.field || `col_${idx}`}
|
||||
variant="outline"
|
||||
className="shrink-0 border-purple-200 bg-purple-50 px-2 py-0.5 text-xs text-purple-600"
|
||||
>
|
||||
{col.label || col.field || `컬럼 ${idx + 1}`}
|
||||
</Badge>
|
||||
))}
|
||||
{section.tableConfig.columns.length > 4 && (
|
||||
<Badge variant="outline" className="shrink-0 px-2 py-0.5 text-xs">
|
||||
+{section.tableConfig.columns.length - 4}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설정 버튼 (타입에 따라 다름) */}
|
||||
{section.type === "table" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => moveSectionUp(index)}
|
||||
disabled={index === 0}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => handleOpenTableSectionSettings(section)}
|
||||
className="h-9 w-full text-xs"
|
||||
>
|
||||
<ChevronUp className="h-3.5 w-3.5" />
|
||||
<Table className="mr-2 h-4 w-4" />
|
||||
테이블 설정
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => moveSectionDown(index)}
|
||||
disabled={index === config.sections.length - 1}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => handleOpenSectionLayout(section)}
|
||||
className="h-9 w-full text-xs"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
<Layout className="mr-2 h-4 w-4" />
|
||||
레이아웃 설정
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 (필드 타입만) */}
|
||||
{section.type !== "table" && (section.fields || []).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
|
||||
{(section.fields || []).slice(0, 4).map((field) => (
|
||||
<Badge
|
||||
key={field.id}
|
||||
variant="outline"
|
||||
className={cn("text-xs px-2 py-0.5 shrink-0", getFieldTypeColor(field.fieldType))}
|
||||
>
|
||||
{field.label}
|
||||
</Badge>
|
||||
))}
|
||||
{(section.fields || []).length > 4 && (
|
||||
<Badge variant="outline" className="text-xs px-2 py-0.5 shrink-0">
|
||||
+{(section.fields || []).length - 4}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 컬럼 목록 (테이블 타입만) */}
|
||||
{section.type === "table" && section.tableConfig?.columns && section.tableConfig.columns.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 max-w-full overflow-hidden pt-1">
|
||||
{section.tableConfig.columns.slice(0, 4).map((col, idx) => (
|
||||
<Badge
|
||||
key={col.field || `col_${idx}`}
|
||||
variant="outline"
|
||||
className="text-xs px-2 py-0.5 shrink-0 text-purple-600 bg-purple-50 border-purple-200"
|
||||
>
|
||||
{col.label || col.field || `컬럼 ${idx + 1}`}
|
||||
</Badge>
|
||||
))}
|
||||
{section.tableConfig.columns.length > 4 && (
|
||||
<Badge variant="outline" className="text-xs px-2 py-0.5 shrink-0">
|
||||
+{section.tableConfig.columns.length - 4}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설정 버튼 (타입에 따라 다름) */}
|
||||
{section.type === "table" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenTableSectionSettings(section)}
|
||||
className="h-9 text-xs w-full"
|
||||
>
|
||||
<Table className="h-4 w-4 mr-2" />
|
||||
테이블 설정
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenSectionLayout(section)}
|
||||
className="h-9 text-xs w-full"
|
||||
>
|
||||
<Layout className="h-4 w-4 mr-2" />
|
||||
레이아웃 설정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -813,11 +834,13 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
setFieldDetailModalOpen(true);
|
||||
}}
|
||||
tableName={config.saveConfig.tableName}
|
||||
tableColumns={tableColumns[config.saveConfig.tableName || ""]?.map(col => ({
|
||||
name: col.name,
|
||||
type: col.type,
|
||||
label: col.label || col.name
|
||||
})) || []}
|
||||
tableColumns={
|
||||
tableColumns[config.saveConfig.tableName || ""]?.map((col) => ({
|
||||
name: col.name,
|
||||
type: col.type,
|
||||
label: col.label || col.name,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -845,15 +868,13 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
fields: group.fields.map((f) => (f.id === updatedField.id ? updatedField : f)),
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
// config 업데이트
|
||||
onChange({
|
||||
...config,
|
||||
sections: config.sections.map((s) =>
|
||||
s.id === selectedSection.id ? updatedSection : s
|
||||
),
|
||||
sections: config.sections.map((s) => (s.id === selectedSection.id ? updatedSection : s)),
|
||||
});
|
||||
|
||||
|
||||
// selectedSection과 selectedField 상태도 업데이트 (다음에 다시 열었을 때 최신 값 반영)
|
||||
setSelectedSection(updatedSection);
|
||||
setSelectedField(updatedField as FormFieldConfig);
|
||||
|
|
@ -881,29 +902,28 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
|
|||
...selectedSection,
|
||||
...updates,
|
||||
};
|
||||
|
||||
|
||||
// config 업데이트
|
||||
onChange({
|
||||
...config,
|
||||
sections: config.sections.map((s) =>
|
||||
s.id === selectedSection.id ? updatedSection : s
|
||||
),
|
||||
sections: config.sections.map((s) => (s.id === selectedSection.id ? updatedSection : s)),
|
||||
});
|
||||
|
||||
|
||||
setSelectedSection(updatedSection);
|
||||
setTableSectionSettingsModalOpen(false);
|
||||
}}
|
||||
tables={tables.map(t => ({ table_name: t.name, comment: t.label }))}
|
||||
tables={tables.map((t) => ({ table_name: t.name, comment: t.label }))}
|
||||
tableColumns={Object.fromEntries(
|
||||
Object.entries(tableColumns).map(([tableName, cols]) => [
|
||||
tableName,
|
||||
cols.map(c => ({
|
||||
cols.map((c) => ({
|
||||
column_name: c.name,
|
||||
data_type: c.type,
|
||||
is_nullable: "YES",
|
||||
comment: c.label,
|
||||
input_type: c.inputType || "text",
|
||||
})),
|
||||
])
|
||||
]),
|
||||
)}
|
||||
onLoadTableColumns={loadTableColumns}
|
||||
allSections={config.sections as FormSectionConfig[]}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -15,6 +15,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical, ChevronUp, ChevronDown, Settings, Check, ChevronsUpDown, Filter, Table as TableIcon, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
// 타입 import
|
||||
import {
|
||||
|
|
@ -2415,7 +2416,7 @@ interface TableSectionSettingsModalProps {
|
|||
section: FormSectionConfig;
|
||||
onSave: (updates: Partial<FormSectionConfig>) => void;
|
||||
tables: { table_name: string; comment?: string }[];
|
||||
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string }[]>;
|
||||
tableColumns: Record<string, { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]>;
|
||||
onLoadTableColumns: (tableName: string) => void;
|
||||
// 카테고리 목록 (table_column_category_values에서 가져옴)
|
||||
categoryList?: { tableName: string; columnName: string; displayName?: string }[];
|
||||
|
|
@ -2452,6 +2453,11 @@ export function TableSectionSettingsModal({
|
|||
|
||||
// 활성 탭
|
||||
const [activeTab, setActiveTab] = useState("source");
|
||||
|
||||
// 사전 필터 카테고리 옵션 캐시 (컬럼명 -> 옵션 배열)
|
||||
const [preFilterCategoryOptions, setPreFilterCategoryOptions] = useState<
|
||||
Record<string, { value: string; label: string }[]>
|
||||
>({});
|
||||
|
||||
// open이 변경될 때마다 데이터 동기화
|
||||
useEffect(() => {
|
||||
|
|
@ -2513,6 +2519,47 @@ export function TableSectionSettingsModal({
|
|||
return tableColumns[tableConfig.source.tableName] || [];
|
||||
}, [tableColumns, tableConfig.source.tableName]);
|
||||
|
||||
// 카테고리 옵션 로드 함수
|
||||
const loadCategoryOptions = useCallback(async (columnName: string) => {
|
||||
if (!tableConfig.source.tableName || !columnName) return;
|
||||
|
||||
// 이미 로드된 경우 스킵
|
||||
if (preFilterCategoryOptions[columnName]) return;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${tableConfig.source.tableName}/${columnName}/values`
|
||||
);
|
||||
if (response.data?.success && response.data?.data) {
|
||||
const options = response.data.data.map((item: any) => ({
|
||||
// value는 DB에 저장된 실제 값(valueCode)을 사용해야 필터링이 정상 작동
|
||||
value: item.valueCode || item.value_code || item.valueLabel || item.value_label || "",
|
||||
// label은 사용자에게 보여질 라벨
|
||||
label: item.valueLabel || item.value_label || item.valueCode || item.value_code || "",
|
||||
}));
|
||||
setPreFilterCategoryOptions((prev) => ({
|
||||
...prev,
|
||||
[columnName]: options,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`카테고리 옵션 로드 실패 (${columnName}):`, error);
|
||||
}
|
||||
}, [tableConfig.source.tableName, preFilterCategoryOptions]);
|
||||
|
||||
// 사전 필터에서 선택된 카테고리 컬럼들의 옵션 자동 로드
|
||||
useEffect(() => {
|
||||
const preFilters = tableConfig.filters?.preFilters || [];
|
||||
preFilters.forEach((filter) => {
|
||||
if (filter.column) {
|
||||
const col = sourceTableColumns.find((c) => c.column_name === filter.column);
|
||||
if (col && col.input_type === "category") {
|
||||
loadCategoryOptions(filter.column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [tableConfig.filters?.preFilters, sourceTableColumns, loadCategoryOptions]);
|
||||
|
||||
// 저장 테이블의 컬럼 목록
|
||||
const saveTableColumns = useMemo(() => {
|
||||
// 저장 테이블이 지정되어 있으면 해당 테이블의 컬럼, 아니면 소스 테이블의 컬럼 사용
|
||||
|
|
@ -3159,59 +3206,96 @@ export function TableSectionSettingsModal({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{(tableConfig.filters?.preFilters || []).map((filter, index) => (
|
||||
<div key={index} className="flex items-center gap-2 border rounded-lg p-2 bg-card">
|
||||
<Select
|
||||
value={filter.column || undefined}
|
||||
onValueChange={(value) => updatePreFilter(index, { column: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-[150px]">
|
||||
<SelectValue placeholder="컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceTableColumns
|
||||
.filter((col) => col.column_name)
|
||||
.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.column_name}
|
||||
{(tableConfig.filters?.preFilters || []).map((filter, index) => {
|
||||
// 선택된 컬럼의 정보 조회
|
||||
const selectedColumn = filter.column
|
||||
? sourceTableColumns.find((c) => c.column_name === filter.column)
|
||||
: null;
|
||||
const isCategory = selectedColumn?.input_type === "category";
|
||||
const categoryOptions = isCategory && filter.column
|
||||
? preFilterCategoryOptions[filter.column] || []
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-2 border rounded-lg p-2 bg-card">
|
||||
<Select
|
||||
value={filter.column || undefined}
|
||||
onValueChange={(value) => {
|
||||
updatePreFilter(index, { column: value, value: "" }); // 컬럼 변경 시 값 초기화
|
||||
// 카테고리 컬럼인 경우 옵션 로드
|
||||
const col = sourceTableColumns.find((c) => c.column_name === value);
|
||||
if (col && col.input_type === "category") {
|
||||
loadCategoryOptions(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-[150px]">
|
||||
<SelectValue placeholder="컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceTableColumns
|
||||
.filter((col) => col.column_name)
|
||||
.map((col) => (
|
||||
<SelectItem key={col.column_name} value={col.column_name}>
|
||||
{col.comment || col.column_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.operator || undefined}
|
||||
onValueChange={(value: any) => updatePreFilter(index, { operator: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-[100px]">
|
||||
<SelectValue placeholder="연산자" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPERATOR_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.operator || undefined}
|
||||
onValueChange={(value: any) => updatePreFilter(index, { operator: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-[100px]">
|
||||
<SelectValue placeholder="연산자" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_OPERATOR_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 카테고리 컬럼인 경우 Select Box로 값 선택 */}
|
||||
{isCategory ? (
|
||||
<Select
|
||||
value={filter.value || undefined}
|
||||
onValueChange={(value) => updatePreFilter(index, { value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs flex-1">
|
||||
<SelectValue placeholder="값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={filter.value || ""}
|
||||
onChange={(e) => updatePreFilter(index, { value: e.target.value })}
|
||||
placeholder="값"
|
||||
className="h-8 text-xs flex-1"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
value={filter.value || ""}
|
||||
onChange={(e) => updatePreFilter(index, { value: e.target.value })}
|
||||
placeholder="값"
|
||||
className="h-8 text-xs flex-1"
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removePreFilter(index)}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removePreFilter(index)}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
|
|
|||
|
|
@ -3022,11 +3022,11 @@ export class ButtonActionExecutor {
|
|||
comp.componentType === "split-panel-layout",
|
||||
);
|
||||
}
|
||||
console.log("🔍 [openEditModal] 분할 패널 확인:", {
|
||||
targetScreenId: config.targetScreenId,
|
||||
hasSplitPanel,
|
||||
componentTypes: layoutData?.components?.map((c: any) => c.type || c.componentType) || [],
|
||||
});
|
||||
// console.log("🔍 [openEditModal] 분할 패널 확인:", {
|
||||
// targetScreenId: config.targetScreenId,
|
||||
// hasSplitPanel,
|
||||
// componentTypes: layoutData?.components?.map((c: any) => c.type || c.componentType) || [],
|
||||
// });
|
||||
} catch (error) {
|
||||
console.warn("레이아웃 정보를 가져오지 못했습니다:", error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const useModalDataStore = create<ModalDataState>()(
|
|||
dataRegistry: {},
|
||||
|
||||
setData: (sourceId, items) => {
|
||||
console.log("📦 [ModalDataStore] 데이터 저장:", { sourceId, itemCount: items.length, items });
|
||||
// console.log("📦 [ModalDataStore] 데이터 저장:", { sourceId, itemCount: items.length, items });
|
||||
set((state) => ({
|
||||
dataRegistry: {
|
||||
...state.dataRegistry,
|
||||
|
|
|
|||
|
|
@ -1689,3 +1689,4 @@ const 출고등록_설정: ScreenSplitPanel = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -536,3 +536,4 @@ const { data: config } = await getScreenSplitPanel(screenId);
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -523,3 +523,4 @@ function ScreenViewPage() {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue