This commit is contained in:
hjjeong 2026-01-12 17:25:22 +09:00
commit b2add92abf
12 changed files with 475 additions and 232 deletions

View File

@ -47,8 +47,13 @@ export interface SplitPanelConfig {
columns: Array<{ name: string; label: string; width?: number }>; columns: Array<{ name: string; label: string; width?: number }>;
relation?: { relation?: {
type: string; type: string;
foreignKey: string; foreignKey?: string;
leftColumn: string; leftColumn?: string;
// 복합키 지원 (새로운 방식)
keys?: Array<{
leftColumn: string;
rightColumn: string;
}>;
}; };
}; };
} }
@ -210,8 +215,21 @@ class MasterDetailExcelService {
} }
// 2. 분할 패널의 relation 정보가 있으면 우선 사용 // 2. 분할 패널의 relation 정보가 있으면 우선 사용
let masterKeyColumn = splitPanel.rightPanel.relation?.leftColumn; // 🔥 keys 배열을 우선 사용 (새로운 복합키 지원 방식)
let detailFkColumn = splitPanel.rightPanel.relation?.foreignKey; 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 관계 조회 // 3. relation 정보가 없으면 column_labels에서 Entity 관계 조회
if (!masterKeyColumn || !detailFkColumn) { if (!masterKeyColumn || !detailFkColumn) {

View File

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

View File

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

View File

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

View File

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

View File

@ -185,6 +185,10 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [rightCategoryMappings, setRightCategoryMappings] = useState< const [rightCategoryMappings, setRightCategoryMappings] = useState<
Record<string, Record<string, { label: string; color?: string }>> Record<string, Record<string, { label: string; color?: string }>>
>({}); // 우측 카테고리 매핑 >({}); // 우측 카테고리 매핑
// 카테고리 코드 라벨 캐시 (CATEGORY_* 코드 -> 라벨)
const [categoryCodeLabels, setCategoryCodeLabels] = useState<Record<string, string>>({});
const { toast } = useToast(); 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 객체) // 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) { if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
return formatDateValue(value, "YYYY-MM-DD"); return formatDateValue(value, "YYYY-MM-DD");
@ -734,7 +746,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 일반 값 // 일반 값
return String(value); return String(value);
}, },
[formatDateValue, formatNumberValue], [formatDateValue, formatNumberValue, categoryCodeLabels],
); );
// 좌측 데이터 로드 // 좌측 데이터 로드
@ -1079,6 +1091,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( const loadTabData = useCallback(
async (tabIndex: number, leftItem: any) => { async (tabIndex: number, leftItem: any) => {

View File

@ -398,6 +398,9 @@ export function TableSectionRenderer({
// 소스 테이블의 컬럼 라벨 (API에서 동적 로드) // 소스 테이블의 컬럼 라벨 (API에서 동적 로드)
const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({}); const [sourceColumnLabels, setSourceColumnLabels] = useState<Record<string, string>>({});
// 카테고리 타입 컬럼의 옵션 (column.type === "category")
const [categoryOptionsMap, setCategoryOptionsMap] = useState<Record<string, { value: string; label: string }[]>>({});
// 외부 데이터(groupedData) 처리: 데이터 전달 모달열기 액션으로 전달받은 데이터를 초기 테이블 데이터로 설정 // 외부 데이터(groupedData) 처리: 데이터 전달 모달열기 액션으로 전달받은 데이터를 초기 테이블 데이터로 설정
useEffect(() => { useEffect(() => {
// 외부 데이터 소스가 활성화되지 않았거나, groupedData가 없으면 스킵 // 외부 데이터 소스가 활성화되지 않았거나, groupedData가 없으면 스킵
@ -511,6 +514,46 @@ export function TableSectionRenderer({
loadColumnLabels(); loadColumnLabels();
}, [tableConfig.source.tableName, tableConfig.source.columnLabels]); }, [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 설정이 있는 경우) // 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우)
useEffect(() => { useEffect(() => {
if (!isConditionalMode) return; if (!isConditionalMode) return;
@ -952,9 +995,15 @@ export function TableSectionRenderer({
baseColumn.selectOptions = dynamicSelectOptionsMap[col.field]; 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; return baseColumn;
}); });
}, [tableConfig.columns, dynamicSelectOptionsMap]); }, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]);
// 원본 계산 규칙 (조건부 계산 포함) // 원본 계산 규칙 (조건부 계산 포함)
const originalCalculationRules: TableCalculationRule[] = useMemo( const originalCalculationRules: TableCalculationRule[] = useMemo(

View File

@ -308,12 +308,29 @@ export function UniversalFormModalConfigPanel({
column_comment?: string; column_comment?: string;
inputType?: string; inputType?: string;
input_type?: string; input_type?: string;
}) => ({ isNullable?: string;
name: c.columnName || c.column_name || "", is_nullable?: string;
type: c.dataType || c.data_type || "text", }) => {
label: c.displayName || c.columnComment || c.column_comment || c.columnName || c.column_name || "", const colName = c.columnName || c.column_name || "";
inputType: c.inputType || c.input_type || "text", 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; onOpenChange: (open: boolean) => void;
column: TableColumnConfig; column: TableColumnConfig;
sourceTableName: string; // 소스 테이블명 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 필드 목록 (섹션 정보 포함) formFields: { columnName: string; label: string; sectionId?: string; sectionTitle?: string }[]; // formData 필드 목록 (섹션 정보 포함)
sections: { id: string; title: string }[]; // 섹션 목록 sections: { id: string; title: string }[]; // 섹션 목록
onSave: (updatedColumn: TableColumnConfig) => void; onSave: (updatedColumn: TableColumnConfig) => void;
tables: { table_name: string; comment?: string }[]; 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; onLoadTableColumns: (tableName: string) => void;
} }
@ -103,6 +103,18 @@ export function TableColumnSettingsModal({
return tableColumns[externalTableName] || []; return tableColumns[externalTableName] || [];
}, [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>) => { const updateColumn = (updates: Partial<TableColumnConfig>) => {
setLocalColumn((prev) => ({ ...prev, ...updates })); setLocalColumn((prev) => ({ ...prev, ...updates }));
@ -574,10 +586,11 @@ export function TableColumnSettingsModal({
<div> <div>
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
<Select <Select
value={localColumn.type} value={isCategoryColumn ? "category" : localColumn.type}
onValueChange={(value: any) => updateColumn({ type: value })} 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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -588,6 +601,9 @@ export function TableColumnSettingsModal({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{isCategoryColumn && (
<p className="text-[10px] text-blue-600 mt-0.5"> </p>
)}
</div> </div>
<div> <div>
<Label className="text-xs"></Label> <Label className="text-xs"></Label>

View File

@ -706,15 +706,15 @@ interface ColumnSettingItemProps {
col: TableColumnConfig; col: TableColumnConfig;
index: number; index: number;
totalCount: 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[]; // 검색 설정에서 선택한 표시 컬럼 목록 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; // 소스 테이블명 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; // 외부 데이터 테이블명 externalTableName?: string; // 외부 데이터 테이블명
externalDataEnabled?: boolean; // 외부 데이터 소스 활성화 여부 externalDataEnabled?: boolean; // 외부 데이터 소스 활성화 여부
tables: { table_name: string; comment?: string }[]; // 전체 테이블 목록 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 }[]; // 섹션 목록 sections: { id: string; title: string }[]; // 섹션 목록
formFields: { columnName: string; label: string; sectionId?: string }[]; // 폼 필드 목록 formFields: { columnName: string; label: string; sectionId?: string }[]; // 폼 필드 목록
tableConfig: TableSectionConfig; // 현재 행 필드 목록 표시용 tableConfig: TableSectionConfig; // 현재 행 필드 목록 표시용
@ -755,6 +755,18 @@ function ColumnSettingItem({
const [parentFieldSearchOpen, setParentFieldSearchOpen] = useState(false); const [parentFieldSearchOpen, setParentFieldSearchOpen] = useState(false);
const [lookupTableOpenMap, setLookupTableOpenMap] = useState<Record<string, boolean>>({}); 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 addLookupOption = () => {
const newOption: LookupOption = { const newOption: LookupOption = {
@ -1117,8 +1129,12 @@ function ColumnSettingItem({
{/* 타입 */} {/* 타입 */}
<div> <div>
<Label className="text-xs"></Label> <Label className="text-xs"></Label>
<Select value={col.type} onValueChange={(value: any) => onUpdate({ type: value })}> <Select
<SelectTrigger className="h-8 text-xs mt-1"> 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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1129,6 +1145,9 @@ function ColumnSettingItem({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{isCategoryColumn && (
<p className="text-[10px] text-blue-600 mt-0.5"> </p>
)}
</div> </div>
{/* 너비 */} {/* 너비 */}

View File

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

View File

@ -4980,19 +4980,7 @@ export class ButtonActionExecutor {
*/ */
private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> { private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try { 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 isMasterDetail = false;
let masterDetailRelation: any = null; let masterDetailRelation: any = null;
let masterDetailExcelConfig: any = undefined; let masterDetailExcelConfig: any = undefined;
@ -5015,6 +5003,10 @@ export class ButtonActionExecutor {
detailTable: relationResponse.data.detailTable, detailTable: relationResponse.data.detailTable,
masterKeyColumn: relationResponse.data.masterKeyColumn, masterKeyColumn: relationResponse.data.masterKeyColumn,
detailFkColumn: relationResponse.data.detailFkColumn, detailFkColumn: relationResponse.data.detailFkColumn,
// 채번 규칙 ID 추가 (excelNumberingRuleId를 numberingRuleId로 매핑)
numberingRuleId: config.masterDetailExcel.numberingRuleId || config.excelNumberingRuleId,
// 업로드 후 제어 설정 추가
afterUploadFlows: config.masterDetailExcel.afterUploadFlows || config.excelAfterUploadFlows,
}; };
} else { } else {
// 버튼 설정이 없으면 분할 패널 정보만 사용 // 버튼 설정이 없으면 분할 패널 정보만 사용
@ -5024,6 +5016,10 @@ export class ButtonActionExecutor {
masterKeyColumn: relationResponse.data.masterKeyColumn, masterKeyColumn: relationResponse.data.masterKeyColumn,
detailFkColumn: relationResponse.data.detailFkColumn, detailFkColumn: relationResponse.data.detailFkColumn,
simpleMode: true, // 기본값으로 간단 모드 사용 simpleMode: true, // 기본값으로 간단 모드 사용
// 채번 규칙 ID 추가 (excelNumberingRuleId 사용)
numberingRuleId: config.excelNumberingRuleId,
// 업로드 후 제어 설정 추가
afterUploadFlows: config.excelAfterUploadFlows,
}; };
} }