This commit is contained in:
SeongHyun Kim 2026-01-12 17:28:11 +09:00
commit f799402564
22 changed files with 2944 additions and 1472 deletions

View File

@ -47,8 +47,13 @@ export interface SplitPanelConfig {
columns: Array<{ name: string; label: string; width?: number }>;
relation?: {
type: string;
foreignKey: string;
leftColumn: string;
foreignKey?: string;
leftColumn?: string;
// 복합키 지원 (새로운 방식)
keys?: Array<{
leftColumn: string;
rightColumn: string;
}>;
};
};
}
@ -210,8 +215,21 @@ class MasterDetailExcelService {
}
// 2. 분할 패널의 relation 정보가 있으면 우선 사용
let masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
let detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
// 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
let masterKeyColumn: string | undefined;
let detailFkColumn: string | undefined;
const relationKeys = splitPanel.rightPanel.relation?.keys;
if (relationKeys && relationKeys.length > 0) {
// keys 배열에서 첫 번째 키 사용
masterKeyColumn = relationKeys[0].leftColumn;
detailFkColumn = relationKeys[0].rightColumn;
logger.info(`keys 배열에서 관계 정보 사용: ${masterKeyColumn} -> ${detailFkColumn}`);
} else {
// 하위 호환성: 기존 leftColumn/foreignKey 사용
masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn;
detailFkColumn = splitPanel.rightPanel.relation?.foreignKey;
}
// 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
if (!masterKeyColumn || !detailFkColumn) {

View File

@ -187,71 +187,68 @@ class TableCategoryValueService {
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
}
// 2. 카테고리 값 조회 (형제 메뉴 포함)
// 2. 카테고리 값 조회 (메뉴 스코프 또는 형제 메뉴 포함)
let query: string;
let params: any[];
const baseSelect = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
`;
params = [tableName, columnName];
logger.info("최고 관리자 카테고리 값 조회");
// 최고 관리자: menuObjid가 있으면 해당 메뉴(및 형제 메뉴)의 값만 조회
if (menuObjid && siblingObjids.length > 0) {
query = baseSelect + ` AND menu_objid = ANY($3::numeric[])`;
params = [tableName, columnName, siblingObjids];
logger.info("최고 관리자 메뉴 스코프 카테고리 값 조회", { menuObjid, siblingObjids });
} else if (menuObjid) {
query = baseSelect + ` AND menu_objid = $3`;
params = [tableName, columnName, menuObjid];
logger.info("최고 관리자 단일 메뉴 카테고리 값 조회", { menuObjid });
} else {
// menuObjid 없으면 모든 값 조회 (중복 가능)
query = baseSelect;
params = [tableName, columnName];
logger.info("최고 관리자 전체 카테고리 값 조회 (menuObjid 없음)");
}
} else {
// 일반 회사: 자신의 카테고리 값만 조회
// 메뉴 스코프 제거: 같은 테이블.컬럼 조합은 모든 메뉴에서 공유
query = `
SELECT
value_id AS "valueId",
table_name AS "tableName",
column_name AS "columnName",
value_code AS "valueCode",
value_label AS "valueLabel",
value_order AS "valueOrder",
parent_value_id AS "parentValueId",
depth,
description,
color,
icon,
is_active AS "isActive",
is_default AS "isDefault",
company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND company_code = $3
`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회", { companyCode });
// 일반 회사: 자신의 회사 + menuObjid로 필터링
if (menuObjid && siblingObjids.length > 0) {
query = baseSelect + ` AND company_code = $3 AND menu_objid = ANY($4::numeric[])`;
params = [tableName, columnName, companyCode, siblingObjids];
logger.info("회사별 메뉴 스코프 카테고리 값 조회", { companyCode, menuObjid, siblingObjids });
} else if (menuObjid) {
query = baseSelect + ` AND company_code = $3 AND menu_objid = $4`;
params = [tableName, columnName, companyCode, menuObjid];
logger.info("회사별 단일 메뉴 카테고리 값 조회", { companyCode, menuObjid });
} else {
// menuObjid 없으면 회사 전체 조회 (중복 가능하지만 회사별로 제한)
query = baseSelect + ` AND company_code = $3`;
params = [tableName, columnName, companyCode];
logger.info("회사별 카테고리 값 조회 (menuObjid 없음)", { companyCode });
}
}
if (!includeInactive) {

View File

@ -705,43 +705,27 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
}
} else {
// 기존 단일 테이블 업로드 로직
console.log("📊 단일 테이블 업로드 시작:", {
tableName,
uploadMode,
numberingRuleId,
numberingTargetColumn,
dataCount: filteredData.length,
});
let successCount = 0;
let failCount = 0;
// 🆕 단일 테이블 채번 설정 확인
// 단일 테이블 채번 설정 확인
const hasNumbering = numberingRuleId && numberingTargetColumn;
console.log("📊 채번 설정:", { hasNumbering, numberingRuleId, numberingTargetColumn });
for (const row of filteredData) {
try {
let dataToSave = { ...row };
// 🆕 채번 적용: 각 행마다 채번 API 호출
// 채번 적용: 각 행마다 채번 API 호출
if (hasNumbering && uploadMode === "insert") {
try {
const { apiClient } = await import("@/lib/api/client");
console.log(`📊 채번 API 호출: /numbering-rules/${numberingRuleId}/allocate`);
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`);
console.log(`📊 채번 API 응답:`, numberingResponse.data);
// 응답 구조: { success: true, data: { generatedCode: "..." } }
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
if (numberingResponse.data?.success && generatedCode) {
dataToSave[numberingTargetColumn] = generatedCode;
console.log(`✅ 채번 적용: ${numberingTargetColumn} = ${generatedCode}`);
} else {
console.warn(`⚠️ 채번 실패: 응답에 코드 없음`, numberingResponse.data);
}
} catch (numError) {
console.error("채번 오류:", numError);
// 채번 실패 시에도 계속 진행 (채번 컬럼만 비워둠)
}
}

View File

@ -1,6 +1,7 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import React, { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useSearchParams } from "next/navigation";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@ -42,7 +43,7 @@ import {
} from "lucide-react";
import { tableTypeApi } from "@/lib/api/screen";
import { commonCodeApi } from "@/lib/api/commonCode";
import { getCurrentUser, UserInfo } from "@/lib/api/client";
import { apiClient, getCurrentUser, UserInfo } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
import { cn } from "@/lib/utils";
@ -100,11 +101,7 @@ const CascadingDropdownInForm: React.FC<CascadingDropdownInFormProps> = ({
const isDisabled = !parentValue || loading;
return (
<Select
value={value || ""}
onValueChange={(newValue) => onChange?.(newValue)}
disabled={isDisabled}
>
<Select value={value || ""} onValueChange={(newValue) => onChange?.(newValue)} disabled={isDisabled}>
<SelectTrigger className={className}>
{loading ? (
<div className="flex items-center gap-2">
@ -187,7 +184,17 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
const screenContext = useScreenContextOptional(); // 화면 컨텍스트 (좌측/우측 위치 확인용)
const splitPanelPosition = screenContext?.splitPanelPosition; // 분할 패널 내 위치
// URL에서 menuObjid 가져오기 (카테고리 값 조회 시 필요)
const searchParams = useSearchParams();
const menuObjid = useMemo(() => {
// 1. ScreenContext에서 가져오기
if (screenContext?.menuObjid) return screenContext.menuObjid;
// 2. URL 쿼리에서 가져오기
const urlMenuObjid = searchParams.get("menuObjid");
return urlMenuObjid ? parseInt(urlMenuObjid) : undefined;
}, [screenContext?.menuObjid, searchParams]);
const [data, setData] = useState<Record<string, any>[]>([]);
const [loading, setLoading] = useState(false);
const [searchValues, setSearchValues] = useState<Record<string, any>>({});
@ -199,7 +206,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const hasInitializedWidthsRef = useRef(false);
const columnRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
const isResizingRef = useRef(false);
// TableOptions 상태
const [filters, setFilters] = useState<TableFilter[]>([]);
const [grouping, setGrouping] = useState<string[]>([]);
@ -236,14 +243,19 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const [columnLabels, setColumnLabels] = useState<Record<string, string>>({}); // 컬럼명 -> 라벨 매핑
// 카테고리 값 매핑 캐시 (컬럼명 -> {코드 -> {라벨, 색상}})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color?: string }>>>({});
const [categoryMappings, setCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>>
>({});
// 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨)
const [categoryCodeLabels, setCategoryCodeLabels] = useState<Record<string, string>>({});
// 테이블 등록 (Context에 등록)
const tableId = `datatable-${component.id}`;
useEffect(() => {
if (!component.tableName || !component.columns) return;
registerTable({
tableId,
label: component.title || "데이터 테이블",
@ -320,7 +332,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
useEffect(() => {
const handleRelatedButtonSelect = (event: CustomEvent) => {
const { targetTable, filterColumn, filterValue } = event.detail || {};
// 이 테이블이 대상 테이블인지 확인
if (targetTable === component.tableName) {
console.log("📌 [InteractiveDataTable] RelatedDataButtons 필터 적용:", {
@ -365,8 +377,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
for (const col of categoryColumns) {
try {
// menuObjid가 있으면 쿼리 파라미터로 전달 (메뉴별 카테고리 색상 적용)
const queryParams = menuObjid ? `?menuObjid=${menuObjid}` : "";
const response = await apiClient.get(
`/table-categories/${component.tableName}/${col.columnName}/values`
`/table-categories/${component.tableName}/${col.columnName}/values${queryParams}`,
);
if (response.data.success && response.data.data) {
@ -379,7 +393,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
};
});
mappings[col.columnName] = mapping;
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping);
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid });
}
} catch (error) {
console.error(`❌ 카테고리 값 로드 실패 [${col.columnName}]:`, error);
@ -394,7 +408,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
};
loadCategoryMappings();
}, [component.tableName, component.columns, getColumnWebType]);
}, [component.tableName, component.columns, getColumnWebType, menuObjid]);
// 파일 상태 확인 함수
const checkFileStatus = useCallback(
@ -583,13 +597,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 없으면 테이블 타입 관리에서 설정된 값 찾기
const tableColumn = tableColumns.find((col) => col.columnName === columnName);
// input_type 우선 사용 (category 등)
const inputType = (tableColumn as any)?.input_type || (tableColumn as any)?.inputType;
if (inputType) {
return inputType;
}
// 없으면 webType 사용
return tableColumn?.webType || "text";
},
@ -696,19 +710,19 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
let linkedFilterValues: Record<string, any> = {};
let hasLinkedFiltersConfigured = false; // 연결 필터가 설정되어 있는지 여부
let hasSelectedLeftData = false; // 좌측에서 데이터가 선택되었는지 여부
if (splitPanelContext) {
// 연결 필터 설정 여부 확인 (현재 테이블에 해당하는 필터가 있는지)
const linkedFiltersConfig = splitPanelContext.linkedFilters || [];
hasLinkedFiltersConfigured = linkedFiltersConfig.some(
(filter) => filter.targetColumn?.startsWith(component.tableName + ".") ||
filter.targetColumn === component.tableName
(filter) =>
filter.targetColumn?.startsWith(component.tableName + ".") || filter.targetColumn === component.tableName,
);
// 좌측 데이터 선택 여부 확인
hasSelectedLeftData = splitPanelContext.selectedLeftData &&
Object.keys(splitPanelContext.selectedLeftData).length > 0;
hasSelectedLeftData =
splitPanelContext.selectedLeftData && Object.keys(splitPanelContext.selectedLeftData).length > 0;
linkedFilterValues = splitPanelContext.getLinkedFilterValues();
// 현재 테이블에 해당하는 필터만 추출 (테이블명.컬럼명 형식에서)
const tableSpecificFilters: Record<string, any> = {};
@ -727,7 +741,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}
linkedFilterValues = tableSpecificFilters;
}
// 🆕 연결 필터가 설정되어 있지만 좌측에서 데이터가 선택되지 않은 경우
// → 빈 데이터 표시 (모든 데이터를 보여주지 않음)
if (hasLinkedFiltersConfigured && !hasSelectedLeftData) {
@ -739,9 +753,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setLoading(false);
return;
}
// 🆕 RelatedDataButtons 필터 적용
let relatedButtonFilterValues: Record<string, any> = {};
const relatedButtonFilterValues: Record<string, any> = {};
if (relatedButtonFilter) {
relatedButtonFilterValues[relatedButtonFilter.filterColumn] = relatedButtonFilter.filterValue;
}
@ -752,16 +766,16 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
...linkedFilterValues,
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
};
console.log("🔍 데이터 조회 시작:", {
tableName: component.tableName,
page,
console.log("🔍 데이터 조회 시작:", {
tableName: component.tableName,
page,
pageSize,
linkedFilterValues,
relatedButtonFilterValues,
mergedSearchParams,
});
const result = await tableTypeApi.getTableData(component.tableName, {
page,
size: pageSize,
@ -769,11 +783,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
});
console.log("✅ 데이터 조회 완료:", {
console.log("✅ 데이터 조회 완료:", {
tableName: component.tableName,
dataLength: result.data.length,
dataLength: result.data.length,
total: result.total,
page: result.page
page: result.page,
});
setData(result.data);
@ -781,6 +795,45 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setTotalPages(result.totalPages);
setCurrentPage(result.page);
// 카테고리 코드 패턴(CATEGORY_*) 검출 및 라벨 조회
const detectAndLoadCategoryLabels = async () => {
const categoryCodes = new Set<string>();
result.data.forEach((row: Record<string, any>) => {
Object.values(row).forEach((value) => {
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
categoryCodes.add(value);
}
});
});
console.log("🏷️ [InteractiveDataTable] 감지된 카테고리 코드:", Array.from(categoryCodes));
// 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외)
const newCodes = Array.from(categoryCodes);
if (newCodes.length > 0) {
try {
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 호출:", newCodes);
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 API 응답:", response.data);
if (response.data.success && response.data.data) {
setCategoryCodeLabels((prev) => {
const newLabels = {
...prev,
...response.data.data,
};
console.log("🏷️ [InteractiveDataTable] 카테고리 라벨 캐시 업데이트:", newLabels);
return newLabels;
});
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
}
};
detectAndLoadCategoryLabels();
// 각 행의 파일 상태 확인 (전체 행 + 가상 파일 컬럼별)
const fileStatusPromises = result.data.map(async (rowData: Record<string, any>) => {
const primaryKeyField = Object.keys(rowData)[0];
@ -916,18 +969,18 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
try {
const columns = await tableTypeApi.getColumns(component.tableName);
setTableColumns(columns);
// 🆕 전체 컬럼 목록 설정
const columnNames = columns.map(col => col.columnName);
const columnNames = columns.map((col) => col.columnName);
setAllAvailableColumns(columnNames);
// 🆕 컬럼명 -> 라벨 매핑 생성
const labels: Record<string, string> = {};
columns.forEach(col => {
columns.forEach((col) => {
labels[col.columnName] = col.displayName || col.columnName;
});
setColumnLabels(labels);
// 🆕 localStorage에서 필터 설정 복원
if (user?.userId && component.componentId) {
const storageKey = `table-search-filter-${user.userId}-${component.componentId}`;
@ -983,28 +1036,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
);
// 행 선택 핸들러
const handleRowSelect = useCallback((rowIndex: number, isSelected: boolean) => {
setSelectedRows((prev) => {
const newSet = new Set(prev);
if (isSelected) {
newSet.add(rowIndex);
} else {
newSet.delete(rowIndex);
const handleRowSelect = useCallback(
(rowIndex: number, isSelected: boolean) => {
setSelectedRows((prev) => {
const newSet = new Set(prev);
if (isSelected) {
newSet.add(rowIndex);
} else {
newSet.delete(rowIndex);
}
return newSet;
});
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (isSelected && data[rowIndex]) {
splitPanelContext.setSelectedLeftData(data[rowIndex]);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
} else if (!isSelected) {
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
}
}
return newSet;
});
// 분할 패널 좌측에서 선택 시 컨텍스트에 데이터 저장 (연결 필터용)
if (splitPanelContext && splitPanelPosition === "left" && !splitPanelContext.disableAutoDataTransfer) {
if (isSelected && data[rowIndex]) {
splitPanelContext.setSelectedLeftData(data[rowIndex]);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 저장:", data[rowIndex]);
} else if (!isSelected) {
splitPanelContext.setSelectedLeftData(null);
console.log("🔗 [InteractiveDataTable] 좌측 선택 데이터 초기화");
}
}
}, [data, splitPanelContext, splitPanelPosition]);
},
[data, splitPanelContext, splitPanelPosition],
);
// 전체 선택/해제 핸들러
const handleSelectAll = useCallback(
@ -1586,7 +1642,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</div>
);
}
// 상세 설정에서 옵션 목록 가져오기
const options = detailSettings?.options || [];
if (options.length > 0) {
@ -1713,7 +1769,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "category": {
// 카테고리 셀렉트 (동적 import)
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const {
CategorySelectComponent,
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
return (
<div>
<CategorySelectComponent
@ -1841,7 +1899,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</div>
);
}
// 상세 설정에서 옵션 목록 가져오기
const optionsAdd = detailSettings?.options || [];
if (optionsAdd.length > 0) {
@ -2013,7 +2071,9 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "category": {
// 카테고리 셀렉트 (동적 import)
const { CategorySelectComponent } = require("@/lib/registry/components/category-select/CategorySelectComponent");
const {
CategorySelectComponent,
} = require("@/lib/registry/components/category-select/CategorySelectComponent");
return (
<div>
<CategorySelectComponent
@ -2151,8 +2211,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const actualWebType = getColumnWebType(column.columnName);
// 파일 타입 컬럼 처리 (가상 파일 컬럼 포함)
const isFileColumn =
actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
const isFileColumn = actualWebType === "file" || column.columnName === "file_path" || column.isVirtualFileColumn;
// 파일 타입 컬럼은 파일 아이콘으로 표시 (컬럼별 파일 관리)
if (isFileColumn && rowData) {
@ -2197,25 +2256,25 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
case "category": {
// 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
if (!value) return "";
const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)];
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값만 텍스트로 표시
const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color;
// 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시
if (!displayColor || displayColor === "none" || !categoryData) {
return <span className="text-sm">{displayLabel}</span>;
}
return (
<Badge
style={{
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor
}}
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
@ -2255,8 +2314,41 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
}
break;
default:
return String(value);
default: {
// 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값)
const strValue = String(value);
if (strValue.startsWith("CATEGORY_")) {
// 1. categoryMappings에서 해당 코드 검색 (색상 정보 포함)
for (const columnName of Object.keys(categoryMappings)) {
const mapping = categoryMappings[columnName];
const categoryData = mapping?.[strValue];
if (categoryData) {
// 색상이 있으면 배지로, 없으면 텍스트로 표시
if (categoryData.color && categoryData.color !== "none") {
return (
<Badge
style={{
backgroundColor: categoryData.color,
borderColor: categoryData.color,
}}
className="text-white"
>
{categoryData.label}
</Badge>
);
}
return <span className="text-sm">{categoryData.label}</span>;
}
}
// 2. categoryCodeLabels에서 검색 (API로 조회한 라벨)
const cachedLabel = categoryCodeLabels[strValue];
if (cachedLabel) {
return <span className="text-sm">{cachedLabel}</span>;
}
}
return strValue;
}
}
return String(value);
@ -2392,15 +2484,12 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
{visibleColumns.length > 0 ? (
<>
<div className="overflow-hidden rounded-lg border border-gray-200/60 bg-white shadow-sm">
<Table style={{ tableLayout: 'fixed' }}>
<TableHeader className="bg-gradient-to-b from-muted/50 to-muted border-b-2 border-primary/20">
<Table style={{ tableLayout: "fixed" }}>
<TableHeader className="from-muted/50 to-muted border-primary/20 border-b-2 bg-gradient-to-b">
<TableRow>
{/* 체크박스 컬럼 (삭제 기능이 활성화된 경우) */}
{component.enableDelete && (
<TableHead
className="px-4"
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
>
<TableHead className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}>
<Checkbox
checked={selectedRows.size === data.length && data.length > 0}
onCheckedChange={handleSelectAll}
@ -2409,74 +2498,74 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
)}
{visibleColumns.map((column: DataTableColumn, columnIndex) => {
const columnWidth = columnWidths[column.id];
return (
<TableHead
key={column.id}
ref={(el) => (columnRefs.current[column.id] = el)}
className="relative px-4 font-bold text-foreground/90 select-none text-center hover:bg-muted/70 transition-colors"
style={{
className="text-foreground/90 hover:bg-muted/70 relative px-4 text-center font-bold transition-colors select-none"
style={{
width: columnWidth ? `${columnWidth}px` : undefined,
userSelect: 'none'
userSelect: "none",
}}
>
{column.label}
{/* 리사이즈 핸들 */}
{columnIndex < visibleColumns.length - 1 && (
<div
className="absolute right-0 top-0 h-full w-2 cursor-col-resize hover:bg-blue-500 z-20"
style={{ marginRight: '-4px', paddingLeft: '4px', paddingRight: '4px' }}
className="absolute top-0 right-0 z-20 h-full w-2 cursor-col-resize hover:bg-blue-500"
style={{ marginRight: "-4px", paddingLeft: "4px", paddingRight: "4px" }}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
const thElement = columnRefs.current[column.id];
if (!thElement) return;
isResizingRef.current = true;
const startX = e.clientX;
const startWidth = columnWidth || thElement.offsetWidth;
// 드래그 중 텍스트 선택 방지
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
const handleMouseMove = (moveEvent: MouseEvent) => {
moveEvent.preventDefault();
const diff = moveEvent.clientX - startX;
const newWidth = Math.max(80, startWidth + diff);
// 직접 DOM 스타일 변경 (리렌더링 없음)
if (thElement) {
thElement.style.width = `${newWidth}px`;
}
};
const handleMouseUp = () => {
// 최종 너비를 state에 저장
if (thElement) {
const finalWidth = Math.max(80, thElement.offsetWidth);
setColumnWidths(prev => ({ ...prev, [column.id]: finalWidth }));
setColumnWidths((prev) => ({ ...prev, [column.id]: finalWidth }));
}
// 텍스트 선택 복원
document.body.style.userSelect = '';
document.body.style.cursor = '';
document.body.style.userSelect = "";
document.body.style.cursor = "";
// 약간의 지연 후 리사이즈 플래그 해제
setTimeout(() => {
isResizingRef.current = false;
}, 100);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}}
/>
)}
@ -2504,10 +2593,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
<TableRow key={rowIndex} className="transition-all duration-200 hover:bg-orange-100">
{/* 체크박스 셀 (삭제 기능이 활성화된 경우) */}
{component.enableDelete && (
<TableCell
className="px-4"
style={{ width: '48px', minWidth: '48px', maxWidth: '48px' }}
>
<TableCell className="px-4" style={{ width: "48px", minWidth: "48px", maxWidth: "48px" }}>
<Checkbox
checked={selectedRows.has(rowIndex)}
onCheckedChange={(checked) => handleRowSelect(rowIndex, checked as boolean)}
@ -2517,10 +2603,10 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
{visibleColumns.map((column: DataTableColumn) => {
const isNumeric = column.widgetType === "number" || column.widgetType === "decimal";
return (
<TableCell
key={column.id}
className="px-4 text-sm font-medium text-gray-900 whitespace-nowrap overflow-hidden text-ellipsis"
style={{ textAlign: isNumeric ? 'right' : 'left' }}
<TableCell
key={column.id}
className="overflow-hidden px-4 text-sm font-medium text-ellipsis whitespace-nowrap text-gray-900"
style={{ textAlign: isNumeric ? "right" : "left" }}
>
{formatCellValue(row[column.columnName], column, row)}
</TableCell>

View File

@ -6,7 +6,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Trash2, Plus } from "lucide-react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Trash2, Plus, ChevronDown, ChevronRight } from "lucide-react";
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
import { UnifiedColumnInfo } from "@/types/table-management";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
@ -19,6 +20,67 @@ interface DataFilterConfigPanelProps {
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
}
/**
*
*/
interface FilterItemCollapsibleProps {
filter: ColumnFilter;
index: number;
filterSummary: string;
onRemove: () => void;
children: React.ReactNode;
}
const FilterItemCollapsible: React.FC<FilterItemCollapsibleProps> = ({
filter,
index,
filterSummary,
onRemove,
children,
}) => {
const [isOpen, setIsOpen] = useState(!filter.columnName); // 설정 안 된 필터는 열린 상태로
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<div className="rounded-lg border p-2">
<CollapsibleTrigger asChild>
<div className="hover:bg-muted/50 cursor-pointer rounded p-1">
{/* 상단: 필터 번호 + 삭제 버튼 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
{isOpen ? (
<ChevronDown className="text-muted-foreground h-3 w-3 shrink-0" />
) : (
<ChevronRight className="text-muted-foreground h-3 w-3 shrink-0" />
)}
<span className="text-muted-foreground text-xs font-medium"> {index + 1}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 shrink-0 p-0"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 하단: 필터 요약 (전체 너비 사용) */}
<div className="mt-1 pl-4">
<span className="text-xs font-medium text-blue-600" title={filterSummary}>
{filterSummary}
</span>
</div>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">{children}</CollapsibleContent>
</div>
</Collapsible>
);
};
/**
*
* , ,
@ -36,13 +98,13 @@ export function DataFilterConfigPanel({
menuObjid,
sampleColumns: columns.slice(0, 3),
});
const [localConfig, setLocalConfig] = useState<DataFilterConfig>(
config || {
enabled: false,
filters: [],
matchType: "all",
}
},
);
// 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록)
@ -52,7 +114,7 @@ export function DataFilterConfigPanel({
useEffect(() => {
if (config) {
setLocalConfig(config);
// 🆕 기존 필터 중 카테고리 타입인 것들의 값을 로드
config.filters?.forEach((filter) => {
if (filter.valueType === "category" && filter.columnName) {
@ -69,7 +131,7 @@ export function DataFilterConfigPanel({
return; // 이미 로드되었거나 로딩 중이면 스킵
}
setLoadingCategories(prev => ({ ...prev, [columnName]: true }));
setLoadingCategories((prev) => ({ ...prev, [columnName]: true }));
try {
console.log("🔍 카테고리 값 로드 시작:", {
@ -82,7 +144,7 @@ export function DataFilterConfigPanel({
tableName,
columnName,
false, // includeInactive
menuObjid // 🆕 메뉴 OBJID 전달
menuObjid, // 🆕 메뉴 OBJID 전달
);
console.log("📦 카테고리 값 로드 응답:", response);
@ -92,16 +154,16 @@ export function DataFilterConfigPanel({
value: item.valueCode,
label: item.valueLabel,
}));
console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length });
setCategoryValues(prev => ({ ...prev, [columnName]: values }));
setCategoryValues((prev) => ({ ...prev, [columnName]: values }));
} else {
console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response);
}
} catch (error) {
console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error);
} finally {
setLoadingCategories(prev => ({ ...prev, [columnName]: false }));
setLoadingCategories((prev) => ({ ...prev, [columnName]: false }));
}
};
@ -145,9 +207,7 @@ export function DataFilterConfigPanel({
const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((filter) =>
filter.id === filterId ? { ...filter, [field]: value } : filter
),
filters: localConfig.filters.map((filter) => (filter.id === filterId ? { ...filter, [field]: value } : filter)),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
@ -178,7 +238,7 @@ export function DataFilterConfigPanel({
<>
{/* 테이블명 표시 */}
{tableName && (
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
: <span className="font-medium">{tableName}</span>
</div>
)}
@ -200,235 +260,127 @@ export function DataFilterConfigPanel({
)}
{/* 필터 목록 */}
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2">
{localConfig.filters.map((filter, index) => (
<div key={filter.id} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handleRemoveFilter(filter.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="max-h-[600px] space-y-2 overflow-y-auto pr-2">
{localConfig.filters.map((filter, index) => {
// 연산자 표시 텍스트
const operatorLabels: Record<string, string> = {
equals: "=",
not_equals: "!=",
greater_than: ">",
less_than: "<",
greater_than_or_equal: ">=",
less_than_or_equal: "<=",
between: "BETWEEN",
in: "IN",
not_in: "NOT IN",
contains: "LIKE",
starts_with: "시작",
ends_with: "끝",
is_null: "IS NULL",
is_not_null: "IS NOT NULL",
date_range_contains: "기간 내",
};
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
{filter.operator !== "date_range_contains" && (
<div>
<Label className="text-xs"></Label>
<Select
value={filter.columnName}
onValueChange={(value) => {
const column = columns.find((col) => col.columnName === value);
console.log("🔍 컬럼 선택:", {
columnName: value,
input_type: column?.input_type,
column,
});
// 컬럼 타입에 따라 valueType 자동 설정
let valueType: "static" | "category" | "code" = "static";
if (column?.input_type === "category") {
valueType = "category";
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
loadCategoryValues(value); // 카테고리 값 로드
} else if (column?.input_type === "code") {
valueType = "code";
}
// 한 번에 모든 변경사항 적용
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, columnName: value, valueType, value: "" }
: f
),
};
console.log("✅ 필터 설정 업데이트:", {
filterId: filter.id,
columnName: value,
valueType,
newConfig,
});
setLocalConfig(newConfig);
onConfigChange(newConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{(col.input_type === "category" || col.input_type === "code") && (
<span className="ml-2 text-xs text-muted-foreground">
({col.input_type})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
// 컬럼 라벨 찾기
const columnLabel =
columns.find((c) => c.columnName === filter.columnName)?.columnLabel || filter.columnName;
{/* 연산자 선택 */}
<div>
<Label className="text-xs"></Label>
<Select
value={filter.operator}
onValueChange={(value: any) => {
// date_range_contains 선택 시 한 번에 모든 변경사항 적용
if (value === "date_range_contains") {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, operator: value, valueType: "dynamic", value: "TODAY" }
: f
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
handleFilterChange(filter.id, "operator", value);
}
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals"> (=)</SelectItem>
<SelectItem value="not_equals"> ()</SelectItem>
<SelectItem value="greater_than"> (&gt;)</SelectItem>
<SelectItem value="less_than"> (&lt;)</SelectItem>
<SelectItem value="greater_than_or_equal"> ()</SelectItem>
<SelectItem value="less_than_or_equal"> ()</SelectItem>
<SelectItem value="between"> (BETWEEN)</SelectItem>
<SelectItem value="in"> (IN)</SelectItem>
<SelectItem value="not_in"> (NOT IN)</SelectItem>
<SelectItem value="contains"> (LIKE %value%)</SelectItem>
<SelectItem value="starts_with"> (LIKE value%)</SelectItem>
<SelectItem value="ends_with"> (LIKE %value)</SelectItem>
<SelectItem value="is_null">NULL</SelectItem>
<SelectItem value="is_not_null">NOT NULL</SelectItem>
<SelectItem value="date_range_contains"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
// 필터 요약 텍스트 생성
const filterSummary = filter.columnName
? `${columnLabel} ${operatorLabels[filter.operator] || filter.operator}${
filter.operator !== "is_null" && filter.operator !== "is_not_null" && filter.value
? ` ${filter.value}`
: ""
}`
: "설정 필요";
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
{filter.operator === "date_range_contains" && (
<>
<div className="col-span-2">
<p className="text-xs text-muted-foreground bg-muted/50 p-2 rounded">
💡 :
<br /> NULL
<br /> NULL
<br />
</p>
</div>
return (
<FilterItemCollapsible
key={filter.id}
filter={filter}
index={index}
filterSummary={filterSummary}
onRemove={() => handleRemoveFilter(filter.id)}
>
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
{filter.operator !== "date_range_contains" && (
<div>
<Label className="text-xs"> </Label>
<Label className="text-xs"></Label>
<Select
value={filter.rangeConfig?.startColumn || ""}
value={filter.columnName}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: value,
endColumn: filter.rangeConfig?.endColumn || "",
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="시작일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.filter(col =>
col.dataType?.toLowerCase().includes('date') ||
col.dataType?.toLowerCase().includes('time')
).map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.rangeConfig?.endColumn || ""}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: filter.rangeConfig?.startColumn || "",
endColumn: value,
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="종료일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.filter(col =>
col.dataType?.toLowerCase().includes('date') ||
col.dataType?.toLowerCase().includes('time')
).map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
const column = columns.find((col) => col.columnName === value);
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.valueType}
onValueChange={(value: any) => {
// dynamic 선택 시 한 번에 valueType과 value를 설정
if (value === "dynamic" && filter.operator === "date_range_contains") {
console.log("🔍 컬럼 선택:", {
columnName: value,
input_type: column?.input_type,
column,
});
// 컬럼 타입에 따라 valueType 자동 설정
let valueType: "static" | "category" | "code" = "static";
if (column?.input_type === "category") {
valueType = "category";
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
loadCategoryValues(value); // 카테고리 값 로드
} else if (column?.input_type === "code") {
valueType = "code";
}
// 한 번에 모든 변경사항 적용
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, valueType: value, value: "TODAY" }
: f
f.id === filter.id ? { ...f, columnName: value, valueType, value: "" } : f,
),
};
console.log("✅ 필터 설정 업데이트:", {
filterId: filter.id,
columnName: value,
valueType,
newConfig,
});
setLocalConfig(newConfig);
onConfigChange(newConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{(col.input_type === "category" || col.input_type === "code") && (
<span className="text-muted-foreground ml-2 text-xs">({col.input_type})</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* 연산자 선택 */}
<div>
<Label className="text-xs"></Label>
<Select
value={filter.operator}
onValueChange={(value: any) => {
// date_range_contains 선택 시 한 번에 모든 변경사항 적용
if (value === "date_range_contains") {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id ? { ...f, operator: value, valueType: "dynamic", value: "TODAY" } : f,
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
// static이나 다른 타입은 value를 빈 문자열로 초기화
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, valueType: value, value: "" }
: f
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
handleFilterChange(filter.id, "operator", value);
}
}}
>
@ -436,106 +388,240 @@ export function DataFilterConfigPanel({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> </SelectItem>
{filter.operator === "date_range_contains" && (
<SelectItem value="dynamic"> ( )</SelectItem>
)}
{isCategoryOrCodeColumn(filter.columnName) && (
<>
<SelectItem value="category"> </SelectItem>
<SelectItem value="code"> </SelectItem>
</>
)}
<SelectItem value="equals"> (=)</SelectItem>
<SelectItem value="not_equals"> ()</SelectItem>
<SelectItem value="greater_than"> (&gt;)</SelectItem>
<SelectItem value="less_than"> (&lt;)</SelectItem>
<SelectItem value="greater_than_or_equal"> ()</SelectItem>
<SelectItem value="less_than_or_equal"> ()</SelectItem>
<SelectItem value="between"> (BETWEEN)</SelectItem>
<SelectItem value="in"> (IN)</SelectItem>
<SelectItem value="not_in"> (NOT IN)</SelectItem>
<SelectItem value="contains"> (LIKE %value%)</SelectItem>
<SelectItem value="starts_with"> (LIKE value%)</SelectItem>
<SelectItem value="ends_with"> (LIKE %value)</SelectItem>
<SelectItem value="is_null">NULL</SelectItem>
<SelectItem value="is_not_null">NOT NULL</SelectItem>
<SelectItem value="date_range_contains"> ( )</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
{filter.operator !== "is_null" &&
filter.operator !== "is_not_null" &&
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
<div>
<Label className="text-xs"></Label>
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
{filter.operator === "date_range_contains" && (
<>
<div className="col-span-2">
<p className="text-muted-foreground bg-muted/50 rounded p-2 text-xs">
💡 :
<br /> NULL
<br /> NULL
<br />
</p>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.rangeConfig?.startColumn || ""}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: value,
endColumn: filter.rangeConfig?.endColumn || "",
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="시작일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns
.filter(
(col) =>
col.dataType?.toLowerCase().includes("date") ||
col.dataType?.toLowerCase().includes("time"),
)
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.rangeConfig?.endColumn || ""}
onValueChange={(value) => {
const newRangeConfig = {
...filter.rangeConfig,
startColumn: filter.rangeConfig?.startColumn || "",
endColumn: value,
};
handleFilterChange(filter.id, "rangeConfig", newRangeConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="종료일 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns
.filter(
(col) =>
col.dataType?.toLowerCase().includes("date") ||
col.dataType?.toLowerCase().includes("time"),
)
.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* 값 타입 선택 (카테고리/코드 컬럼 또는 date_range_contains) */}
{(isCategoryOrCodeColumn(filter.columnName) || filter.operator === "date_range_contains") && (
<div>
<Label className="text-xs"> </Label>
<Select
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
value={filter.valueType}
onValueChange={(value: any) => {
// dynamic 선택 시 한 번에 valueType과 value를 설정
if (value === "dynamic" && filter.operator === "date_range_contains") {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id ? { ...f, valueType: value, value: "TODAY" } : f,
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
} else {
// static이나 다른 타입은 value를 빈 문자열로 초기화
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id ? { ...f, valueType: value, value: "" } : f,
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
}
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder={
loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"
} />
<SelectValue />
</SelectTrigger>
<SelectContent>
{categoryValues[filter.columnName].map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
<SelectItem value="static"> </SelectItem>
{filter.operator === "date_range_contains" && (
<SelectItem value="dynamic"> ( )</SelectItem>
)}
{isCategoryOrCodeColumn(filter.columnName) && (
<>
<SelectItem value="category"> </SelectItem>
<SelectItem value="code"> </SelectItem>
</>
)}
</SelectContent>
</Select>
) : filter.operator === "in" || filter.operator === "not_in" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
onChange={(e) => {
const values = e.target.value.split(",").map((v) => v.trim());
handleFilterChange(filter.id, "value", values);
}}
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : filter.operator === "between" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
onChange={(e) => {
const values = e.target.value.split("~").map((v) => v.trim());
handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]);
}}
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : (
<Input
type={filter.operator === "date_range_contains" ? "date" : "text"}
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
)}
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
{filter.operator !== "is_null" &&
filter.operator !== "is_not_null" &&
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
<div>
<Label className="text-xs"></Label>
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
<Select
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue
placeholder={loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"}
/>
</SelectTrigger>
<SelectContent>
{categoryValues[filter.columnName].map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : filter.operator === "in" || filter.operator === "not_in" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
onChange={(e) => {
const values = e.target.value.split(",").map((v) => v.trim());
handleFilterChange(filter.id, "value", values);
}}
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : filter.operator === "between" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
onChange={(e) => {
const values = e.target.value.split("~").map((v) => v.trim());
handleFilterChange(
filter.id,
"value",
values.length === 2 ? values : [values[0] || "", ""],
);
}}
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : (
<Input
type={filter.operator === "date_range_contains" ? "date" : "text"}
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
placeholder={
filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"
}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
)}
<p className="text-muted-foreground mt-1 text-[10px]">
{filter.valueType === "category" && categoryValues[filter.columnName]
? "카테고리 값을 선택하세요"
: filter.operator === "in" || filter.operator === "not_in"
? "여러 값은 쉼표(,)로 구분하세요"
: filter.operator === "between"
? "시작과 종료 값을 ~로 구분하세요"
: filter.operator === "date_range_contains"
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
: "필터링할 값을 입력하세요"}
</p>
</div>
)}
<p className="text-[10px] text-muted-foreground mt-1">
{filter.valueType === "category" && categoryValues[filter.columnName]
? "카테고리 값을 선택하세요"
: filter.operator === "in" || filter.operator === "not_in"
? "여러 값은 쉼표(,)로 구분하세요"
: filter.operator === "between"
? "시작과 종료 값을 ~로 구분하세요"
: filter.operator === "date_range_contains"
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
: "필터링할 값을 입력하세요"}
</p>
</div>
)}
{/* date_range_contains의 dynamic 타입 안내 */}
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
<div className="rounded-md bg-blue-50 p-2">
<p className="text-[10px] text-blue-700">
.
</p>
</div>
)}
</div>
))}
{/* date_range_contains의 dynamic 타입 안내 */}
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
<div className="rounded-md bg-blue-50 p-2">
<p className="text-[10px] text-blue-700"> .</p>
</div>
)}
</FilterItemCollapsible>
);
})}
</div>
{/* 필터 추가 버튼 */}
<Button
variant="outline"
size="sm"
className="w-full h-8 text-xs sm:h-10 sm:text-sm"
className="h-8 w-full text-xs sm:h-10 sm:text-sm"
onClick={handleAddFilter}
disabled={columns.length === 0}
>
@ -544,13 +630,10 @@ export function DataFilterConfigPanel({
</Button>
{columns.length === 0 && (
<p className="text-xs text-muted-foreground text-center">
</p>
<p className="text-muted-foreground text-center text-xs"> </p>
)}
</>
)}
</div>
);
}

View File

@ -13,6 +13,7 @@ import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
interface ScreenContextValue {
screenId?: number;
tableName?: string;
menuObjid?: number; // 메뉴 OBJID (카테고리 값 조회 시 필요)
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
@ -39,6 +40,7 @@ const ScreenContext = createContext<ScreenContextValue | null>(null);
interface ScreenContextProviderProps {
screenId?: number;
tableName?: string;
menuObjid?: number; // 메뉴 OBJID
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치
children: React.ReactNode;
}
@ -49,6 +51,7 @@ interface ScreenContextProviderProps {
export function ScreenContextProvider({
screenId,
tableName,
menuObjid,
splitPanelPosition,
children,
}: ScreenContextProviderProps) {
@ -112,6 +115,7 @@ export function ScreenContextProvider({
() => ({
screenId,
tableName,
menuObjid,
splitPanelPosition,
formData,
updateFormData,
@ -127,6 +131,7 @@ export function ScreenContextProvider({
[
screenId,
tableName,
menuObjid,
splitPanelPosition,
formData,
updateFormData,

View File

@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
// 🆕 연관 데이터 버튼 컴포넌트
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
// 🆕 피벗 그리드 컴포넌트
import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운)
/**
*
*/

View File

@ -0,0 +1,213 @@
"use client";
/**
* PivotGrid
* , , /
*/
import React from "react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import {
ArrowUpAZ,
ArrowDownAZ,
Filter,
ChevronDown,
ChevronRight,
Copy,
Eye,
EyeOff,
BarChart3,
} from "lucide-react";
import { PivotFieldConfig, AggregationType } from "../types";
interface PivotContextMenuProps {
children: React.ReactNode;
// 현재 컨텍스트 정보
cellType: "header" | "data" | "rowHeader" | "columnHeader";
field?: PivotFieldConfig;
rowPath?: string[];
columnPath?: string[];
value?: any;
// 콜백
onSort?: (field: string, direction: "asc" | "desc") => void;
onFilter?: (field: string) => void;
onExpand?: (path: string[]) => void;
onCollapse?: (path: string[]) => void;
onExpandAll?: () => void;
onCollapseAll?: () => void;
onCopy?: (value: any) => void;
onHideField?: (field: string) => void;
onChangeSummary?: (field: string, summaryType: AggregationType) => void;
onDrillDown?: (rowPath: string[], columnPath: string[]) => void;
}
export const PivotContextMenu: React.FC<PivotContextMenuProps> = ({
children,
cellType,
field,
rowPath,
columnPath,
value,
onSort,
onFilter,
onExpand,
onCollapse,
onExpandAll,
onCollapseAll,
onCopy,
onHideField,
onChangeSummary,
onDrillDown,
}) => {
const handleCopy = () => {
if (value !== undefined && value !== null) {
navigator.clipboard.writeText(String(value));
onCopy?.(value);
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
{/* 정렬 옵션 (헤더에서만) */}
{(cellType === "rowHeader" || cellType === "columnHeader") && field && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
<ArrowUpAZ className="mr-2 h-4 w-4" />
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onClick={() => onSort?.(field.field, "asc")}>
<ArrowUpAZ className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={() => onSort?.(field.field, "desc")}>
<ArrowDownAZ className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
</>
)}
{/* 확장/축소 옵션 */}
{(cellType === "rowHeader" || cellType === "columnHeader") && (
<>
{rowPath && rowPath.length > 0 && (
<>
<ContextMenuItem onClick={() => onExpand?.(rowPath)}>
<ChevronDown className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={() => onCollapse?.(rowPath)}>
<ChevronRight className="mr-2 h-4 w-4" />
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={onExpandAll}>
<ChevronDown className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuItem onClick={onCollapseAll}>
<ChevronRight className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 필터 옵션 */}
{field && onFilter && (
<>
<ContextMenuItem onClick={() => onFilter(field.field)}>
<Filter className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 집계 함수 변경 (데이터 필드에서만) */}
{cellType === "data" && field && onChangeSummary && (
<>
<ContextMenuSub>
<ContextMenuSubTrigger>
<BarChart3 className="mr-2 h-4 w-4" />
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "sum")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "count")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "avg")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "min")}
>
</ContextMenuItem>
<ContextMenuItem
onClick={() => onChangeSummary(field.field, "max")}
>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
</>
)}
{/* 드릴다운 (데이터 셀에서만) */}
{cellType === "data" && rowPath && columnPath && onDrillDown && (
<>
<ContextMenuItem onClick={() => onDrillDown(rowPath, columnPath)}>
<Eye className="mr-2 h-4 w-4" />
</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
{/* 필드 숨기기 */}
{field && onHideField && (
<ContextMenuItem onClick={() => onHideField(field.field)}>
<EyeOff className="mr-2 h-4 w-4" />
</ContextMenuItem>
)}
{/* 복사 */}
<ContextMenuItem onClick={handleCopy}>
<Copy className="mr-2 h-4 w-4" />
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export default PivotContextMenu;

View File

@ -94,6 +94,15 @@ const DISPLAY_MODE_OPTIONS: { value: SummaryDisplayMode; label: string }[] = [
{ value: "percentDifferenceFromPrevious", label: "이전 대비 % 차이" },
];
const DATE_GROUP_OPTIONS: { value: string; label: string }[] = [
{ value: "none", label: "그룹 없음" },
{ value: "year", label: "년" },
{ value: "quarter", label: "분기" },
{ value: "month", label: "월" },
{ value: "week", label: "주" },
{ value: "day", label: "일" },
];
const DATA_TYPE_ICONS: Record<string, React.ReactNode> = {
string: <Type className="h-3.5 w-3.5" />,
number: <Hash className="h-3.5 w-3.5" />,

View File

@ -2,7 +2,7 @@
/**
* FieldPanel
* (, , , )
* (, , )
*
*/
@ -247,7 +247,7 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
return (
<div
className={cn(
"flex-1 min-h-[60px] rounded-md border-2 border-dashed p-2",
"flex-1 min-h-[44px] rounded border border-dashed p-1.5",
"transition-colors duration-200",
config.color,
isOver && "border-primary bg-primary/5"
@ -255,7 +255,7 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
data-area={area}
>
{/* 영역 헤더 */}
<div className="flex items-center gap-1.5 mb-2 text-xs font-medium text-muted-foreground">
<div className="flex items-center gap-1 mb-1 text-[11px] font-medium text-muted-foreground">
{icon}
<span>{title}</span>
{areaFields.length > 0 && (
@ -267,9 +267,9 @@ const DroppableArea: React.FC<DroppableAreaProps> = ({
{/* 필드 목록 */}
<SortableContext items={fieldIds} strategy={horizontalListSortingStrategy}>
<div className="flex flex-wrap gap-1.5 min-h-[28px]">
<div className="flex flex-wrap gap-1 min-h-[22px]">
{areaFields.length === 0 ? (
<span className="text-xs text-muted-foreground/50 italic">
<span className="text-[10px] text-muted-foreground/50 italic">
</span>
) : (
@ -443,16 +443,42 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
? fields.find((f) => `${f.area}-${f.field}` === activeId)
: null;
// 각 영역의 필드 수 계산
const filterCount = fields.filter((f) => f.area === "filter" && f.visible !== false).length;
const columnCount = fields.filter((f) => f.area === "column" && f.visible !== false).length;
const rowCount = fields.filter((f) => f.area === "row" && f.visible !== false).length;
const dataCount = fields.filter((f) => f.area === "data" && f.visible !== false).length;
if (collapsed) {
return (
<div className="border-b border-border px-3 py-2">
<div className="border-b border-border px-3 py-1.5 flex items-center justify-between bg-muted/10">
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{filterCount > 0 && (
<span className="flex items-center gap-1">
<Filter className="h-3 w-3" />
{filterCount}
</span>
)}
<span className="flex items-center gap-1">
<Columns className="h-3 w-3" />
{columnCount}
</span>
<span className="flex items-center gap-1">
<Rows className="h-3 w-3" />
{rowCount}
</span>
<span className="flex items-center gap-1">
<BarChart3 className="h-3 w-3" />
{dataCount}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={onToggleCollapse}
className="text-xs"
className="text-xs h-6 px-2"
>
</Button>
</div>
);
@ -466,9 +492,9 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="border-b border-border bg-muted/20 p-3">
{/* 2x2 그리드로 영역 배치 */}
<div className="grid grid-cols-2 gap-2">
<div className="border-b border-border bg-muted/20 p-2">
{/* 4개 영역 배치: 2x2 그리드 */}
<div className="grid grid-cols-2 gap-1.5">
{/* 필터 영역 */}
<DroppableArea
area="filter"
@ -516,12 +542,12 @@ export const FieldPanel: React.FC<FieldPanelProps> = ({
{/* 접기 버튼 */}
{onToggleCollapse && (
<div className="flex justify-center mt-2">
<div className="flex justify-center mt-1.5">
<Button
variant="ghost"
size="sm"
onClick={onToggleCollapse}
className="text-xs h-6"
className="text-xs h-5 px-2"
>
</Button>

View File

@ -7,4 +7,5 @@ export { FieldChooser } from "./FieldChooser";
export { DrillDownModal } from "./DrillDownModal";
export { FilterPopup } from "./FilterPopup";
export { PivotChart } from "./PivotChart";
export { PivotContextMenu } from "./ContextMenu";

View File

@ -90,6 +90,10 @@ export interface PivotFieldConfig {
// 계층 관련
displayFolder?: string; // 필드 선택기에서 폴더 구조
isMeasure?: boolean; // 측정값 전용 필드 (data 영역만 가능)
// 계산 필드
isCalculated?: boolean; // 계산 필드 여부
calculateFormula?: string; // 계산 수식 (예: "[Sales] / [Quantity]")
}
// ==================== 데이터 소스 설정 ====================
@ -140,11 +144,13 @@ export interface PivotTotalsConfig {
showRowGrandTotals?: boolean; // 행 총합계 표시
showRowTotals?: boolean; // 행 소계 표시
rowTotalsPosition?: "first" | "last"; // 소계 위치
rowGrandTotalPosition?: "top" | "bottom"; // 행 총계 위치 (상단/하단)
// 열 총합계
showColumnGrandTotals?: boolean; // 열 총합계 표시
showColumnTotals?: boolean; // 열 소계 표시
columnTotalsPosition?: "first" | "last"; // 소계 위치
columnGrandTotalPosition?: "left" | "right"; // 열 총계 위치 (좌측/우측)
}
// 필드 선택기 설정
@ -214,6 +220,7 @@ export interface PivotStyleConfig {
alternateRowColors?: boolean;
highlightTotals?: boolean; // 총합계 강조
conditionalFormats?: ConditionalFormatRule[]; // 조건부 서식 규칙
mergeCells?: boolean; // 같은 값 셀 병합
}
// ==================== 내보내기 설정 ====================

View File

@ -285,11 +285,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// onChange 호출하여 부모에게 알림
if (onChange && items.length > 0) {
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
const dataWithMeta = items.map((item: any) => ({
...item,
_targetTable: targetTable,
_originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달
_existingRecord: !!item.id, // 🆕 기존 레코드 플래그 (id가 있으면 기존 레코드)
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
}));
onChange(dataWithMeta);
}
@ -388,10 +391,13 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// onChange 호출 (effectiveTargetTable 사용)
if (onChange) {
if (items.length > 0) {
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
const dataWithMeta = items.map((item: any) => ({
...item,
_targetTable: effectiveTargetTable,
_existingRecord: !!item.id,
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
}));
onChange(dataWithMeta);
} else {
@ -673,26 +679,25 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
const handleRepeaterChange = useCallback(
(newValue: any[]) => {
// 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가
let valueWithMeta = newValue;
// 🆕 RepeaterFieldGroup이 관리하는 필드 목록 추출
const repeaterFieldNames = (configRef.current.fields || []).map((f: any) => f.name);
// 🆕 모든 항목에 메타데이터 추가
let valueWithMeta = newValue.map((item: any) => ({
...item,
_targetTable: effectiveTargetTable || targetTable,
_existingRecord: !!item.id,
_repeaterFields: repeaterFieldNames, // 🆕 품목 고유 필드 목록
}));
if (isRightPanel && effectiveTargetTable) {
valueWithMeta = newValue.map((item: any) => {
const itemWithMeta = {
...item,
_targetTable: effectiveTargetTable,
};
// 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가
if (fkColumn && fkValue && item._isNewItem) {
itemWithMeta[fkColumn] = fkValue;
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", {
fkColumn,
fkValue,
});
// 🆕 분할 패널에서 우측인 경우, FK 값 추가
if (isRightPanel && fkColumn && fkValue) {
valueWithMeta = valueWithMeta.map((item: any) => {
if (item._isNewItem) {
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", { fkColumn, fkValue });
return { ...item, [fkColumn]: fkValue };
}
return itemWithMeta;
return item;
});
}
@ -754,6 +759,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
screenContext?.updateFormData,
isRightPanel,
effectiveTargetTable,
targetTable,
fkColumn,
fkValue,
fieldName,

View File

@ -185,6 +185,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [rightCategoryMappings, setRightCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>>
>({}); // 우측 카테고리 매핑
// 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨)
const [categoryCodeLabels, setCategoryCodeLabels] = useState<Record<string, string>>({});
const { toast } = useToast();
// 추가 모달 상태
@ -713,6 +717,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
);
}
// 🆕 카테고리 코드 패턴 감지 (CATEGORY_로 시작하는 값)
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
const cachedLabel = categoryCodeLabels[value];
if (cachedLabel) {
return <span className="text-sm">{cachedLabel}</span>;
}
}
// 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
return formatDateValue(value, "YYYY-MM-DD");
@ -734,7 +746,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 일반 값
return String(value);
},
[formatDateValue, formatNumberValue],
[formatDateValue, formatNumberValue, categoryCodeLabels],
);
// 좌측 데이터 로드
@ -1014,10 +1026,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 추가 dataFilter 적용
let filteredData = result.data || [];
const dataFilter = componentConfig.rightPanel?.dataFilter;
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
// filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용)
const filterConditions = dataFilter?.filters || dataFilter?.conditions || [];
if (dataFilter?.enabled && filterConditions.length > 0) {
filteredData = filteredData.filter((item: any) => {
return dataFilter.conditions.every((cond: any) => {
const value = item[cond.column];
return filterConditions.every((cond: any) => {
// columnName 또는 column 지원
const columnName = cond.columnName || cond.column;
const value = item[columnName];
const condValue = cond.value;
switch (cond.operator) {
case "equals":
@ -1026,6 +1042,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return value !== condValue;
case "contains":
return String(value).includes(String(condValue));
case "is_null":
case "NULL":
return value === null || value === undefined || value === "";
case "is_not_null":
case "NOT NULL":
return value !== null && value !== undefined && value !== "";
default:
return true;
}
@ -1079,6 +1101,49 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
],
);
// 🆕 카테고리 코드 라벨 로드 (rightData 변경 시)
useEffect(() => {
const loadCategoryCodeLabels = async () => {
if (!rightData) return;
const categoryCodes = new Set<string>();
// rightData가 배열인 경우 (조인 모드)
const dataArray = Array.isArray(rightData) ? rightData : [rightData];
dataArray.forEach((row: Record<string, any>) => {
if (row) {
Object.values(row).forEach((value) => {
if (typeof value === "string" && value.startsWith("CATEGORY_")) {
categoryCodes.add(value);
}
});
}
});
// 새로운 카테고리 코드만 필터링 (이미 캐시된 것 제외)
const newCodes = Array.from(categoryCodes).filter((code) => !categoryCodeLabels[code]);
if (newCodes.length > 0) {
try {
console.log("🏷️ [SplitPanel] 카테고리 코드 라벨 조회:", newCodes);
const response = await apiClient.post("/table-categories/labels-by-codes", { valueCodes: newCodes });
if (response.data.success && response.data.data) {
console.log("🏷️ [SplitPanel] 카테고리 라벨 조회 결과:", response.data.data);
setCategoryCodeLabels((prev) => ({
...prev,
...response.data.data,
}));
}
} catch (error) {
console.error("카테고리 라벨 조회 실패:", error);
}
}
};
loadCategoryCodeLabels();
}, [rightData]);
// 🆕 추가 탭 데이터 로딩 함수
const loadTabData = useCallback(
async (tabIndex: number, leftItem: any) => {
@ -1138,10 +1203,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 데이터 필터 적용
const dataFilter = tabConfig.dataFilter;
if (dataFilter?.enabled && dataFilter.conditions?.length > 0) {
// filters 또는 conditions 배열 지원 (DataFilterConfigPanel은 filters 사용)
const filterConditions = dataFilter?.filters || dataFilter?.conditions || [];
if (dataFilter?.enabled && filterConditions.length > 0) {
resultData = resultData.filter((item: any) => {
return dataFilter.conditions.every((cond: any) => {
const value = item[cond.column];
return filterConditions.every((cond: any) => {
// columnName 또는 column 지원
const columnName = cond.columnName || cond.column;
const value = item[columnName];
const condValue = cond.value;
switch (cond.operator) {
case "equals":
@ -1150,6 +1219,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return value !== condValue;
case "contains":
return String(value).includes(String(condValue));
case "is_null":
case "NULL":
return value === null || value === undefined || value === "";
case "is_not_null":
case "NOT NULL":
return value !== null && value !== undefined && value !== "";
default:
return true;
}

View File

@ -398,6 +398,9 @@ export function TableSectionRenderer({
// 소스 테이블의 컬럼 라벨 (API에서 동적 로드)
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
// 카테고리 타입 컬럼의 옵션 (column.type === "category")
const [categoryOptionsMap, setCategoryOptionsMap] = useState<Record<string, { value: string; label: string }[]>>({});
// 외부 데이터(groupedData) 처리: 데이터 전달 모달열기 액션으로 전달받은 데이터를 초기 테이블 데이터로 설정
useEffect(() => {
// 외부 데이터 소스가 활성화되지 않았거나, groupedData가 없으면 스킵
@ -511,6 +514,46 @@ export function TableSectionRenderer({
loadColumnLabels();
}, [tableConfig.source.tableName, tableConfig.source.columnLabels]);
// 카테고리 타입 컬럼의 옵션 로드
useEffect(() => {
const loadCategoryOptions = async () => {
const sourceTableName = tableConfig.source.tableName;
if (!sourceTableName) return;
if (!tableConfig.columns) return;
// 카테고리 타입인 컬럼만 필터링
const categoryColumns = tableConfig.columns.filter((col) => col.type === "category");
if (categoryColumns.length === 0) return;
const newOptionsMap: Record<string, { value: string; label: string }[]> = {};
for (const col of categoryColumns) {
// 소스 필드 또는 필드명으로 카테고리 값 조회
const actualColumnName = col.sourceField || col.field;
if (!actualColumnName) continue;
try {
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
const result = await getCategoryValues(sourceTableName, actualColumnName, false);
if (result && result.success && Array.isArray(result.data)) {
const options = result.data.map((item: any) => ({
value: item.valueCode || item.value_code || item.value || "",
label: item.valueLabel || item.displayLabel || item.display_label || item.label || item.valueCode || item.value_code || item.value || "",
}));
newOptionsMap[col.field] = options;
}
} catch (error) {
console.error(`카테고리 옵션 로드 실패 (${col.field}):`, error);
}
}
setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap }));
};
loadCategoryOptions();
}, [tableConfig.source.tableName, tableConfig.columns]);
// 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
useEffect(() => {
if (!isConditionalMode) return;
@ -952,9 +995,15 @@ export function TableSectionRenderer({
baseColumn.selectOptions = dynamicSelectOptionsMap[col.field];
}
// 카테고리 타입인 경우 옵션 적용 및 select 타입으로 변환
if (col.type === "category" && categoryOptionsMap[col.field]) {
baseColumn.type = "select"; // RepeaterTable에서 select로 렌더링
baseColumn.selectOptions = categoryOptionsMap[col.field];
}
return baseColumn;
});
}, [tableConfig.columns, dynamicSelectOptionsMap]);
}, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
// 원본 계산 규칙 (조건부 계산 포함)
const originalCalculationRules: TableCalculationRule[] = useMemo(

View File

@ -308,12 +308,29 @@ export function UniversalFormModalConfigPanel({
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",
}),
isNullable?: string;
is_nullable?: string;
}) => {
const colName = c.columnName || c.column_name || "";
const dataType = c.dataType || c.data_type || "text";
const inputType = c.inputType || c.input_type || "text";
const displayName = c.displayName || c.columnComment || c.column_comment || colName;
const isNullable = c.isNullable || c.is_nullable || "YES";
return {
// camelCase (기존 호환성)
name: colName,
type: dataType,
label: displayName,
inputType: inputType,
// snake_case (TableSectionSettingsModal 호환성)
column_name: colName,
data_type: dataType,
is_nullable: isNullable,
comment: displayName,
input_type: inputType,
};
},
),
}));
}

View File

@ -48,12 +48,12 @@ interface TableColumnSettingsModalProps {
onOpenChange: (open: boolean) => void;
column: TableColumnConfig;
sourceTableName: string; // 소스 테이블명
sourceTableColumns: { column_name: string; data_type: string; comment?: string }[];
sourceTableColumns: { column_name: string; data_type: string; comment?: string; input_type?: string }[];
formFields: { columnName: string; label: string; sectionId?: string; sectionTitle?: string }[]; // formData 필드 목록 (섹션 정보 포함)
sections: { id: string; title: string }[]; // 섹션 목록
onSave: (updatedColumn: TableColumnConfig) => 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;
}
@ -103,6 +103,18 @@ export function TableColumnSettingsModal({
return tableColumns[externalTableName] || [];
}, [tableColumns, externalTableName]);
// 소스 필드 기준으로 카테고리 타입인지 확인
const actualSourceField = localColumn.sourceField || localColumn.field;
const sourceColumnInfo = sourceTableColumns.find((c) => c.column_name === actualSourceField);
const isCategoryColumn = sourceColumnInfo?.input_type === "category";
// 카테고리 컬럼인 경우 타입을 자동으로 category로 설정
useEffect(() => {
if (isCategoryColumn && localColumn.type !== "category") {
updateColumn({ type: "category" });
}
}, [isCategoryColumn, localColumn.type]);
// 컬럼 업데이트 함수
const updateColumn = (updates: Partial<TableColumnConfig>) => {
setLocalColumn((prev) => ({ ...prev, ...updates }));
@ -574,10 +586,11 @@ export function TableColumnSettingsModal({
<div>
<Label className="text-xs"></Label>
<Select
value={localColumn.type}
value={isCategoryColumn ? "category" : localColumn.type}
onValueChange={(value: any) => updateColumn({ type: value })}
disabled={isCategoryColumn}
>
<SelectTrigger className="h-8 text-xs mt-1">
<SelectTrigger className={cn("h-8 text-xs mt-1", isCategoryColumn && "opacity-70 cursor-not-allowed")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -588,6 +601,9 @@ export function TableColumnSettingsModal({
))}
</SelectContent>
</Select>
{isCategoryColumn && (
<p className="text-[10px] text-blue-600 mt-0.5"> </p>
)}
</div>
<div>
<Label className="text-xs"></Label>

View File

@ -706,15 +706,15 @@ interface ColumnSettingItemProps {
col: TableColumnConfig;
index: number;
totalCount: number;
saveTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[];
saveTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[];
displayColumns: string[]; // 검색 설정에서 선택한 표시 컬럼 목록
sourceTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 소스 테이블 컬럼
sourceTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]; // 소스 테이블 컬럼
sourceTableName: string; // 소스 테이블명
externalTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string }[]; // 외부 데이터 테이블 컬럼
externalTableColumns: { column_name: string; data_type: string; is_nullable: string; comment?: string; input_type?: string }[]; // 외부 데이터 테이블 컬럼
externalTableName?: string; // 외부 데이터 테이블명
externalDataEnabled?: boolean; // 외부 데이터 소스 활성화 여부
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 }[]>; // 테이블별 컬럼
sections: { id: string; title: string }[]; // 섹션 목록
formFields: { columnName: string; label: string; sectionId?: string }[]; // 폼 필드 목록
tableConfig: TableSectionConfig; // 현재 행 필드 목록 표시용
@ -755,6 +755,18 @@ function ColumnSettingItem({
const [parentFieldSearchOpen, setParentFieldSearchOpen] = useState(false);
const [lookupTableOpenMap, setLookupTableOpenMap] = useState<Record<string, boolean>>({});
// 소스 필드 기준으로 카테고리 타입인지 확인
const actualSourceField = col.sourceField || col.field;
const sourceColumnInfo = sourceTableColumns.find((c) => c.column_name === actualSourceField);
const isCategoryColumn = sourceColumnInfo?.input_type === "category";
// 카테고리 컬럼인 경우 타입을 자동으로 category로 설정
useEffect(() => {
if (isCategoryColumn && col.type !== "category") {
onUpdate({ type: "category" });
}
}, [isCategoryColumn, col.type, onUpdate]);
// 조회 옵션 추가
const addLookupOption = () => {
const newOption: LookupOption = {
@ -1117,8 +1129,12 @@ function ColumnSettingItem({
{/* 타입 */}
<div>
<Label className="text-xs"></Label>
<Select value={col.type} onValueChange={(value: any) => onUpdate({ type: value })}>
<SelectTrigger className="h-8 text-xs mt-1">
<Select
value={isCategoryColumn ? "category" : col.type}
onValueChange={(value: any) => onUpdate({ type: value })}
disabled={isCategoryColumn}
>
<SelectTrigger className={cn("h-8 text-xs mt-1", isCategoryColumn && "opacity-70 cursor-not-allowed")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -1129,6 +1145,9 @@ function ColumnSettingItem({
))}
</SelectContent>
</Select>
{isCategoryColumn && (
<p className="text-[10px] text-blue-600 mt-0.5"> </p>
)}
</div>
{/* 너비 */}

View File

@ -899,6 +899,7 @@ export const TABLE_COLUMN_TYPE_OPTIONS = [
{ value: "number", label: "숫자" },
{ value: "date", label: "날짜" },
{ value: "select", label: "선택(드롭다운)" },
{ value: "category", label: "카테고리" },
] as const;
// 값 매핑 타입 옵션

View File

@ -690,6 +690,151 @@ export class ButtonActionExecutor {
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
}
// 🆕 RepeaterFieldGroup JSON 문자열 파싱 및 저장 처리
// formData에 JSON 배열 문자열이 저장된 경우 처리 (반복_필드_그룹 등)
const repeaterJsonKeys = Object.keys(context.formData).filter((key) => {
const value = context.formData[key];
if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) && parsed.length > 0 && parsed[0]._targetTable;
} catch {
return false;
}
}
return false;
});
if (repeaterJsonKeys.length > 0) {
console.log("🔄 [handleSave] RepeaterFieldGroup JSON 문자열 감지:", repeaterJsonKeys);
// 🆕 상단 폼 데이터(마스터 정보) 추출
// RepeaterFieldGroup JSON과 컴포넌트 키를 제외한 나머지가 마스터 정보
const masterFields: Record<string, any> = {};
Object.keys(context.formData).forEach((fieldKey) => {
// 제외 조건
if (fieldKey.startsWith("comp_")) return;
if (fieldKey.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) return;
if (fieldKey.endsWith("_label") || fieldKey.endsWith("_value_label")) return;
const value = context.formData[fieldKey];
// JSON 배열 문자열 제외 (RepeaterFieldGroup 데이터)
if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) return;
// 객체 타입인 경우 (범용_폼_모달 등) 내부 필드를 펼쳐서 추가
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
Object.entries(value).forEach(([innerKey, innerValue]) => {
if (innerKey.endsWith("_label") || innerKey.endsWith("_value_label")) return;
if (innerValue !== undefined && innerValue !== null && innerValue !== "") {
masterFields[innerKey] = innerValue;
}
});
return;
}
// 유효한 값만 포함
if (value !== undefined && value !== null && value !== "") {
masterFields[fieldKey] = value;
}
});
console.log("📋 [handleSave] 상단 마스터 정보 (모든 품목에 적용):", masterFields);
for (const key of repeaterJsonKeys) {
try {
const parsedData = JSON.parse(context.formData[key]);
const repeaterTargetTable = parsedData[0]?._targetTable;
if (!repeaterTargetTable) {
console.warn(`⚠️ [handleSave] RepeaterFieldGroup targetTable 없음 (key: ${key})`);
continue;
}
console.log(`📦 [handleSave] RepeaterFieldGroup 저장 시작: ${repeaterTargetTable}, ${parsedData.length}`);
// 🆕 품목 고유 필드 목록 (RepeaterFieldGroup 설정에서 가져옴)
// 첫 번째 아이템의 _repeaterFields에서 추출
const repeaterFields: string[] = parsedData[0]?._repeaterFields || [];
const itemOnlyFields = new Set([...repeaterFields, 'id']); // id는 항상 포함
console.log("📋 [handleSave] RepeaterFieldGroup 품목 필드:", repeaterFields);
for (const item of parsedData) {
// 메타 필드 제거
const { _targetTable, _isNewItem, _existingRecord, _originalItemIds, _deletedItemIds, _repeaterFields, ...itemData } = item;
// 🔧 품목 고유 필드만 추출 (RepeaterFieldGroup 설정 기반)
const itemOnlyData: Record<string, any> = {};
Object.keys(itemData).forEach((field) => {
if (itemOnlyFields.has(field)) {
itemOnlyData[field] = itemData[field];
}
});
// 🔧 마스터 정보 + 품목 고유 정보 병합
// masterFields: 상단 폼에서 수정한 최신 마스터 정보
// itemOnlyData: 품목 고유 필드만 (품번, 품명, 수량 등)
const dataWithMeta: Record<string, unknown> = {
...masterFields, // 상단 마스터 정보 (최신)
...itemOnlyData, // 품목 고유 필드만
created_by: context.userId,
updated_by: context.userId,
company_code: context.companyCode,
};
// 불필요한 필드 제거
Object.keys(dataWithMeta).forEach((field) => {
if (field.endsWith("_label") || field.endsWith("_value_label") || field.endsWith("_numberingRuleId")) {
delete dataWithMeta[field];
}
});
// 새 레코드 vs 기존 레코드 판단
const isNewRecord = _isNewItem || !item.id || item.id === "" || item.id === undefined;
console.log(`📦 [handleSave] 저장할 데이터 (${isNewRecord ? 'INSERT' : 'UPDATE'}):`, {
id: item.id,
dataWithMeta,
});
if (isNewRecord) {
// INSERT - DynamicFormApi 사용하여 제어관리 실행
delete dataWithMeta.id;
const insertResult = await DynamicFormApi.saveFormData({
screenId: context.screenId || 0,
tableName: repeaterTargetTable,
data: dataWithMeta as Record<string, any>,
});
console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data);
} else if (item.id && _existingRecord === true) {
// UPDATE - 기존 레코드
const originalData = { id: item.id };
const updatedData = { ...dataWithMeta, id: item.id };
const updateResult = await apiClient.put(`/table-management/tables/${repeaterTargetTable}/edit`, {
originalData,
updatedData,
});
console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data);
}
}
} catch (err) {
console.error(`❌ [handleSave] RepeaterFieldGroup 저장 실패 (key: ${key}):`, err);
}
}
// RepeaterFieldGroup 저장 완료 후 새로고침
console.log("✅ [handleSave] RepeaterFieldGroup 저장 완료");
context.onRefresh?.();
context.onFlowRefresh?.();
window.dispatchEvent(new CustomEvent("closeEditModal"));
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
return true;
}
// 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리
// 범용_폼_모달 내부에 _tableSection_ 데이터가 있는 경우 공통 필드 + 개별 품목 병합 저장
const universalFormModalResult = await this.handleUniversalFormModalTableSectionSave(config, context, formData);
@ -4836,19 +4981,7 @@ export class ButtonActionExecutor {
*/
private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("📤 엑셀 업로드 모달 열기:", {
config,
context,
userId: context.userId,
tableName: context.tableName,
screenId: context.screenId,
// 채번 설정 디버깅
numberingRuleId: config.excelNumberingRuleId,
numberingTargetColumn: config.excelNumberingTargetColumn,
afterUploadFlows: config.excelAfterUploadFlows,
});
// 🆕 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지)
// 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지)
let isMasterDetail = false;
let masterDetailRelation: any = null;
let masterDetailExcelConfig: any = undefined;
@ -4871,6 +5004,10 @@ export class ButtonActionExecutor {
detailTable: relationResponse.data.detailTable,
masterKeyColumn: relationResponse.data.masterKeyColumn,
detailFkColumn: relationResponse.data.detailFkColumn,
// 채번 규칙 ID 추가 (excelNumberingRuleId를 numberingRuleId로 매핑)
numberingRuleId: config.masterDetailExcel.numberingRuleId || config.excelNumberingRuleId,
// 업로드 후 제어 설정 추가
afterUploadFlows: config.masterDetailExcel.afterUploadFlows || config.excelAfterUploadFlows,
};
} else {
// 버튼 설정이 없으면 분할 패널 정보만 사용
@ -4880,6 +5017,10 @@ export class ButtonActionExecutor {
masterKeyColumn: relationResponse.data.masterKeyColumn,
detailFkColumn: relationResponse.data.detailFkColumn,
simpleMode: true, // 기본값으로 간단 모드 사용
// 채번 규칙 ID 추가 (excelNumberingRuleId 사용)
numberingRuleId: config.excelNumberingRuleId,
// 업로드 후 제어 설정 추가
afterUploadFlows: config.excelAfterUploadFlows,
};
}