Merge remote-tracking branch 'origin/main' into ksh

This commit is contained in:
SeongHyun Kim 2026-01-06 13:23:00 +09:00
commit 75b5530d04
26 changed files with 956 additions and 438 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

@ -55,3 +55,4 @@ export default router;

View File

@ -51,3 +51,4 @@ export default router;

View File

@ -67,3 +67,4 @@ export default router;

View File

@ -55,3 +55,4 @@ export default router;

View File

@ -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 = "";

View File

@ -2409,11 +2409,19 @@ export class TableManagementService {
}
// SET 절 생성 (수정할 데이터) - 먼저 생성
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
const setConditions: string[] = [];
const setValues: any[] = [];
let paramIndex = 1;
const skippedColumns: string[] = [];
Object.keys(updatedData).forEach((column) => {
// 테이블에 존재하지 않는 컬럼은 스킵
if (!columnTypeMap.has(column)) {
skippedColumns.push(column);
return;
}
const dataType = columnTypeMap.get(column) || "text";
setConditions.push(
`"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}`
@ -2424,6 +2432,10 @@ export class TableManagementService {
paramIndex++;
});
if (skippedColumns.length > 0) {
logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`);
}
// WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용)
let whereConditions: string[] = [];
let whereValues: any[] = [];

View File

@ -587,3 +587,4 @@ const result = await executeNodeFlow(flowId, {

View File

@ -360,3 +360,4 @@

View File

@ -346,3 +346,4 @@ const getComponentValue = (componentId: string) => {

View File

@ -127,3 +127,4 @@ export default function ScreenManagementPage() {
</div>
);
}

View File

@ -140,3 +140,4 @@ export const useActiveTabOptional = () => {

View File

@ -197,3 +197,4 @@ export function applyAutoFillToFormData(

View File

@ -44,7 +44,42 @@ export function AutocompleteSearchInputComponent({
const displayField = config?.displayField || propDisplayField || "";
const displayFields = config?.displayFields || (displayField ? [displayField] : []); // 다중 표시 필드
const displaySeparator = config?.displaySeparator || " → "; // 구분자
const valueField = config?.valueField || propValueField || "";
// valueField 결정: fieldMappings 기반으로 추론 (config.valueField가 fieldMappings에 없으면 무시)
const getValueField = () => {
// fieldMappings가 있으면 그 안에서 추론 (가장 신뢰할 수 있는 소스)
if (config?.fieldMappings && config.fieldMappings.length > 0) {
// config.valueField가 fieldMappings의 sourceField에 있으면 사용
if (config?.valueField) {
const hasValueFieldInMappings = config.fieldMappings.some(
(m: any) => m.sourceField === config.valueField
);
if (hasValueFieldInMappings) {
return config.valueField;
}
// fieldMappings에 없으면 무시하고 추론
}
// _code 또는 _id로 끝나는 필드 우선 (보통 PK나 코드 필드)
const codeMapping = config.fieldMappings.find(
(m: any) => m.sourceField?.endsWith("_code") || m.sourceField?.endsWith("_id")
);
if (codeMapping) {
return codeMapping.sourceField;
}
// 없으면 첫 번째 매핑 사용
return config.fieldMappings[0].sourceField || "";
}
// fieldMappings가 없으면 기존 방식
if (config?.valueField) return config.valueField;
if (propValueField) return propValueField;
return "";
};
const valueField = getValueField();
const searchFields = config?.searchFields || propSearchFields || displayFields; // 검색 필드도 다중 표시 필드 사용
const placeholder = config?.placeholder || propPlaceholder || "검색...";
@ -76,11 +111,39 @@ export function AutocompleteSearchInputComponent({
// 선택된 데이터를 ref로도 유지 (리렌더링 시 초기화 방지)
const selectedDataRef = useRef<EntitySearchResult | null>(null);
const inputValueRef = useRef<string>("");
const initialValueLoadedRef = useRef<string | null>(null); // 초기값 로드 추적
// formData에서 현재 값 가져오기 (isInteractive 모드)
const currentValue = isInteractive && formData && component?.columnName
? formData[component.columnName]
: value;
// 우선순위: 1) component.columnName, 2) fieldMappings에서 valueField에 매핑된 targetField
const getCurrentValue = () => {
if (!isInteractive || !formData) {
return value;
}
// 1. component.columnName으로 직접 바인딩된 경우
if (component?.columnName && formData[component.columnName] !== undefined) {
return formData[component.columnName];
}
// 2. fieldMappings에서 valueField와 매핑된 targetField에서 값 가져오기
if (config?.fieldMappings && Array.isArray(config.fieldMappings)) {
const valueFieldMapping = config.fieldMappings.find(
(mapping: any) => mapping.sourceField === valueField
);
if (valueFieldMapping) {
const targetField = valueFieldMapping.targetField || valueFieldMapping.targetColumn;
if (targetField && formData[targetField] !== undefined) {
return formData[targetField];
}
}
}
return value;
};
const currentValue = getCurrentValue();
// selectedData 변경 시 ref도 업데이트
useEffect(() => {
@ -98,6 +161,79 @@ export function AutocompleteSearchInputComponent({
}
}, []);
// 초기값이 있을 때 해당 값의 표시 텍스트를 조회하여 설정
useEffect(() => {
const loadInitialDisplayValue = async () => {
// 이미 로드된 값이거나, 값이 없거나, 이미 선택된 데이터가 있으면 스킵
if (!currentValue || selectedData || selectedDataRef.current) {
return;
}
// 이미 같은 값을 로드한 적이 있으면 스킵
if (initialValueLoadedRef.current === currentValue) {
return;
}
// 테이블명과 필드 정보가 없으면 스킵
if (!tableName || !valueField) {
return;
}
console.log("🔄 AutocompleteSearchInput 초기값 로드:", {
currentValue,
tableName,
valueField,
displayFields,
});
try {
// API를 통해 해당 값의 표시 텍스트 조회
const { apiClient } = await import("@/lib/api/client");
const filterConditionWithValue = {
...filterCondition,
[valueField]: currentValue,
};
const params = new URLSearchParams({
searchText: "",
searchFields: searchFields.join(","),
filterCondition: JSON.stringify(filterConditionWithValue),
page: "1",
limit: "10",
});
const response = await apiClient.get<{ success: boolean; data: EntitySearchResult[] }>(
`/entity-search/${tableName}?${params.toString()}`
);
if (response.data.success && response.data.data && response.data.data.length > 0) {
const matchedItem = response.data.data.find((item: EntitySearchResult) =>
String(item[valueField]) === String(currentValue)
);
if (matchedItem) {
const displayText = getDisplayValue(matchedItem);
console.log("✅ 초기값 표시 텍스트 로드 성공:", {
currentValue,
displayText,
matchedItem,
});
setSelectedData(matchedItem);
setInputValue(displayText);
selectedDataRef.current = matchedItem;
inputValueRef.current = displayText;
initialValueLoadedRef.current = currentValue;
}
}
} catch (error) {
console.error("❌ 초기값 표시 텍스트 로드 실패:", error);
}
};
loadInitialDisplayValue();
}, [currentValue, tableName, valueField, displayFields, filterCondition, searchFields, selectedData]);
// value가 변경되면 표시값 업데이트 - 단, selectedData가 있으면 유지
useEffect(() => {
// selectedData가 있으면 표시값 유지 (사용자가 방금 선택한 경우)
@ -107,6 +243,7 @@ export function AutocompleteSearchInputComponent({
if (!currentValue) {
setInputValue("");
initialValueLoadedRef.current = null; // 값이 없어지면 초기화
}
}, [currentValue, selectedData]);

View File

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

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

View File

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

View File

@ -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 });
};
@ -3918,7 +3916,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
@ -3928,7 +3926,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":
@ -4142,7 +4140,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} 선택`}
/>
);

View File

@ -381,6 +381,34 @@ 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[]>([]);
// 소스 테이블의 카테고리 타입 컬럼 목록 로드
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(() => {
@ -1281,16 +1309,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 +1929,7 @@ export function TableSectionRenderer({
onSelect={handleConditionalAddItems}
columnLabels={columnLabels}
modalFilters={modalFiltersForModal}
categoryColumns={sourceCategoryColumns}
/>
</div>
);
@ -2000,6 +2038,7 @@ export function TableSectionRenderer({
onSelect={handleAddItems}
columnLabels={columnLabels}
modalFilters={modalFiltersForModal}
categoryColumns={sourceCategoryColumns}
/>
</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 }[];
}>({});
// 부모 화면에서 전달 가능한 필드 목록
@ -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[]}

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 }[];
@ -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 />

View File

@ -995,6 +995,40 @@ export class ButtonActionExecutor {
console.log("📋 [handleSave] 범용 폼 모달 공통 필드:", commonFields);
}
// 🆕 루트 레벨 formData에서 RepeaterFieldGroup에 전달할 공통 필드 추출
// 주문번호, 발주번호 등 마스터-디테일 관계에서 필요한 필드만 명시적으로 지정
const masterDetailFields = [
// 번호 필드
"order_no", // 발주번호
"sales_order_no", // 수주번호
"shipment_no", // 출하번호
"receipt_no", // 입고번호
"work_order_no", // 작업지시번호
// 거래처 필드
"supplier_code", // 공급처 코드
"supplier_name", // 공급처 이름
"customer_code", // 고객 코드
"customer_name", // 고객 이름
// 날짜 필드
"order_date", // 발주일
"sales_date", // 수주일
"shipment_date", // 출하일
"receipt_date", // 입고일
"due_date", // 납기일
// 담당자/메모 필드
"manager", // 담당자
"memo", // 메모
"remark", // 비고
];
for (const fieldName of masterDetailFields) {
const value = context.formData[fieldName];
if (value !== undefined && value !== "" && value !== null && !(fieldName in commonFields)) {
commonFields[fieldName] = value;
}
}
console.log("📋 [handleSave] 최종 공통 필드 (마스터-디테일 필드 포함):", commonFields);
for (const item of parsedData) {
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)

View File

@ -1689,3 +1689,4 @@ const 출고등록_설정: ScreenSplitPanel = {

View File

@ -536,3 +536,4 @@ const { data: config } = await getScreenSplitPanel(screenId);

View File

@ -523,3 +523,4 @@ function ScreenViewPage() {