Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into ksh
This commit is contained in:
commit
f799402564
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
// 채번 실패 시에도 계속 진행 (채번 컬럼만 비워둠)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">크다 (>)</SelectItem>
|
||||
<SelectItem value="less_than">작다 (<)</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">크다 (>)</SelectItem>
|
||||
<SelectItem value="less_than">작다 (<)</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인
|
|||
// 🆕 연관 데이터 버튼 컴포넌트
|
||||
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
|
||||
|
||||
// 🆕 피벗 그리드 컴포넌트
|
||||
import "./pivot-grid/PivotGridRenderer"; // 피벗 테이블 (행/열 그룹화, 집계, 드릴다운)
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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;
|
||||
|
||||
|
|
@ -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" />,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ export { FieldChooser } from "./FieldChooser";
|
|||
export { DrillDownModal } from "./DrillDownModal";
|
||||
export { FilterPopup } from "./FilterPopup";
|
||||
export { PivotChart } from "./PivotChart";
|
||||
export { PivotContextMenu } from "./ContextMenu";
|
||||
|
||||
|
|
|
|||
|
|
@ -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; // 같은 값 셀 병합
|
||||
}
|
||||
|
||||
// ==================== 내보내기 설정 ====================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
{/* 너비 */}
|
||||
|
|
|
|||
|
|
@ -899,6 +899,7 @@ export const TABLE_COLUMN_TYPE_OPTIONS = [
|
|||
{ value: "number", label: "숫자" },
|
||||
{ value: "date", label: "날짜" },
|
||||
{ value: "select", label: "선택(드롭다운)" },
|
||||
{ value: "category", label: "카테고리" },
|
||||
] as const;
|
||||
|
||||
// 값 매핑 타입 옵션
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue