Merge remote-tracking branch 'upstream/main'

This commit is contained in:
kjs 2026-01-06 13:46:34 +09:00
commit 642a16fbb3
17 changed files with 1309 additions and 677 deletions

View File

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

View File

@ -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),

View File

@ -104,7 +104,7 @@ function ScreenViewPage() {
// 편집 모달 이벤트 리스너 등록
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
// console.log("🎭 편집 모달 열기 이벤트 수신:", event.detail);
setEditModalConfig({
screenId: event.detail.screenId,

View File

@ -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();
}
};

View File

@ -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("화면 데이터가 없습니다");
}

View File

@ -306,11 +306,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
useEffect(() => {
const newData = splitPanelContext?.selectedLeftData ?? null;
setTrackedSelectedLeftData(newData);
console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", {
label: component.label,
hasData: !!newData,
dataKeys: newData ? Object.keys(newData) : [],
});
// console.log("🔄 [ButtonPrimary] selectedLeftData 변경 감지:", {
// label: component.label,
// hasData: !!newData,
// dataKeys: newData ? Object.keys(newData) : [],
// });
}, [splitPanelContext?.selectedLeftData, component.label]);
// modalDataStore 상태 구독 (실시간 업데이트)

View File

@ -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()}`
);

View File

@ -32,6 +32,7 @@ export function ItemSelectionModal({
onSelect,
columnLabels = {},
modalFilters = [],
categoryColumns = [],
}: ItemSelectionModalProps) {
const [localSearchText, setLocalSearchText] = useState("");
const [selectedItems, setSelectedItems] = useState<any[]>([]);
@ -42,6 +43,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(() => {
const combined = { ...filterCondition };
@ -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>
);
})

View File

@ -202,4 +202,7 @@ export interface ItemSelectionModalProps {
// 모달 내부 필터 (사용자 선택 가능)
modalFilters?: ModalFilterConfig[];
// 카테고리 타입 컬럼 목록 (해당 컬럼은 코드 → 라벨로 변환하여 표시)
categoryColumns?: string[];
}

View File

@ -6,7 +6,7 @@ import { RepeatScreenModalDefinition } from "./index";
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent(RepeatScreenModalDefinition);
console.log("✅ RepeatScreenModal 컴포넌트 등록 완료");
// console.log("✅ RepeatScreenModal 컴포넌트 등록 완료");
}
export {};

View File

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

View File

@ -382,6 +382,34 @@ export function TableSectionRenderer({
const [dynamicOptionsLoading, setDynamicOptionsLoading] = useState(false);
const dynamicOptionsLoadedRef = React.useRef(false);
// 소스 테이블의 카테고리 타입 컬럼 목록
const [sourceCategoryColumns, setSourceCategoryColumns] = useState<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]);
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
useEffect(() => {
if (!isConditionalMode) return;
@ -788,13 +816,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;
@ -1281,16 +1318,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 +1938,7 @@ export function TableSectionRenderer({
onSelect={handleConditionalAddItems}
columnLabels={columnLabels}
modalFilters={modalFiltersForModal}
categoryColumns={sourceCategoryColumns}
/>
</div>
);
@ -2000,6 +2047,7 @@ export function TableSectionRenderer({
onSelect={handleAddItems}
columnLabels={columnLabels}
modalFilters={modalFiltersForModal}
categoryColumns={sourceCategoryColumns}
/>
</div>
);

View File

@ -197,6 +197,10 @@ export function UniversalFormModalComponent({
// 로딩 상태
const [saving, setSaving] = useState(false);
// 채번규칙 원본 값 추적 (수동 모드 감지용)
// key: columnName, value: 자동 생성된 원본 값
const [numberingOriginalValues, setNumberingOriginalValues] = useState<Record<string, string>>({});
// 🆕 수정 모드: 원본 그룹 데이터 (INSERT/UPDATE/DELETE 추적용)
const [originalGroupedData, setOriginalGroupedData] = useState<any[]>([]);
const groupedDataInitializedRef = useRef(false);
@ -216,26 +220,25 @@ export function UniversalFormModalComponent({
// 초기화 - 최초 마운트 시 또는 initialData가 변경되었을 때 실행
useEffect(() => {
console.log("[UniversalFormModal] useEffect 시작", {
initialData,
hasInitialized: hasInitialized.current,
lastInitializedId: lastInitializedId.current,
});
// console.log("[UniversalFormModal] useEffect 시작", {
// initialData,
// hasInitialized: hasInitialized.current,
// lastInitializedId: lastInitializedId.current,
// });
// initialData에서 ID 값 추출 (id, ID, objid 등)
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
// 생성 모드에서 부모로부터 전달받은 데이터 해시 (ID가 없을 때만)
const createModeDataHash = !currentIdString && initialData && Object.keys(initialData).length > 0
? JSON.stringify(initialData)
: undefined;
const createModeDataHash =
!currentIdString && initialData && Object.keys(initialData).length > 0 ? JSON.stringify(initialData) : undefined;
// 이미 초기화되었고, ID가 동일하고, 생성 모드 데이터도 동일하면 스킵
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
// 생성 모드에서 데이터가 새로 전달된 경우는 재초기화 필요
if (!createModeDataHash || capturedInitialData.current) {
console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨");
// console.log("[UniversalFormModal] 초기화 스킵 - 이미 초기화됨");
// 🆕 채번 플래그가 true인데 formData에 값이 없으면 재생성 필요
// (컴포넌트 remount로 인해 state가 초기화된 경우)
return;
@ -245,18 +248,18 @@ export function UniversalFormModalComponent({
// 🆕 컴포넌트 remount 감지: hasInitialized가 true인데 formData가 비어있으면 재초기화
// (React의 Strict Mode나 EmbeddedScreen 리렌더링으로 인한 remount)
if (hasInitialized.current && !currentIdString) {
console.log("[UniversalFormModal] 컴포넌트 remount 감지 - 채번 플래그 초기화");
// console.log("[UniversalFormModal] 컴포넌트 remount 감지 - 채번 플래그 초기화");
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
}
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
if (hasInitialized.current && currentIdString && lastInitializedId.current !== currentIdString) {
console.log("[UniversalFormModal] ID 변경 감지 - 재초기화:", {
prevId: lastInitializedId.current,
newId: currentIdString,
initialData: initialData,
});
// console.log("[UniversalFormModal] ID 변경 감지 - 재초기화:", {
// prevId: lastInitializedId.current,
// newId: currentIdString,
// initialData: initialData,
// });
// 채번 플래그 초기화 (새 항목이므로)
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
@ -266,10 +269,10 @@ export function UniversalFormModalComponent({
if (initialData && Object.keys(initialData).length > 0) {
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
lastInitializedId.current = currentIdString;
console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
// console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
}
console.log("[UniversalFormModal] initializeForm 호출 예정");
// console.log("[UniversalFormModal] initializeForm 호출 예정");
hasInitialized.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -279,7 +282,7 @@ export function UniversalFormModalComponent({
useEffect(() => {
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
// console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
@ -287,7 +290,7 @@ export function UniversalFormModalComponent({
// 컴포넌트 unmount 시 채번 플래그 초기화
useEffect(() => {
return () => {
console.log("[채번] 컴포넌트 unmount - 플래그 초기화");
// console.log("[채번] 컴포넌트 unmount - 플래그 초기화");
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
};
@ -359,14 +362,14 @@ export function UniversalFormModalComponent({
// 테이블 타입 섹션 찾기
const tableSection = config.sections.find((s) => s.type === "table");
if (!tableSection) {
console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시");
// console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시");
return;
}
console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", {
sectionId: tableSection.id,
itemCount: _groupedData.length,
});
// console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", {
// sectionId: tableSection.id,
// itemCount: _groupedData.length,
// });
// 원본 데이터 저장 (수정/삭제 추적용)
setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData)));
@ -421,31 +424,31 @@ export function UniversalFormModalComponent({
}
if (isGeneratingRef.current) {
console.log("[채번] 생성 진행 중 - 스킵");
// console.log("[채번] 생성 진행 중 - 스킵");
return;
}
isGeneratingRef.current = true; // 진행 중 표시
console.log("[채번] 생성 시작", { sectionsCount: config.sections.length });
// console.log("[채번] 생성 시작", { sectionsCount: config.sections.length });
const updatedData = { ...currentFormData };
let hasChanges = false;
for (const section of config.sections) {
console.log("[채번] 섹션 검사:", section.title, { type: section.type, repeatable: section.repeatable, fieldsCount: section.fields?.length });
// console.log("[채번] 섹션 검사:", section.title, { type: section.type, repeatable: section.repeatable, fieldsCount: section.fields?.length });
if (section.repeatable || section.type === "table") continue;
for (const field of (section.fields || [])) {
for (const field of section.fields || []) {
// generateOnOpen은 기본값 true (undefined일 경우 true로 처리)
const shouldGenerateOnOpen = field.numberingRule?.generateOnOpen !== false;
console.log("[채번] 필드 검사:", field.columnName, {
hasNumberingRule: !!field.numberingRule,
enabled: field.numberingRule?.enabled,
generateOnOpen: field.numberingRule?.generateOnOpen,
shouldGenerateOnOpen,
ruleId: field.numberingRule?.ruleId,
currentValue: updatedData[field.columnName],
});
// console.log("[채번] 필드 검사:", field.columnName, {
// hasNumberingRule: !!field.numberingRule,
// enabled: field.numberingRule?.enabled,
// generateOnOpen: field.numberingRule?.generateOnOpen,
// shouldGenerateOnOpen,
// ruleId: field.numberingRule?.ruleId,
// currentValue: updatedData[field.columnName],
// });
if (
field.numberingRule?.enabled &&
shouldGenerateOnOpen &&
@ -453,22 +456,29 @@ export function UniversalFormModalComponent({
!updatedData[field.columnName]
) {
try {
console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
// console.log(`[채번 미리보기 API 호출] ${field.columnName}, ruleId: ${field.numberingRule.ruleId}`);
// generateOnOpen: 미리보기만 표시 (DB 시퀀스 증가 안 함)
const response = await previewNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
updatedData[field.columnName] = response.data.generatedCode;
const generatedCode = response.data.generatedCode;
updatedData[field.columnName] = generatedCode;
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
const ruleIdKey = `${field.columnName}_numberingRuleId`;
updatedData[ruleIdKey] = field.numberingRule.ruleId;
// 원본 채번 값 저장 (수동 모드 감지용)
setNumberingOriginalValues((prev) => ({
...prev,
[field.columnName]: generatedCode,
}));
hasChanges = true;
numberingGeneratedRef.current = true; // 생성 완료 표시
console.log(
`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
);
console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
// console.log(
// `[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
// );
// console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
if (onChange) {
@ -476,7 +486,7 @@ export function UniversalFormModalComponent({
...updatedData,
[ruleIdKey]: field.numberingRule.ruleId,
});
console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
// console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
}
}
} catch (error) {
@ -497,17 +507,17 @@ export function UniversalFormModalComponent({
// 폼 초기화
const initializeForm = useCallback(async () => {
console.log("[initializeForm] 시작");
// console.log("[initializeForm] 시작");
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
const effectiveInitialData = capturedInitialData.current || initialData;
console.log("[initializeForm] 초기 데이터:", {
capturedInitialData: capturedInitialData.current,
initialData: initialData,
effectiveInitialData: effectiveInitialData,
hasData: effectiveInitialData && Object.keys(effectiveInitialData).length > 0,
});
// console.log("[initializeForm] 초기 데이터:", {
// capturedInitialData: capturedInitialData.current,
// initialData: initialData,
// effectiveInitialData: effectiveInitialData,
// hasData: effectiveInitialData && Object.keys(effectiveInitialData).length > 0,
// });
const newFormData: FormDataState = {};
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
@ -534,7 +544,7 @@ export function UniversalFormModalComponent({
continue;
} else {
// 일반 섹션 필드 초기화
for (const field of (section.fields || [])) {
for (const field of section.fields || []) {
// 기본값 설정
let value = field.defaultValue ?? "";
@ -562,7 +572,9 @@ export function UniversalFormModalComponent({
const triggerValue = effectiveInitialData[group.triggerField];
if (triggerValue === group.triggerValueOnAdd) {
newActivatedGroups.add(key);
console.log(`[initializeForm] 옵셔널 그룹 자동 활성화: ${key}, triggerField=${group.triggerField}, value=${triggerValue}`);
console.log(
`[initializeForm] 옵셔널 그룹 자동 활성화: ${key}, triggerField=${group.triggerField}, value=${triggerValue}`,
);
// 활성화된 그룹의 필드값도 초기화
for (const field of group.fields || []) {
@ -588,6 +600,244 @@ export function UniversalFormModalComponent({
}
}
// 🆕 테이블 섹션(type: "table") 디테일 데이터 로드 (마스터-디테일 구조)
// 수정 모드일 때 디테일 테이블에서 데이터 가져오기
if (effectiveInitialData) {
console.log("[initializeForm] 테이블 섹션 디테일 로드 시작", {
sectionsCount: config.sections.length,
effectiveInitialDataKeys: Object.keys(effectiveInitialData),
});
for (const section of config.sections) {
if (section.type !== "table" || !section.tableConfig) {
continue;
}
const tableConfig = section.tableConfig;
const editConfig = tableConfig.editConfig;
const saveConfig = tableConfig.saveConfig;
console.log(`[initializeForm] 테이블 섹션 ${section.id} 검사:`, {
hasEditConfig: !!editConfig,
loadOnEdit: editConfig?.loadOnEdit,
hasSaveConfig: !!saveConfig,
targetTable: saveConfig?.targetTable,
linkColumn: editConfig?.linkColumn,
});
// 수정 모드 로드 설정 확인 (기본값: true)
if (editConfig?.loadOnEdit === false) {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: loadOnEdit=false, 스킵`);
continue;
}
// 디테일 테이블과 연결 정보 확인
const detailTable = saveConfig?.targetTable;
let linkColumn = editConfig?.linkColumn;
if (!detailTable) {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: saveConfig.targetTable 미설정, 스킵`);
continue;
}
// linkColumn이 설정되지 않았으면, 디테일 테이블 컬럼 정보 조회하여 자동 감지
if (!linkColumn?.masterField || !linkColumn?.detailField) {
try {
// 마스터 테이블명 확인 (saveConfig에서)
// 1. customApiSave.multiTable.mainTable.tableName (다중 테이블 저장)
// 2. saveConfig.tableName (단일 테이블 저장)
const masterTable =
config.saveConfig?.customApiSave?.multiTable?.mainTable?.tableName || config.saveConfig?.tableName;
// 디테일 테이블의 컬럼 목록 조회
const columnsResponse = await apiClient.get(`/table-management/tables/${detailTable}/columns`);
if (columnsResponse.data?.success && columnsResponse.data?.data) {
// API 응답 구조: { success, data: { columns: [...], total, page, ... } }
const columnsArray = columnsResponse.data.data.columns || columnsResponse.data.data || [];
const detailColumnsData = Array.isArray(columnsArray) ? columnsArray : [];
const detailColumns = detailColumnsData.map((col: any) => col.column_name || col.columnName);
const masterKeys = Object.keys(effectiveInitialData);
console.log(`[initializeForm] 테이블 섹션 ${section.id}: 연결 필드 자동 감지`, {
masterTable,
detailTable,
detailColumnsCount: detailColumnsData.length,
});
// 방법 1: 엔티티 관계 기반 감지 (정확)
// 디테일 테이블에서 마스터 테이블을 참조하는 엔티티 컬럼 찾기
if (masterTable) {
for (const col of detailColumnsData) {
const colName = col.column_name || col.columnName;
const inputType = col.input_type || col.inputType;
// 엔티티 타입 컬럼 확인
if (inputType === "entity") {
// reference_table 또는 detail_settings에서 참조 테이블 확인
let refTable = col.reference_table || col.referenceTable;
// detail_settings에서 referenceTable 확인
if (!refTable && col.detail_settings) {
try {
const settings =
typeof col.detail_settings === "string"
? JSON.parse(col.detail_settings)
: col.detail_settings;
refTable = settings.referenceTable;
} catch {
// JSON 파싱 실패 무시
}
}
// 마스터 테이블을 참조하는 컬럼 발견
if (refTable === masterTable) {
// 참조 컬럼 확인 (마스터 테이블의 어떤 컬럼을 참조하는지)
let refColumn = col.reference_column || col.referenceColumn;
if (!refColumn && col.detail_settings) {
try {
const settings =
typeof col.detail_settings === "string"
? JSON.parse(col.detail_settings)
: col.detail_settings;
refColumn = settings.referenceColumn;
} catch {
// JSON 파싱 실패 무시
}
}
// 마스터 데이터에 해당 컬럼 값이 있는지 확인
if (refColumn && effectiveInitialData[refColumn]) {
console.log(
`[initializeForm] 테이블 섹션 ${section.id}: 엔티티 관계 감지 - ${colName}${masterTable}.${refColumn}`,
);
linkColumn = { masterField: refColumn, detailField: colName };
break;
}
}
}
}
}
// 방법 2: 공통 컬럼 패턴 기반 감지 (폴백)
// 엔티티 관계가 없으면 공통 컬럼명 패턴으로 찾기
if (!linkColumn) {
const priorityPatterns = ["_no", "_number", "_code", "_id"];
for (const pattern of priorityPatterns) {
for (const masterKey of masterKeys) {
if (
masterKey.endsWith(pattern) &&
detailColumns.includes(masterKey) &&
effectiveInitialData[masterKey] &&
masterKey !== "id" &&
masterKey !== "company_code"
) {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: 공통 컬럼 패턴 감지 - ${masterKey}`);
linkColumn = { masterField: masterKey, detailField: masterKey };
break;
}
}
if (linkColumn) break;
}
}
// 방법 3: 일반 공통 컬럼 (마지막 폴백)
if (!linkColumn) {
for (const masterKey of masterKeys) {
if (
detailColumns.includes(masterKey) &&
effectiveInitialData[masterKey] &&
masterKey !== "id" &&
masterKey !== "company_code" &&
!masterKey.startsWith("__")
) {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: 공통 컬럼 감지 - ${masterKey}`);
linkColumn = { masterField: masterKey, detailField: masterKey };
break;
}
}
}
}
} catch (error) {
console.warn(`[initializeForm] 테이블 섹션 ${section.id}: 컬럼 정보 조회 실패`, error);
}
}
if (!linkColumn?.masterField || !linkColumn?.detailField) {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: linkColumn 미설정 및 자동 감지 실패, 스킵`);
continue;
}
// 마스터 테이블의 연결 필드 값 가져오기
const masterValue = effectiveInitialData[linkColumn.masterField];
if (!masterValue) {
console.log(
`[initializeForm] 테이블 섹션 ${section.id}: masterField(${linkColumn.masterField}) 값 없음, 스킵`,
);
continue;
}
try {
console.log(`[initializeForm] 테이블 섹션 ${section.id}: 디테일 데이터 로드 시작`, {
detailTable,
linkColumn,
masterValue,
});
// 디테일 테이블에서 데이터 조회
// operator: "equals"를 사용하여 정확히 일치하는 값만 검색 (엔티티 타입 컬럼에서 중요)
const searchCondition: Record<string, any> = {
[linkColumn.detailField]: { value: masterValue, operator: "equals" },
};
console.log(
`[initializeForm] 테이블 섹션 ${section.id}: API 요청 - URL: /table-management/tables/${detailTable}/data`,
);
console.log(
`[initializeForm] 테이블 섹션 ${section.id}: API 요청 - search:`,
JSON.stringify(searchCondition),
);
const response = await apiClient.post(`/table-management/tables/${detailTable}/data`, {
search: searchCondition, // filters가 아닌 search로 전달
page: 1,
size: 1000, // pageSize가 아닌 size로 전달
autoFilter: { enabled: true }, // 멀티테넌시 필터 적용
});
console.log(
`[initializeForm] 테이블 섹션 ${section.id}: API 응답 - success: ${response.data?.success}, total: ${response.data?.data?.total}, dataLength: ${response.data?.data?.data?.length}`,
);
if (response.data?.success) {
// 다양한 응답 구조 처리
let items: any[] = [];
const data = response.data.data;
if (Array.isArray(data)) {
items = data;
} else if (data?.items && Array.isArray(data.items)) {
items = data.items;
} else if (data?.rows && Array.isArray(data.rows)) {
items = data.rows;
} else if (data?.data && Array.isArray(data.data)) {
items = data.data;
}
console.log(`[initializeForm] 테이블 섹션 ${section.id}: ${items.length}건 로드됨`, items);
// 테이블 섹션 데이터를 formData에 저장 (TableSectionRenderer에서 사용)
const tableSectionKey = `__tableSection_${section.id}`;
newFormData[tableSectionKey] = items;
console.log(`[initializeForm] 테이블 섹션 ${section.id}: formData[${tableSectionKey}]에 저장됨`);
}
} catch (error) {
console.error(`[initializeForm] 테이블 섹션 ${section.id}: 디테일 데이터 로드 실패`, error);
}
}
}
setFormData(newFormData);
setRepeatSections(newRepeatSections);
setCollapsedSections(newCollapsed);
@ -671,9 +921,9 @@ export function UniversalFormModalComponent({
}
// 채번규칙 자동 생성
console.log("[initializeForm] generateNumberingValues 호출");
// console.log("[initializeForm] generateNumberingValues 호출");
await generateNumberingValues(newFormData);
console.log("[initializeForm] 완료");
// console.log("[initializeForm] 완료");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
@ -684,7 +934,7 @@ export function UniversalFormModalComponent({
_index: index,
};
for (const field of (section.fields || [])) {
for (const field of section.fields || []) {
item[field.columnName] = field.defaultValue ?? "";
}
@ -694,8 +944,42 @@ export function UniversalFormModalComponent({
// 필드 값 변경 핸들러
const handleFieldChange = useCallback(
(columnName: string, value: any) => {
// 채번규칙 필드의 수동 모드 감지
const originalNumberingValue = numberingOriginalValues[columnName];
const ruleIdKey = `${columnName}_numberingRuleId`;
// 해당 필드의 채번규칙 설정 찾기
let fieldConfig: FormFieldConfig | undefined;
for (const section of config.sections) {
if (section.type === "table" || section.repeatable) continue;
fieldConfig = section.fields?.find((f) => f.columnName === columnName);
if (fieldConfig) break;
// 옵셔널 필드 그룹에서도 찾기
for (const group of section.optionalFieldGroups || []) {
fieldConfig = group.fields?.find((f) => f.columnName === columnName);
if (fieldConfig) break;
}
if (fieldConfig) break;
}
setFormData((prev) => {
const newData = { ...prev, [columnName]: value };
// 채번규칙이 활성화된 필드이고, "사용자 수정 가능"이 ON인 경우
if (fieldConfig?.numberingRule?.enabled && fieldConfig?.numberingRule?.editable && originalNumberingValue) {
// 사용자가 값을 수정했으면 (원본과 다르면) ruleId 제거 → 수동 모드
if (value !== originalNumberingValue) {
delete newData[ruleIdKey];
console.log(`[채번 수동 모드] ${columnName}: 사용자가 값 수정 → ruleId 제거`);
} else {
// 원본 값으로 복구하면 ruleId 복구 → 자동 모드
if (fieldConfig.numberingRule.ruleId) {
newData[ruleIdKey] = fieldConfig.numberingRule.ruleId;
console.log(`[채번 자동 모드] ${columnName}: 원본 값 복구 → ruleId 복구`);
}
}
}
// onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용)
if (onChange) {
setTimeout(() => onChange(newData), 0);
@ -703,7 +987,7 @@ export function UniversalFormModalComponent({
return newData;
});
},
[onChange],
[onChange, numberingOriginalValues, config.sections],
);
// 반복 섹션 필드 값 변경 핸들러
@ -776,47 +1060,53 @@ export function UniversalFormModalComponent({
}, []);
// 옵셔널 필드 그룹 활성화
const activateOptionalFieldGroup = useCallback((sectionId: string, groupId: string) => {
const section = config.sections.find((s) => s.id === sectionId);
const group = section?.optionalFieldGroups?.find((g) => g.id === groupId);
if (!group) return;
const activateOptionalFieldGroup = useCallback(
(sectionId: string, groupId: string) => {
const section = config.sections.find((s) => s.id === sectionId);
const group = section?.optionalFieldGroups?.find((g) => g.id === groupId);
if (!group) return;
const key = `${sectionId}-${groupId}`;
setActivatedOptionalFieldGroups((prev) => {
const newSet = new Set(prev);
newSet.add(key);
return newSet;
});
const key = `${sectionId}-${groupId}`;
setActivatedOptionalFieldGroups((prev) => {
const newSet = new Set(prev);
newSet.add(key);
return newSet;
});
// 연동 필드 값 변경 (추가 시)
if (group.triggerField && group.triggerValueOnAdd !== undefined) {
handleFieldChange(group.triggerField, group.triggerValueOnAdd);
}
}, [config, handleFieldChange]);
// 연동 필드 값 변경 (추가 시)
if (group.triggerField && group.triggerValueOnAdd !== undefined) {
handleFieldChange(group.triggerField, group.triggerValueOnAdd);
}
},
[config, handleFieldChange],
);
// 옵셔널 필드 그룹 비활성화
const deactivateOptionalFieldGroup = useCallback((sectionId: string, groupId: string) => {
const section = config.sections.find((s) => s.id === sectionId);
const group = section?.optionalFieldGroups?.find((g) => g.id === groupId);
if (!group) return;
const deactivateOptionalFieldGroup = useCallback(
(sectionId: string, groupId: string) => {
const section = config.sections.find((s) => s.id === sectionId);
const group = section?.optionalFieldGroups?.find((g) => g.id === groupId);
if (!group) return;
const key = `${sectionId}-${groupId}`;
setActivatedOptionalFieldGroups((prev) => {
const newSet = new Set(prev);
newSet.delete(key);
return newSet;
});
const key = `${sectionId}-${groupId}`;
setActivatedOptionalFieldGroups((prev) => {
const newSet = new Set(prev);
newSet.delete(key);
return newSet;
});
// 연동 필드 값 변경 (제거 시)
if (group.triggerField && group.triggerValueOnRemove !== undefined) {
handleFieldChange(group.triggerField, group.triggerValueOnRemove);
}
// 연동 필드 값 변경 (제거 시)
if (group.triggerField && group.triggerValueOnRemove !== undefined) {
handleFieldChange(group.triggerField, group.triggerValueOnRemove);
}
// 옵셔널 필드 그룹 필드 값 초기화
(group.fields || []).forEach((field) => {
handleFieldChange(field.columnName, field.defaultValue || "");
});
}, [config, handleFieldChange]);
// 옵셔널 필드 그룹 필드 값 초기화
(group.fields || []).forEach((field) => {
handleFieldChange(field.columnName, field.defaultValue || "");
});
},
[config, handleFieldChange],
);
// Select 옵션 로드
const loadSelectOptions = useCallback(
@ -862,13 +1152,11 @@ export function UniversalFormModalComponent({
// categoryKey 형식: "tableName.columnName"
const [categoryTable, categoryColumn] = optionConfig.categoryKey.split(".");
if (categoryTable && categoryColumn) {
const response = await apiClient.get(
`/table-categories/${categoryTable}/${categoryColumn}/values`
);
const response = await apiClient.get(`/table-categories/${categoryTable}/${categoryColumn}/values`);
if (response.data?.success && response.data?.data) {
// 라벨값을 DB에 저장 (화면에 표시되는 값 그대로 저장)
// 코드값을 DB에 저장하고 라벨값을 화면에 표시
options = response.data.data.map((item: any) => ({
value: item.valueLabel || item.value_label,
value: item.valueCode || item.value_code,
label: item.valueLabel || item.value_label,
}));
}
@ -943,7 +1231,7 @@ export function UniversalFormModalComponent({
for (const section of config.sections) {
if (section.repeatable || section.type === "table") continue; // 반복 섹션 및 테이블 섹션은 별도 검증
for (const field of (section.fields || [])) {
for (const field of section.fields || []) {
if (field.required && !field.hidden && !field.numberingRule?.hidden) {
const value = formData[field.columnName];
if (value === undefined || value === null || value === "") {
@ -975,19 +1263,45 @@ export function UniversalFormModalComponent({
}
});
// 저장 시점 채번규칙 처리 (generateOnSave만 처리)
// 저장 시점 채번규칙 처리
for (const section of config.sections) {
// 테이블 타입 섹션은 건너뛰기
if (section.type === "table") continue;
for (const field of (section.fields || [])) {
if (field.numberingRule?.enabled && field.numberingRule?.generateOnSave && field.numberingRule?.ruleId) {
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
dataToSave[field.columnName] = response.data.generatedCode;
console.log(`[채번 할당] ${field.columnName} = ${response.data.generatedCode}`);
for (const field of section.fields || []) {
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
const ruleIdKey = `${field.columnName}_numberingRuleId`;
const hasRuleId = dataToSave[ruleIdKey]; // 사용자가 수정하지 않았으면 ruleId 유지됨
// 채번 규칙 할당 조건
const shouldAllocate =
// 1. generateOnSave가 ON인 경우: 항상 저장 시점에 할당
field.numberingRule.generateOnSave ||
// 2. editable이 OFF인 경우: 사용자 입력 무시하고 채번 규칙으로 덮어씌움
!field.numberingRule.editable ||
// 3. editable이 ON이고 사용자가 수정하지 않은 경우 (ruleId 유지됨): 실제 번호 할당
(field.numberingRule.editable && hasRuleId);
if (shouldAllocate) {
const response = await allocateNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
dataToSave[field.columnName] = response.data.generatedCode;
let reason = "(알 수 없음)";
if (field.numberingRule.generateOnSave) {
reason = "(generateOnSave)";
} else if (!field.numberingRule.editable) {
reason = "(editable=OFF, 강제 덮어씌움)";
} else if (hasRuleId) {
reason = "(editable=ON, 사용자 미수정)";
}
console.log(`[채번 할당] ${field.columnName} = ${response.data.generatedCode} ${reason}`);
} else {
console.error(`[채번 실패] ${field.columnName}:`, response.error);
}
} else {
console.error(`[채번 실패] ${field.columnName}:`, response.error);
console.log(
`[채번 스킵] ${field.columnName}: 사용자가 직접 입력한 값 유지 = ${dataToSave[field.columnName]}`,
);
}
}
}
@ -995,22 +1309,30 @@ export function UniversalFormModalComponent({
// 별도 테이블에 저장해야 하는 테이블 섹션 목록
const tableSectionsForSeparateTable = config.sections.filter(
(s) => s.type === "table" &&
(s) =>
s.type === "table" &&
s.tableConfig?.saveConfig?.targetTable &&
s.tableConfig.saveConfig.targetTable !== config.saveConfig.tableName
s.tableConfig.saveConfig.targetTable !== config.saveConfig.tableName,
);
// 테이블 섹션이 있고 메인 테이블에 품목별로 저장하는 경우 (공통 + 개별 병합 저장)
// targetTable이 없거나 메인 테이블과 같은 경우
const tableSectionsForMainTable = config.sections.filter(
(s) => s.type === "table" &&
(s) =>
s.type === "table" &&
(!s.tableConfig?.saveConfig?.targetTable ||
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName)
s.tableConfig.saveConfig.targetTable === config.saveConfig.tableName),
);
console.log("[saveSingleRow] 메인 테이블:", config.saveConfig.tableName);
console.log("[saveSingleRow] 메인 테이블에 저장할 테이블 섹션:", tableSectionsForMainTable.map(s => s.id));
console.log("[saveSingleRow] 별도 테이블에 저장할 테이블 섹션:", tableSectionsForSeparateTable.map(s => s.id));
console.log(
"[saveSingleRow] 메인 테이블에 저장할 테이블 섹션:",
tableSectionsForMainTable.map((s) => s.id),
);
console.log(
"[saveSingleRow] 별도 테이블에 저장할 테이블 섹션:",
tableSectionsForSeparateTable.map((s) => s.id),
);
console.log("[saveSingleRow] 테이블 섹션 데이터 키:", Object.keys(tableSectionData));
console.log("[saveSingleRow] dataToSave 키:", Object.keys(dataToSave));
@ -1057,7 +1379,7 @@ export function UniversalFormModalComponent({
const response = await apiClient.post(
`/table-management/tables/${config.saveConfig.tableName}/add`,
rowToSave
rowToSave,
);
if (!response.data?.success) {
@ -1164,7 +1486,7 @@ export function UniversalFormModalComponent({
const saveResponse = await apiClient.post(
`/table-management/tables/${section.tableConfig.saveConfig.targetTable}/add`,
itemToSave
itemToSave,
);
if (!saveResponse.data?.success) {
@ -1174,7 +1496,13 @@ export function UniversalFormModalComponent({
}
}
}
}, [config.sections, config.saveConfig.tableName, config.saveConfig.primaryKeyColumn, config.saveConfig.sectionSaveModes, formData]);
}, [
config.sections,
config.saveConfig.tableName,
config.saveConfig.primaryKeyColumn,
config.saveConfig.sectionSaveModes,
formData,
]);
// 다중 행 저장 (겸직 등)
const saveMultipleRows = useCallback(async () => {
@ -1250,7 +1578,7 @@ export function UniversalFormModalComponent({
for (const section of config.sections) {
if (section.repeatable || section.type === "table") continue;
for (const field of (section.fields || [])) {
for (const field of section.fields || []) {
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
// generateOnSave 또는 generateOnOpen 모두 저장 시 실제 순번 할당
const shouldAllocate = field.numberingRule.generateOnSave || field.numberingRule.generateOnOpen;
@ -1325,7 +1653,7 @@ export function UniversalFormModalComponent({
for (const section of config.sections) {
if (section.repeatable || section.type === "table") continue;
for (const field of (section.fields || [])) {
for (const field of section.fields || []) {
// 채번규칙이 활성화된 필드 처리
if (field.numberingRule?.enabled && field.numberingRule?.ruleId) {
// 신규 생성이거나 값이 없는 경우에만 채번
@ -1683,9 +2011,7 @@ export function UniversalFormModalComponent({
// 메인 표시 컬럼 (displayColumn)
const mainDisplayVal = row[lfg.displayColumn || ""] || "";
// 서브 표시 컬럼 (subDisplayColumn이 있으면 사용, 없으면 valueColumn 사용)
const subDisplayVal = lfg.subDisplayColumn
? (row[lfg.subDisplayColumn] || "")
: (row[valueColumn] || "");
const subDisplayVal = lfg.subDisplayColumn ? row[lfg.subDisplayColumn] || "" : row[valueColumn] || "";
switch (lfg.displayFormat) {
case "code_name":
@ -1704,7 +2030,10 @@ export function UniversalFormModalComponent({
matches.forEach((match) => {
const columnName = match.slice(1, -1); // { } 제거
const columnValue = row[columnName];
result = result.replace(match, columnValue !== undefined && columnValue !== null ? String(columnValue) : "");
result = result.replace(
match,
columnValue !== undefined && columnValue !== null ? String(columnValue) : "",
);
});
}
return result;
@ -1761,7 +2090,12 @@ export function UniversalFormModalComponent({
<SelectContent>
{sourceData.length > 0 ? (
sourceData
.filter((row) => row[valueColumn] !== null && row[valueColumn] !== undefined && String(row[valueColumn]) !== "")
.filter(
(row) =>
row[valueColumn] !== null &&
row[valueColumn] !== undefined &&
String(row[valueColumn]) !== "",
)
.map((row, index) => (
<SelectItem key={`${row[valueColumn]}_${index}`} value={String(row[valueColumn])}>
{getDisplayText(row)}
@ -2025,9 +2359,7 @@ export function UniversalFormModalComponent({
{/* 옵셔널 필드 그룹 렌더링 */}
{section.optionalFieldGroups && section.optionalFieldGroups.length > 0 && (
<div className="mt-4 space-y-3">
{section.optionalFieldGroups.map((group) =>
renderOptionalFieldGroup(section, group, sectionColumns)
)}
{section.optionalFieldGroups.map((group) => renderOptionalFieldGroup(section, group, sectionColumns))}
</div>
)}
</CardContent>
@ -2055,7 +2387,7 @@ export function UniversalFormModalComponent({
const renderOptionalFieldGroup = (
section: FormSectionConfig,
group: OptionalFieldGroupConfig,
sectionColumns: number
sectionColumns: number,
) => {
const key = `${section.id}-${group.id}`;
const isActivated = activatedOptionalFieldGroups.has(key);
@ -2074,9 +2406,7 @@ export function UniversalFormModalComponent({
<div className="flex items-center justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">{group.title}</p>
{group.description && (
<p className="text-muted-foreground/70 mt-0.5 text-xs">{group.description}</p>
)}
{group.description && <p className="text-muted-foreground/70 mt-0.5 text-xs">{group.description}</p>}
</div>
<Button
variant="outline"
@ -2115,16 +2445,10 @@ export function UniversalFormModalComponent({
<div className="flex items-center justify-between p-3">
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 text-left hover:opacity-80">
{isCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<div>
<p className="text-sm font-medium">{group.title}</p>
{group.description && (
<p className="text-muted-foreground mt-0.5 text-xs">{group.description}</p>
)}
{group.description && <p className="text-muted-foreground mt-0.5 text-xs">{group.description}</p>}
</div>
</button>
</CollapsibleTrigger>
@ -2154,8 +2478,8 @@ export function UniversalFormModalComponent({
formData[field.columnName],
(value) => handleFieldChange(field.columnName, value),
`${section.id}-${group.id}-${field.id}`,
groupColumns
)
groupColumns,
),
)}
</div>
</CollapsibleContent>
@ -2169,9 +2493,7 @@ export function UniversalFormModalComponent({
<div className="mb-3 flex items-center justify-between">
<div>
<p className="text-sm font-medium">{group.title}</p>
{group.description && (
<p className="text-muted-foreground mt-0.5 text-xs">{group.description}</p>
)}
{group.description && <p className="text-muted-foreground mt-0.5 text-xs">{group.description}</p>}
</div>
<Button
variant="ghost"
@ -2198,8 +2520,8 @@ export function UniversalFormModalComponent({
formData[field.columnName],
(value) => handleFieldChange(field.columnName, value),
`${section.id}-${group.id}-${field.id}`,
groupColumns
)
groupColumns,
),
)}
</div>
</div>
@ -2327,7 +2649,8 @@ export function UniversalFormModalComponent({
<div className="text-muted-foreground text-center">
<p className="font-medium">{config.modal.title || "범용 폼 모달"}</p>
<p className="mt-1 text-xs">
{config.sections.length} |{config.sections.reduce((acc, s) => acc + (s.fields?.length || 0), 0)}
{config.sections.length} |{config.sections.reduce((acc, s) => acc + (s.fields?.length || 0), 0)}
</p>
<p className="mt-1 text-xs"> : {config.saveConfig.tableName || "(미설정)"}</p>
</div>

View File

@ -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 }[];
}>({});
// 부모 화면에서 전달 가능한 필드 목록
@ -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,
@ -202,7 +192,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
// 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,
@ -215,7 +205,7 @@ 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,20 +360,23 @@ 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(
@ -400,7 +404,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
}),
});
},
[config, onChange]
[config, onChange],
);
// 테이블 섹션 설정 모달 열기
@ -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>
)}
<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>
<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="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>
{/* 순서 조정 버튼 */}
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex gap-1">
{/* 필드 목록 (필드 타입만) */}
{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,
})) || []
}
/>
)}
@ -849,9 +872,7 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
// 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 상태도 업데이트 (다음에 다시 열었을 때 최신 값 반영)
@ -885,25 +906,24 @@ export function UniversalFormModalConfigPanel({ config, onChange, allComponents
// 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[]}

View File

@ -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 }[];
@ -2453,6 +2454,11 @@ export function TableSectionSettingsModal({
// 활성 탭
const [activeTab, setActiveTab] = useState("source");
// 사전 필터 카테고리 옵션 캐시 (컬럼명 -> 옵션 배열)
const [preFilterCategoryOptions, setPreFilterCategoryOptions] = useState<
Record<string, { value: string; label: string }[]>
>({});
// open이 변경될 때마다 데이터 동기화
useEffect(() => {
if (open) {
@ -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 />

View File

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

View File

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