Compare commits
2 Commits
762ab8e684
...
34cd7ba9e3
| Author | SHA1 | Date |
|---|---|---|
|
|
34cd7ba9e3 | |
|
|
d4895c363c |
|
|
@ -14,7 +14,7 @@ router.get(
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
async (req: AuthenticatedRequest, res) => {
|
async (req: AuthenticatedRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } =
|
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter, enableEntityJoin, displayColumns, deduplication } =
|
||||||
req.query;
|
req.query;
|
||||||
|
|
||||||
// 입력값 검증
|
// 입력값 검증
|
||||||
|
|
@ -37,6 +37,9 @@ router.get(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 enableEntityJoin 파싱
|
||||||
|
const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true;
|
||||||
|
|
||||||
// SQL 인젝션 방지를 위한 검증
|
// SQL 인젝션 방지를 위한 검증
|
||||||
const tables = [leftTable as string, rightTable as string];
|
const tables = [leftTable as string, rightTable as string];
|
||||||
const columns = [leftColumn as string, rightColumn as string];
|
const columns = [leftColumn as string, rightColumn as string];
|
||||||
|
|
@ -64,6 +67,31 @@ router.get(
|
||||||
// 회사 코드 추출 (멀티테넌시 필터링)
|
// 회사 코드 추출 (멀티테넌시 필터링)
|
||||||
const userCompany = req.user?.companyCode;
|
const userCompany = req.user?.companyCode;
|
||||||
|
|
||||||
|
// displayColumns 파싱 (item_info.item_name 등)
|
||||||
|
let parsedDisplayColumns: Array<{ name: string; label?: string }> | undefined;
|
||||||
|
if (displayColumns) {
|
||||||
|
try {
|
||||||
|
parsedDisplayColumns = JSON.parse(displayColumns as string);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("displayColumns 파싱 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 deduplication 파싱
|
||||||
|
let parsedDeduplication: {
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
} | undefined;
|
||||||
|
if (deduplication) {
|
||||||
|
try {
|
||||||
|
parsedDeduplication = JSON.parse(deduplication as string);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("deduplication 파싱 실패:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`🔗 조인 데이터 조회:`, {
|
console.log(`🔗 조인 데이터 조회:`, {
|
||||||
leftTable,
|
leftTable,
|
||||||
rightTable,
|
rightTable,
|
||||||
|
|
@ -71,10 +99,13 @@ router.get(
|
||||||
rightColumn,
|
rightColumn,
|
||||||
leftValue,
|
leftValue,
|
||||||
userCompany,
|
userCompany,
|
||||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그
|
dataFilter: parsedDataFilter,
|
||||||
|
enableEntityJoin: enableEntityJoinFlag,
|
||||||
|
displayColumns: parsedDisplayColumns, // 🆕 표시 컬럼 로그
|
||||||
|
deduplication: parsedDeduplication, // 🆕 중복 제거 로그
|
||||||
});
|
});
|
||||||
|
|
||||||
// 조인 데이터 조회 (회사 코드 + 데이터 필터 전달)
|
// 조인 데이터 조회 (회사 코드 + 데이터 필터 + Entity 조인 + 표시 컬럼 + 중복 제거 전달)
|
||||||
const result = await dataService.getJoinedData(
|
const result = await dataService.getJoinedData(
|
||||||
leftTable as string,
|
leftTable as string,
|
||||||
rightTable as string,
|
rightTable as string,
|
||||||
|
|
@ -82,7 +113,10 @@ router.get(
|
||||||
rightColumn as string,
|
rightColumn as string,
|
||||||
leftValue as string,
|
leftValue as string,
|
||||||
userCompany,
|
userCompany,
|
||||||
parsedDataFilter // 🆕 데이터 필터 전달
|
parsedDataFilter,
|
||||||
|
enableEntityJoinFlag,
|
||||||
|
parsedDisplayColumns, // 🆕 표시 컬럼 전달
|
||||||
|
parsedDeduplication // 🆕 중복 제거 설정 전달
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
@ -305,10 +339,31 @@ router.get(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`);
|
const { enableEntityJoin, groupByColumns } = req.query;
|
||||||
|
const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true;
|
||||||
|
|
||||||
// 레코드 상세 조회
|
// groupByColumns 파싱 (JSON 문자열 또는 쉼표 구분)
|
||||||
const result = await dataService.getRecordDetail(tableName, id);
|
let groupByColumnsArray: string[] = [];
|
||||||
|
if (groupByColumns) {
|
||||||
|
try {
|
||||||
|
if (typeof groupByColumns === "string") {
|
||||||
|
// JSON 형식이면 파싱, 아니면 쉼표로 분리
|
||||||
|
groupByColumnsArray = groupByColumns.startsWith("[")
|
||||||
|
? JSON.parse(groupByColumns)
|
||||||
|
: groupByColumns.split(",").map(c => c.trim());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("groupByColumns 파싱 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
|
||||||
|
enableEntityJoin: enableEntityJoinFlag,
|
||||||
|
groupByColumns: groupByColumnsArray
|
||||||
|
});
|
||||||
|
|
||||||
|
// 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함)
|
||||||
|
const result = await dataService.getRecordDetail(tableName, id, enableEntityJoinFlag, groupByColumnsArray);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return res.status(400).json(result);
|
return res.status(400).json(result);
|
||||||
|
|
@ -523,6 +578,82 @@ router.post(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹화된 데이터 UPSERT API
|
||||||
|
* POST /api/data/upsert-grouped
|
||||||
|
*
|
||||||
|
* 요청 본문:
|
||||||
|
* {
|
||||||
|
* tableName: string,
|
||||||
|
* parentKeys: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" },
|
||||||
|
* records: [ { customer_item_code: "84-44", start_date: "2025-11-18", ... }, ... ]
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/upsert-grouped",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res) => {
|
||||||
|
try {
|
||||||
|
const { tableName, parentKeys, records } = req.body;
|
||||||
|
|
||||||
|
// 입력값 검증
|
||||||
|
if (!tableName || !parentKeys || !records || !Array.isArray(records)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 파라미터가 누락되었습니다 (tableName, parentKeys, records).",
|
||||||
|
error: "MISSING_PARAMETERS",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블명 검증
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "유효하지 않은 테이블명입니다.",
|
||||||
|
error: "INVALID_TABLE_NAME",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔄 그룹화된 데이터 UPSERT: ${tableName}`, {
|
||||||
|
parentKeys,
|
||||||
|
recordCount: records.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// UPSERT 수행
|
||||||
|
const result = await dataService.upsertGroupedRecords(
|
||||||
|
tableName,
|
||||||
|
parentKeys,
|
||||||
|
records
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(400).json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, {
|
||||||
|
inserted: result.inserted,
|
||||||
|
updated: result.updated,
|
||||||
|
deleted: result.deleted,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "데이터가 저장되었습니다.",
|
||||||
|
inserted: result.inserted,
|
||||||
|
updated: result.updated,
|
||||||
|
deleted: result.deleted,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("그룹화된 데이터 UPSERT 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "데이터 저장 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.delete(
|
router.delete(
|
||||||
"/:tableName/:id",
|
"/:tableName/:id",
|
||||||
authenticateToken,
|
authenticateToken,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
|
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
|
||||||
*/
|
*/
|
||||||
import { query, queryOne } from "../database/db";
|
import { query, queryOne } from "../database/db";
|
||||||
|
import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool import
|
||||||
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
||||||
|
|
||||||
interface GetTableDataParams {
|
interface GetTableDataParams {
|
||||||
|
|
@ -53,6 +54,103 @@ const BLOCKED_TABLES = [
|
||||||
const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
|
|
||||||
class DataService {
|
class DataService {
|
||||||
|
/**
|
||||||
|
* 중복 데이터 제거 (메모리 내 처리)
|
||||||
|
*/
|
||||||
|
private deduplicateData(
|
||||||
|
data: any[],
|
||||||
|
config: {
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}
|
||||||
|
): any[] {
|
||||||
|
if (!data || data.length === 0) return data;
|
||||||
|
|
||||||
|
// 그룹별로 데이터 분류
|
||||||
|
const groups: Record<string, any[]> = {};
|
||||||
|
|
||||||
|
for (const row of data) {
|
||||||
|
const groupKey = row[config.groupByColumn];
|
||||||
|
if (groupKey === undefined || groupKey === null) continue;
|
||||||
|
|
||||||
|
if (!groups[groupKey]) {
|
||||||
|
groups[groupKey] = [];
|
||||||
|
}
|
||||||
|
groups[groupKey].push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 그룹에서 하나의 행만 선택
|
||||||
|
const result: any[] = [];
|
||||||
|
|
||||||
|
for (const [groupKey, rows] of Object.entries(groups)) {
|
||||||
|
if (rows.length === 0) continue;
|
||||||
|
|
||||||
|
let selectedRow: any;
|
||||||
|
|
||||||
|
switch (config.keepStrategy) {
|
||||||
|
case "latest":
|
||||||
|
// 정렬 컬럼 기준 최신 (가장 큰 값)
|
||||||
|
if (config.sortColumn) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = a[config.sortColumn!];
|
||||||
|
const bVal = b[config.sortColumn!];
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal > bVal) return -1;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedRow = rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "earliest":
|
||||||
|
// 정렬 컬럼 기준 최초 (가장 작은 값)
|
||||||
|
if (config.sortColumn) {
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const aVal = a[config.sortColumn!];
|
||||||
|
const bVal = b[config.sortColumn!];
|
||||||
|
if (aVal === bVal) return 0;
|
||||||
|
if (aVal < bVal) return -1;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectedRow = rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "base_price":
|
||||||
|
// base_price = true인 행 찾기
|
||||||
|
selectedRow = rows.find(row => row.base_price === true) || rows[0];
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "current_date":
|
||||||
|
// start_date <= CURRENT_DATE <= end_date 조건에 맞는 행
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0); // 시간 제거
|
||||||
|
|
||||||
|
selectedRow = rows.find(row => {
|
||||||
|
const startDate = row.start_date ? new Date(row.start_date) : null;
|
||||||
|
const endDate = row.end_date ? new Date(row.end_date) : null;
|
||||||
|
|
||||||
|
if (startDate) startDate.setHours(0, 0, 0, 0);
|
||||||
|
if (endDate) endDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const afterStart = !startDate || today >= startDate;
|
||||||
|
const beforeEnd = !endDate || today <= endDate;
|
||||||
|
|
||||||
|
return afterStart && beforeEnd;
|
||||||
|
}) || rows[0]; // 조건에 맞는 행이 없으면 첫 번째 행
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
selectedRow = rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(selectedRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 접근 검증 (공통 메서드)
|
* 테이블 접근 검증 (공통 메서드)
|
||||||
*/
|
*/
|
||||||
|
|
@ -374,11 +472,13 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 레코드 상세 조회
|
* 레코드 상세 조회 (Entity Join 지원 + 그룹핑 기반 다중 레코드 조회)
|
||||||
*/
|
*/
|
||||||
async getRecordDetail(
|
async getRecordDetail(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
id: string | number
|
id: string | number,
|
||||||
|
enableEntityJoin: boolean = false,
|
||||||
|
groupByColumns: string[] = []
|
||||||
): Promise<ServiceResponse<any>> {
|
): Promise<ServiceResponse<any>> {
|
||||||
try {
|
try {
|
||||||
// 테이블 접근 검증
|
// 테이블 접근 검증
|
||||||
|
|
@ -401,6 +501,87 @@ class DataService {
|
||||||
pkColumn = pkResult[0].attname;
|
pkColumn = pkResult[0].attname;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 Entity Join이 활성화된 경우
|
||||||
|
if (enableEntityJoin) {
|
||||||
|
const { EntityJoinService } = await import("./entityJoinService");
|
||||||
|
const entityJoinService = new EntityJoinService();
|
||||||
|
|
||||||
|
// Entity Join 구성 감지
|
||||||
|
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||||
|
|
||||||
|
if (joinConfigs.length > 0) {
|
||||||
|
console.log(`✅ Entity Join 감지: ${joinConfigs.length}개`);
|
||||||
|
|
||||||
|
// Entity Join 쿼리 생성 (개별 파라미터로 전달)
|
||||||
|
const { query: joinQuery } = entityJoinService.buildJoinQuery(
|
||||||
|
tableName,
|
||||||
|
joinConfigs,
|
||||||
|
["*"],
|
||||||
|
`main."${pkColumn}" = $1` // 🔧 main. 접두사 추가하여 모호성 해결
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await pool.query(joinQuery, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "레코드를 찾을 수 없습니다.",
|
||||||
|
error: "RECORD_NOT_FOUND",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ Entity Join 데이터 조회 성공:`, result.rows[0]);
|
||||||
|
|
||||||
|
// 🆕 groupByColumns가 있으면 그룹핑 기반 다중 레코드 조회
|
||||||
|
if (groupByColumns.length > 0) {
|
||||||
|
const baseRecord = result.rows[0];
|
||||||
|
|
||||||
|
// 그룹핑 컬럼들의 값 추출
|
||||||
|
const groupConditions: string[] = [];
|
||||||
|
const groupValues: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
for (const col of groupByColumns) {
|
||||||
|
const value = baseRecord[col];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
groupConditions.push(`main."${col}" = $${paramIndex}`);
|
||||||
|
groupValues.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupConditions.length > 0) {
|
||||||
|
const groupWhereClause = groupConditions.join(" AND ");
|
||||||
|
|
||||||
|
console.log(`🔍 그룹핑 조회: ${groupByColumns.join(", ")}`, groupValues);
|
||||||
|
|
||||||
|
// 그룹핑 기준으로 모든 레코드 조회
|
||||||
|
const { query: groupQuery } = entityJoinService.buildJoinQuery(
|
||||||
|
tableName,
|
||||||
|
joinConfigs,
|
||||||
|
["*"],
|
||||||
|
groupWhereClause
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupResult = await pool.query(groupQuery, groupValues);
|
||||||
|
|
||||||
|
console.log(`✅ 그룹 레코드 조회 성공: ${groupResult.rows.length}개`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: groupResult.rows, // 🔧 배열로 반환!
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0], // 그룹핑 없으면 단일 레코드
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 쿼리 (Entity Join 없음)
|
||||||
const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||||
const result = await query<any>(queryText, [id]);
|
const result = await query<any>(queryText, [id]);
|
||||||
|
|
||||||
|
|
@ -427,7 +608,7 @@ class DataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조인된 데이터 조회
|
* 조인된 데이터 조회 (🆕 Entity 조인 지원)
|
||||||
*/
|
*/
|
||||||
async getJoinedData(
|
async getJoinedData(
|
||||||
leftTable: string,
|
leftTable: string,
|
||||||
|
|
@ -436,7 +617,15 @@ class DataService {
|
||||||
rightColumn: string,
|
rightColumn: string,
|
||||||
leftValue?: string | number,
|
leftValue?: string | number,
|
||||||
userCompany?: string,
|
userCompany?: string,
|
||||||
dataFilter?: any // 🆕 데이터 필터
|
dataFilter?: any, // 🆕 데이터 필터
|
||||||
|
enableEntityJoin?: boolean, // 🆕 Entity 조인 활성화
|
||||||
|
displayColumns?: Array<{ name: string; label?: string }>, // 🆕 표시 컬럼 (item_info.item_name 등)
|
||||||
|
deduplication?: { // 🆕 중복 제거 설정
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
}
|
||||||
): Promise<ServiceResponse<any[]>> {
|
): Promise<ServiceResponse<any[]>> {
|
||||||
try {
|
try {
|
||||||
// 왼쪽 테이블 접근 검증
|
// 왼쪽 테이블 접근 검증
|
||||||
|
|
@ -451,6 +640,143 @@ class DataService {
|
||||||
return rightValidation.error!;
|
return rightValidation.error!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 Entity 조인이 활성화된 경우 entityJoinService 사용
|
||||||
|
if (enableEntityJoin) {
|
||||||
|
try {
|
||||||
|
const { entityJoinService } = await import("./entityJoinService");
|
||||||
|
const joinConfigs = await entityJoinService.detectEntityJoins(rightTable);
|
||||||
|
|
||||||
|
// 🆕 displayColumns에서 추가 조인 필요한 컬럼 감지 (item_info.item_name 등)
|
||||||
|
if (displayColumns && Array.isArray(displayColumns)) {
|
||||||
|
// 테이블별로 요청된 컬럼들을 그룹핑
|
||||||
|
const tableColumns: Record<string, Set<string>> = {};
|
||||||
|
|
||||||
|
for (const col of displayColumns) {
|
||||||
|
if (col.name && col.name.includes('.')) {
|
||||||
|
const [refTable, refColumn] = col.name.split('.');
|
||||||
|
if (!tableColumns[refTable]) {
|
||||||
|
tableColumns[refTable] = new Set();
|
||||||
|
}
|
||||||
|
tableColumns[refTable].add(refColumn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 각 테이블별로 처리
|
||||||
|
for (const [refTable, refColumns] of Object.entries(tableColumns)) {
|
||||||
|
// 이미 조인 설정에 있는지 확인
|
||||||
|
const existingJoins = joinConfigs.filter(jc => jc.referenceTable === refTable);
|
||||||
|
|
||||||
|
if (existingJoins.length > 0) {
|
||||||
|
// 기존 조인이 있으면, 각 컬럼을 개별 조인으로 분리
|
||||||
|
for (const refColumn of refColumns) {
|
||||||
|
// 이미 해당 컬럼을 표시하는 조인이 있는지 확인
|
||||||
|
const existingJoin = existingJoins.find(
|
||||||
|
jc => jc.displayColumns.length === 1 && jc.displayColumns[0] === refColumn
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingJoin) {
|
||||||
|
// 없으면 새 조인 설정 복제하여 추가
|
||||||
|
const baseJoin = existingJoins[0];
|
||||||
|
const newJoin = {
|
||||||
|
...baseJoin,
|
||||||
|
displayColumns: [refColumn],
|
||||||
|
aliasColumn: `${baseJoin.sourceColumn}_${refColumn}`, // 고유한 별칭 생성 (예: item_id_size)
|
||||||
|
// ⚠️ 중요: referenceTable과 referenceColumn을 명시하여 JOIN된 테이블에서 가져옴
|
||||||
|
referenceTable: refTable,
|
||||||
|
referenceColumn: baseJoin.referenceColumn, // item_number 등
|
||||||
|
};
|
||||||
|
joinConfigs.push(newJoin);
|
||||||
|
console.log(`📌 추가 표시 컬럼: ${refTable}.${refColumn} (새 조인 생성, alias: ${newJoin.aliasColumn})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ 조인 설정 없음: ${refTable}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (joinConfigs.length > 0) {
|
||||||
|
console.log(`🔗 조인 모드에서 Entity 조인 적용: ${joinConfigs.length}개 설정`);
|
||||||
|
|
||||||
|
// WHERE 조건 생성
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 좌측 테이블 조인 조건 (leftValue로 필터링)
|
||||||
|
// rightColumn을 직접 사용 (customer_item_mapping.customer_id = 'CUST-0002')
|
||||||
|
if (leftValue !== undefined && leftValue !== null) {
|
||||||
|
whereConditions.push(`main."${rightColumn}" = $${paramIndex}`);
|
||||||
|
values.push(leftValue);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회사별 필터링
|
||||||
|
if (userCompany && userCompany !== "*") {
|
||||||
|
const hasCompanyCode = await this.checkColumnExists(rightTable, "company_code");
|
||||||
|
if (hasCompanyCode) {
|
||||||
|
whereConditions.push(`main.company_code = $${paramIndex}`);
|
||||||
|
values.push(userCompany);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 필터 적용 (buildDataFilterWhereClause 사용)
|
||||||
|
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
|
||||||
|
const { buildDataFilterWhereClause } = await import("../utils/dataFilterUtil");
|
||||||
|
const filterResult = buildDataFilterWhereClause(dataFilter, "main", paramIndex);
|
||||||
|
if (filterResult.whereClause) {
|
||||||
|
whereConditions.push(filterResult.whereClause);
|
||||||
|
values.push(...filterResult.params);
|
||||||
|
paramIndex += filterResult.params.length;
|
||||||
|
console.log(`🔍 Entity 조인에 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
|
||||||
|
console.log(`📊 필터 파라미터:`, filterResult.params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.length > 0 ? whereConditions.join(" AND ") : "";
|
||||||
|
|
||||||
|
// Entity 조인 쿼리 빌드
|
||||||
|
// buildJoinQuery가 자동으로 main.* 처리하므로 ["*"]만 전달
|
||||||
|
const selectColumns = ["*"];
|
||||||
|
|
||||||
|
const { query: finalQuery, aliasMap } = entityJoinService.buildJoinQuery(
|
||||||
|
rightTable,
|
||||||
|
joinConfigs,
|
||||||
|
selectColumns,
|
||||||
|
whereClause,
|
||||||
|
"",
|
||||||
|
undefined,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`🔍 Entity 조인 쿼리 실행 (전체):`, finalQuery);
|
||||||
|
console.log(`🔍 파라미터:`, values);
|
||||||
|
|
||||||
|
const result = await pool.query(finalQuery, values);
|
||||||
|
|
||||||
|
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${result.rows.length}개`);
|
||||||
|
|
||||||
|
// 🆕 중복 제거 처리
|
||||||
|
let finalData = result.rows;
|
||||||
|
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||||
|
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
||||||
|
finalData = this.deduplicateData(result.rows, deduplication);
|
||||||
|
console.log(`✅ 중복 제거 완료: ${result.rows.length}개 → ${finalData.length}개`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: finalData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Entity 조인 처리 실패, 기본 조인으로 폴백:", error);
|
||||||
|
// Entity 조인 실패 시 기본 조인으로 폴백
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 조인 쿼리 (Entity 조인 미사용 또는 실패 시)
|
||||||
let queryText = `
|
let queryText = `
|
||||||
SELECT DISTINCT r.*
|
SELECT DISTINCT r.*
|
||||||
FROM "${rightTable}" r
|
FROM "${rightTable}" r
|
||||||
|
|
@ -501,9 +827,17 @@ class DataService {
|
||||||
|
|
||||||
const result = await query<any>(queryText, values);
|
const result = await query<any>(queryText, values);
|
||||||
|
|
||||||
|
// 🆕 중복 제거 처리
|
||||||
|
let finalData = result;
|
||||||
|
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||||
|
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
||||||
|
finalData = this.deduplicateData(result, deduplication);
|
||||||
|
console.log(`✅ 중복 제거 완료: ${result.length}개 → ${finalData.length}개`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: result,
|
data: finalData,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -728,6 +1062,185 @@ class DataService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹화된 데이터 UPSERT
|
||||||
|
* - 부모 키(예: customer_id, item_id)와 레코드 배열을 받아
|
||||||
|
* - 기존 DB의 레코드들과 비교하여 INSERT/UPDATE/DELETE 수행
|
||||||
|
* - 각 레코드의 모든 필드 조합을 고유 키로 사용
|
||||||
|
*/
|
||||||
|
async upsertGroupedRecords(
|
||||||
|
tableName: string,
|
||||||
|
parentKeys: Record<string, any>,
|
||||||
|
records: Array<Record<string, any>>
|
||||||
|
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
|
||||||
|
try {
|
||||||
|
// 테이블 접근 권한 검증
|
||||||
|
if (!this.canAccessTable(tableName)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `테이블 '${tableName}'에 접근할 수 없습니다.`,
|
||||||
|
error: "ACCESS_DENIED",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primary Key 감지
|
||||||
|
const pkColumn = await this.detectPrimaryKey(tableName);
|
||||||
|
if (!pkColumn) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `테이블 '${tableName}'의 Primary Key를 찾을 수 없습니다.`,
|
||||||
|
error: "PRIMARY_KEY_NOT_FOUND",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 UPSERT 시작: ${tableName}`, {
|
||||||
|
parentKeys,
|
||||||
|
newRecordsCount: records.length,
|
||||||
|
primaryKey: pkColumn,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. 기존 DB 레코드 조회 (parentKeys 기준)
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const whereValues: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(parentKeys)) {
|
||||||
|
whereConditions.push(`"${key}" = $${paramIndex}`);
|
||||||
|
whereValues.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = whereConditions.join(" AND ");
|
||||||
|
const selectQuery = `SELECT * FROM "${tableName}" WHERE ${whereClause}`;
|
||||||
|
|
||||||
|
console.log(`📋 기존 레코드 조회:`, { query: selectQuery, values: whereValues });
|
||||||
|
|
||||||
|
const existingRecords = await pool.query(selectQuery, whereValues);
|
||||||
|
|
||||||
|
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`);
|
||||||
|
|
||||||
|
// 2. 새 레코드와 기존 레코드 비교
|
||||||
|
let inserted = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let deleted = 0;
|
||||||
|
|
||||||
|
// 새 레코드 처리 (INSERT or UPDATE)
|
||||||
|
for (const newRecord of records) {
|
||||||
|
// 전체 레코드 데이터 (parentKeys + newRecord)
|
||||||
|
const fullRecord = { ...parentKeys, ...newRecord };
|
||||||
|
|
||||||
|
// 고유 키: parentKeys 제외한 나머지 필드들
|
||||||
|
const uniqueFields = Object.keys(newRecord);
|
||||||
|
|
||||||
|
// 기존 레코드에서 일치하는 것 찾기
|
||||||
|
const existingRecord = existingRecords.rows.find((existing) => {
|
||||||
|
return uniqueFields.every((field) => {
|
||||||
|
const existingValue = existing[field];
|
||||||
|
const newValue = newRecord[field];
|
||||||
|
|
||||||
|
// null/undefined 처리
|
||||||
|
if (existingValue == null && newValue == null) return true;
|
||||||
|
if (existingValue == null || newValue == null) return false;
|
||||||
|
|
||||||
|
// Date 타입 처리
|
||||||
|
if (existingValue instanceof Date && typeof newValue === 'string') {
|
||||||
|
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열 비교
|
||||||
|
return String(existingValue) === String(newValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingRecord) {
|
||||||
|
// UPDATE: 기존 레코드가 있으면 업데이트
|
||||||
|
const updateFields: string[] = [];
|
||||||
|
const updateValues: any[] = [];
|
||||||
|
let updateParamIndex = 1;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(fullRecord)) {
|
||||||
|
if (key !== pkColumn) { // Primary Key는 업데이트하지 않음
|
||||||
|
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
||||||
|
updateValues.push(value);
|
||||||
|
updateParamIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateValues.push(existingRecord[pkColumn]); // WHERE 조건용
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE "${tableName}"
|
||||||
|
SET ${updateFields.join(", ")}, updated_date = NOW()
|
||||||
|
WHERE "${pkColumn}" = $${updateParamIndex}
|
||||||
|
`;
|
||||||
|
|
||||||
|
await pool.query(updateQuery, updateValues);
|
||||||
|
updated++;
|
||||||
|
|
||||||
|
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||||
|
} else {
|
||||||
|
// INSERT: 기존 레코드가 없으면 삽입
|
||||||
|
const insertFields = Object.keys(fullRecord);
|
||||||
|
const insertPlaceholders = insertFields.map((_, idx) => `$${idx + 1}`);
|
||||||
|
const insertValues = Object.values(fullRecord);
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO "${tableName}" (${insertFields.map(f => `"${f}"`).join(", ")})
|
||||||
|
VALUES (${insertPlaceholders.join(", ")})
|
||||||
|
`;
|
||||||
|
|
||||||
|
await pool.query(insertQuery, insertValues);
|
||||||
|
inserted++;
|
||||||
|
|
||||||
|
console.log(`➕ INSERT: 새 레코드`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
|
||||||
|
for (const existingRecord of existingRecords.rows) {
|
||||||
|
const uniqueFields = Object.keys(records[0] || {});
|
||||||
|
|
||||||
|
const stillExists = records.some((newRecord) => {
|
||||||
|
return uniqueFields.every((field) => {
|
||||||
|
const existingValue = existingRecord[field];
|
||||||
|
const newValue = newRecord[field];
|
||||||
|
|
||||||
|
if (existingValue == null && newValue == null) return true;
|
||||||
|
if (existingValue == null || newValue == null) return false;
|
||||||
|
|
||||||
|
if (existingValue instanceof Date && typeof newValue === 'string') {
|
||||||
|
return existingValue.toISOString().split('T')[0] === newValue.split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(existingValue) === String(newValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!stillExists) {
|
||||||
|
// DELETE: 새 레코드에 없으면 삭제
|
||||||
|
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||||
|
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
|
||||||
|
deleted++;
|
||||||
|
|
||||||
|
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { inserted, updated, deleted },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`UPSERT 오류 (${tableName}):`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "데이터 저장 중 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dataService = new DataService();
|
export const dataService = new DataService();
|
||||||
|
|
|
||||||
|
|
@ -81,11 +81,11 @@ export class EntityJoinService {
|
||||||
let referenceColumn = column.reference_column;
|
let referenceColumn = column.reference_column;
|
||||||
let displayColumn = column.display_column;
|
let displayColumn = column.display_column;
|
||||||
|
|
||||||
if (column.input_type === 'category') {
|
if (column.input_type === "category") {
|
||||||
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
|
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
|
||||||
referenceTable = referenceTable || 'table_column_category_values';
|
referenceTable = referenceTable || "table_column_category_values";
|
||||||
referenceColumn = referenceColumn || 'value_code';
|
referenceColumn = referenceColumn || "value_code";
|
||||||
displayColumn = displayColumn || 'value_label';
|
displayColumn = displayColumn || "value_label";
|
||||||
|
|
||||||
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
|
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
|
||||||
referenceTable,
|
referenceTable,
|
||||||
|
|
@ -214,8 +214,14 @@ export class EntityJoinService {
|
||||||
): { query: string; aliasMap: Map<string, string> } {
|
): { query: string; aliasMap: Map<string, string> } {
|
||||||
try {
|
try {
|
||||||
// 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지)
|
// 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지)
|
||||||
|
// "*"는 특별 처리: AS 없이 그냥 main.*만
|
||||||
const baseColumns = selectColumns
|
const baseColumns = selectColumns
|
||||||
.map((col) => `main.${col}::TEXT AS ${col}`)
|
.map((col) => {
|
||||||
|
if (col === "*") {
|
||||||
|
return "main.*";
|
||||||
|
}
|
||||||
|
return `main.${col}::TEXT AS ${col}`;
|
||||||
|
})
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
// Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리)
|
// Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리)
|
||||||
|
|
@ -255,7 +261,9 @@ export class EntityJoinService {
|
||||||
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응)
|
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응)
|
||||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||||
aliasMap.set(aliasKey, alias);
|
aliasMap.set(aliasKey, alias);
|
||||||
logger.info(`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}`);
|
logger.info(
|
||||||
|
`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const joinColumns = joinConfigs
|
const joinColumns = joinConfigs
|
||||||
|
|
@ -273,57 +281,41 @@ export class EntityJoinService {
|
||||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||||
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
||||||
// 조인 테이블의 referenceColumn을 기본값으로 사용
|
// 조인 테이블의 referenceColumn을 기본값으로 사용
|
||||||
resultColumns.push(`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`);
|
resultColumns.push(
|
||||||
|
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
|
||||||
|
);
|
||||||
} else if (displayColumns.length === 1) {
|
} else if (displayColumns.length === 1) {
|
||||||
// 단일 컬럼인 경우
|
// 단일 컬럼인 경우
|
||||||
const col = displayColumns[0];
|
const col = displayColumns[0];
|
||||||
const isJoinTableColumn = [
|
|
||||||
"dept_name",
|
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
|
||||||
"dept_code",
|
// 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원
|
||||||
"master_user_id",
|
const isJoinTableColumn =
|
||||||
"location_name",
|
config.referenceTable && config.referenceTable !== tableName;
|
||||||
"parent_dept_code",
|
|
||||||
"master_sabun",
|
|
||||||
"location",
|
|
||||||
"data_type",
|
|
||||||
"company_name",
|
|
||||||
"sales_yn",
|
|
||||||
"status",
|
|
||||||
"value_label", // table_column_category_values
|
|
||||||
"user_name", // user_info
|
|
||||||
].includes(col);
|
|
||||||
|
|
||||||
if (isJoinTableColumn) {
|
if (isJoinTableColumn) {
|
||||||
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`);
|
resultColumns.push(
|
||||||
|
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||||
|
);
|
||||||
|
|
||||||
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
|
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
|
||||||
// sourceColumn_label 형식으로 추가
|
// sourceColumn_label 형식으로 추가
|
||||||
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`);
|
resultColumns.push(
|
||||||
|
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`);
|
resultColumns.push(
|
||||||
|
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 여러 컬럼인 경우 CONCAT으로 연결
|
// 여러 컬럼인 경우 CONCAT으로 연결
|
||||||
// 기본 테이블과 조인 테이블의 컬럼을 구분해서 처리
|
// 기본 테이블과 조인 테이블의 컬럼을 구분해서 처리
|
||||||
const concatParts = displayColumns
|
const concatParts = displayColumns
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
// 조인 테이블의 컬럼인지 확인 (조인 테이블에 존재하는 컬럼만 조인 별칭 사용)
|
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
|
||||||
// 현재는 dept_info 테이블의 컬럼들을 확인
|
const isJoinTableColumn =
|
||||||
const isJoinTableColumn = [
|
config.referenceTable && config.referenceTable !== tableName;
|
||||||
"dept_name",
|
|
||||||
"dept_code",
|
|
||||||
"master_user_id",
|
|
||||||
"location_name",
|
|
||||||
"parent_dept_code",
|
|
||||||
"master_sabun",
|
|
||||||
"location",
|
|
||||||
"data_type",
|
|
||||||
"company_name",
|
|
||||||
"sales_yn",
|
|
||||||
"status",
|
|
||||||
"value_label", // table_column_category_values
|
|
||||||
"user_name", // user_info
|
|
||||||
].includes(col);
|
|
||||||
|
|
||||||
if (isJoinTableColumn) {
|
if (isJoinTableColumn) {
|
||||||
// 조인 테이블 컬럼은 조인 별칭 사용
|
// 조인 테이블 컬럼은 조인 별칭 사용
|
||||||
|
|
@ -358,7 +350,7 @@ export class EntityJoinService {
|
||||||
const alias = aliasMap.get(aliasKey);
|
const alias = aliasMap.get(aliasKey);
|
||||||
|
|
||||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||||
if (config.referenceTable === 'table_column_category_values') {
|
if (config.referenceTable === "table_column_category_values") {
|
||||||
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
||||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||||
}
|
}
|
||||||
|
|
@ -424,7 +416,7 @@ export class EntityJoinService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
|
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
|
||||||
if (config.referenceTable === 'table_column_category_values') {
|
if (config.referenceTable === "table_column_category_values") {
|
||||||
logger.info(
|
logger.info(
|
||||||
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
|
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
|
||||||
);
|
);
|
||||||
|
|
@ -580,7 +572,7 @@ export class EntityJoinService {
|
||||||
const alias = aliasMap.get(aliasKey);
|
const alias = aliasMap.get(aliasKey);
|
||||||
|
|
||||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||||
if (config.referenceTable === 'table_column_category_values') {
|
if (config.referenceTable === "table_column_category_values") {
|
||||||
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
||||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,28 @@
|
||||||
export interface ColumnFilter {
|
export interface ColumnFilter {
|
||||||
id: string;
|
id: string;
|
||||||
columnName: string;
|
columnName: string;
|
||||||
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null";
|
operator:
|
||||||
|
| "equals"
|
||||||
|
| "not_equals"
|
||||||
|
| "in"
|
||||||
|
| "not_in"
|
||||||
|
| "contains"
|
||||||
|
| "starts_with"
|
||||||
|
| "ends_with"
|
||||||
|
| "is_null"
|
||||||
|
| "is_not_null"
|
||||||
|
| "greater_than"
|
||||||
|
| "less_than"
|
||||||
|
| "greater_than_or_equal"
|
||||||
|
| "less_than_or_equal"
|
||||||
|
| "between"
|
||||||
|
| "date_range_contains";
|
||||||
value: string | string[];
|
value: string | string[];
|
||||||
valueType: "static" | "category" | "code";
|
valueType: "static" | "category" | "code" | "dynamic";
|
||||||
|
rangeConfig?: {
|
||||||
|
startColumn: string;
|
||||||
|
endColumn: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DataFilterConfig {
|
export interface DataFilterConfig {
|
||||||
|
|
@ -123,6 +142,71 @@ export function buildDataFilterWhereClause(
|
||||||
conditions.push(`${columnRef} IS NOT NULL`);
|
conditions.push(`${columnRef} IS NOT NULL`);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "greater_than":
|
||||||
|
conditions.push(`${columnRef} > $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "less_than":
|
||||||
|
conditions.push(`${columnRef} < $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "greater_than_or_equal":
|
||||||
|
conditions.push(`${columnRef} >= $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "less_than_or_equal":
|
||||||
|
conditions.push(`${columnRef} <= $${paramIndex}`);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "between":
|
||||||
|
if (Array.isArray(value) && value.length === 2) {
|
||||||
|
conditions.push(`${columnRef} BETWEEN $${paramIndex} AND $${paramIndex + 1}`);
|
||||||
|
params.push(value[0], value[1]);
|
||||||
|
paramIndex += 2;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "date_range_contains":
|
||||||
|
// 날짜 범위 포함: start_date <= value <= end_date
|
||||||
|
// filter.rangeConfig = { startColumn: "start_date", endColumn: "end_date" }
|
||||||
|
// NULL 처리:
|
||||||
|
// - start_date만 있고 end_date가 NULL이면: start_date <= value (이후 계속)
|
||||||
|
// - end_date만 있고 start_date가 NULL이면: value <= end_date (이전 계속)
|
||||||
|
// - 둘 다 있으면: start_date <= value <= end_date
|
||||||
|
if (filter.rangeConfig && filter.rangeConfig.startColumn && filter.rangeConfig.endColumn) {
|
||||||
|
const startCol = getColumnRef(filter.rangeConfig.startColumn);
|
||||||
|
const endCol = getColumnRef(filter.rangeConfig.endColumn);
|
||||||
|
|
||||||
|
// value가 "TODAY"면 현재 날짜로 변환
|
||||||
|
const actualValue = filter.valueType === "dynamic" && value === "TODAY"
|
||||||
|
? "CURRENT_DATE"
|
||||||
|
: `$${paramIndex}`;
|
||||||
|
|
||||||
|
if (actualValue === "CURRENT_DATE") {
|
||||||
|
// CURRENT_DATE는 파라미터가 아니므로 직접 SQL에 포함
|
||||||
|
// NULL 처리: (start_date IS NULL OR start_date <= CURRENT_DATE) AND (end_date IS NULL OR end_date >= CURRENT_DATE)
|
||||||
|
conditions.push(
|
||||||
|
`((${startCol} IS NULL OR ${startCol} <= CURRENT_DATE) AND (${endCol} IS NULL OR ${endCol} >= CURRENT_DATE))`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// NULL 처리: (start_date IS NULL OR start_date <= $param) AND (end_date IS NULL OR end_date >= $param)
|
||||||
|
conditions.push(
|
||||||
|
`((${startCol} IS NULL OR ${startCol} <= $${paramIndex}) AND (${endCol} IS NULL OR ${endCol} >= $${paramIndex}))`
|
||||||
|
);
|
||||||
|
params.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// 알 수 없는 연산자는 무시
|
// 알 수 없는 연산자는 무시
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,97 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
|
|
||||||
console.log("API 응답:", { screenInfo, layoutData });
|
console.log("API 응답:", { screenInfo, layoutData });
|
||||||
|
|
||||||
|
// 🆕 URL 파라미터 확인 (수정 모드)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const mode = urlParams.get("mode");
|
||||||
|
const editId = urlParams.get("editId");
|
||||||
|
const tableName = urlParams.get("tableName") || screenInfo.tableName;
|
||||||
|
const groupByColumnsParam = urlParams.get("groupByColumns");
|
||||||
|
|
||||||
|
console.log("📋 URL 파라미터 확인:", { mode, editId, tableName, groupByColumnsParam });
|
||||||
|
|
||||||
|
// 수정 모드이고 editId가 있으면 해당 레코드 조회
|
||||||
|
if (mode === "edit" && editId && tableName) {
|
||||||
|
try {
|
||||||
|
console.log("🔍 수정 데이터 조회 시작:", { tableName, editId, groupByColumnsParam });
|
||||||
|
|
||||||
|
const { dataApi } = await import("@/lib/api/data");
|
||||||
|
|
||||||
|
// groupByColumns 파싱
|
||||||
|
let groupByColumns: string[] = [];
|
||||||
|
if (groupByColumnsParam) {
|
||||||
|
try {
|
||||||
|
groupByColumns = JSON.parse(groupByColumnsParam);
|
||||||
|
console.log("✅ [ScreenModal] groupByColumns 파싱 성공:", groupByColumns);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("groupByColumns 파싱 실패:", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ [ScreenModal] groupByColumnsParam이 없습니다!");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🚀 [ScreenModal] API 호출 직전:", {
|
||||||
|
tableName,
|
||||||
|
editId,
|
||||||
|
enableEntityJoin: true,
|
||||||
|
groupByColumns,
|
||||||
|
groupByColumnsLength: groupByColumns.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 apiClient를 named import로 가져오기
|
||||||
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
const params: any = {
|
||||||
|
enableEntityJoin: true,
|
||||||
|
};
|
||||||
|
if (groupByColumns.length > 0) {
|
||||||
|
params.groupByColumns = JSON.stringify(groupByColumns);
|
||||||
|
console.log("✅ [ScreenModal] groupByColumns를 params에 추가:", params.groupByColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📡 [ScreenModal] 실제 API 요청:", {
|
||||||
|
url: `/data/${tableName}/${editId}`,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiResponse = await apiClient.get(`/data/${tableName}/${editId}`, { params });
|
||||||
|
const response = apiResponse.data;
|
||||||
|
|
||||||
|
console.log("📩 [ScreenModal] API 응답 받음:", {
|
||||||
|
success: response.success,
|
||||||
|
hasData: !!response.data,
|
||||||
|
dataType: response.data ? (Array.isArray(response.data) ? "배열" : "객체") : "없음",
|
||||||
|
dataLength: Array.isArray(response.data) ? response.data.length : 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 배열인 경우 (그룹핑) vs 단일 객체
|
||||||
|
const isArray = Array.isArray(response.data);
|
||||||
|
|
||||||
|
if (isArray) {
|
||||||
|
console.log(`✅ 수정 데이터 로드 완료 (그룹 레코드: ${response.data.length}개)`);
|
||||||
|
console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log("✅ 수정 데이터 로드 완료 (필드 수:", Object.keys(response.data).length, ")");
|
||||||
|
console.log("📊 모든 필드 키:", Object.keys(response.data));
|
||||||
|
console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData(response.data);
|
||||||
|
|
||||||
|
// setFormData 직후 확인
|
||||||
|
console.log("🔄 setFormData 호출 완료");
|
||||||
|
} else {
|
||||||
|
console.error("❌ 수정 데이터 로드 실패:", response.error);
|
||||||
|
toast.error("데이터를 불러올 수 없습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 수정 데이터 조회 오류:", error);
|
||||||
|
toast.error("데이터를 불러오는 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// screenApi는 직접 데이터를 반환하므로 .success 체크 불필요
|
// screenApi는 직접 데이터를 반환하므로 .success 체크 불필요
|
||||||
if (screenInfo && layoutData) {
|
if (screenInfo && layoutData) {
|
||||||
const components = layoutData.components || [];
|
const components = layoutData.components || [];
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,8 @@ export function DataFilterConfigPanel({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 컬럼 선택 */}
|
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
|
||||||
|
{filter.operator !== "date_range_contains" && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">컬럼</Label>
|
<Label className="text-xs">컬럼</Label>
|
||||||
<Select
|
<Select
|
||||||
|
|
@ -248,13 +249,30 @@ export function DataFilterConfigPanel({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 연산자 선택 */}
|
{/* 연산자 선택 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">연산자</Label>
|
<Label className="text-xs">연산자</Label>
|
||||||
<Select
|
<Select
|
||||||
value={filter.operator}
|
value={filter.operator}
|
||||||
onValueChange={(value: any) => handleFilterChange(filter.id, "operator", value)}
|
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">
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -262,6 +280,11 @@ export function DataFilterConfigPanel({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="equals">같음 (=)</SelectItem>
|
<SelectItem value="equals">같음 (=)</SelectItem>
|
||||||
<SelectItem value="not_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="in">포함됨 (IN)</SelectItem>
|
||||||
<SelectItem value="not_in">포함되지 않음 (NOT IN)</SelectItem>
|
<SelectItem value="not_in">포함되지 않음 (NOT IN)</SelectItem>
|
||||||
<SelectItem value="contains">포함 (LIKE %value%)</SelectItem>
|
<SelectItem value="contains">포함 (LIKE %value%)</SelectItem>
|
||||||
|
|
@ -269,34 +292,138 @@ export function DataFilterConfigPanel({
|
||||||
<SelectItem value="ends_with">끝 (LIKE %value)</SelectItem>
|
<SelectItem value="ends_with">끝 (LIKE %value)</SelectItem>
|
||||||
<SelectItem value="is_null">NULL</SelectItem>
|
<SelectItem value="is_null">NULL</SelectItem>
|
||||||
<SelectItem value="is_not_null">NOT NULL</SelectItem>
|
<SelectItem value="is_not_null">NOT NULL</SelectItem>
|
||||||
|
<SelectItem value="date_range_contains">날짜 범위 포함 (기간 내)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 값 타입 선택 (카테고리/코드 컬럼만) */}
|
{/* 날짜 범위 포함 - 시작일/종료일 컬럼 선택 */}
|
||||||
{isCategoryOrCodeColumn(filter.columnName) && (
|
{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>
|
||||||
|
<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>
|
<div>
|
||||||
<Label className="text-xs">값 타입</Label>
|
<Label className="text-xs">값 타입</Label>
|
||||||
<Select
|
<Select
|
||||||
value={filter.valueType}
|
value={filter.valueType}
|
||||||
onValueChange={(value: any) =>
|
onValueChange={(value: any) => {
|
||||||
handleFilterChange(filter.id, "valueType", value)
|
// 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">
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="static">직접 입력</SelectItem>
|
<SelectItem value="static">직접 입력</SelectItem>
|
||||||
|
{filter.operator === "date_range_contains" && (
|
||||||
|
<SelectItem value="dynamic">동적 값 (오늘 날짜)</SelectItem>
|
||||||
|
)}
|
||||||
|
{isCategoryOrCodeColumn(filter.columnName) && (
|
||||||
|
<>
|
||||||
<SelectItem value="category">카테고리 선택</SelectItem>
|
<SelectItem value="category">카테고리 선택</SelectItem>
|
||||||
<SelectItem value="code">코드 선택</SelectItem>
|
<SelectItem value="code">코드 선택</SelectItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 값 입력 (NULL 체크 제외) */}
|
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
|
||||||
{filter.operator !== "is_null" && filter.operator !== "is_not_null" && (
|
{filter.operator !== "is_null" &&
|
||||||
|
filter.operator !== "is_not_null" &&
|
||||||
|
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs">값</Label>
|
<Label className="text-xs">값</Label>
|
||||||
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
||||||
|
|
@ -328,11 +455,22 @@ export function DataFilterConfigPanel({
|
||||||
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
|
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
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
|
<Input
|
||||||
|
type={filter.operator === "date_range_contains" ? "date" : "text"}
|
||||||
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
|
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
|
||||||
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
|
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
|
||||||
placeholder="필터 값 입력"
|
placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"}
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -341,10 +479,23 @@ export function DataFilterConfigPanel({
|
||||||
? "카테고리 값을 선택하세요"
|
? "카테고리 값을 선택하세요"
|
||||||
: filter.operator === "in" || filter.operator === "not_in"
|
: filter.operator === "in" || filter.operator === "not_in"
|
||||||
? "여러 값은 쉼표(,)로 구분하세요"
|
? "여러 값은 쉼표(,)로 구분하세요"
|
||||||
|
: filter.operator === "between"
|
||||||
|
? "시작과 종료 값을 ~로 구분하세요"
|
||||||
|
: filter.operator === "date_range_contains"
|
||||||
|
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
|
||||||
: "필터링할 값을 입력하세요"}
|
: "필터링할 값을 입력하세요"}
|
||||||
</p>
|
</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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,49 @@ export const dataApi = {
|
||||||
* 특정 레코드 상세 조회
|
* 특정 레코드 상세 조회
|
||||||
* @param tableName 테이블명
|
* @param tableName 테이블명
|
||||||
* @param id 레코드 ID
|
* @param id 레코드 ID
|
||||||
|
* @param enableEntityJoin Entity 조인 활성화 여부 (기본값: false)
|
||||||
|
* @param groupByColumns 그룹핑 기준 컬럼들 (배열)
|
||||||
*/
|
*/
|
||||||
getRecordDetail: async (tableName: string, id: string | number): Promise<any> => {
|
getRecordDetail: async (
|
||||||
const response = await apiClient.get(`/data/${tableName}/${id}`);
|
tableName: string,
|
||||||
return response.data?.data || response.data;
|
id: string | number,
|
||||||
|
enableEntityJoin: boolean = false,
|
||||||
|
groupByColumns: string[] = []
|
||||||
|
): Promise<{ success: boolean; data?: any; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const params: any = {};
|
||||||
|
if (enableEntityJoin) {
|
||||||
|
params.enableEntityJoin = true;
|
||||||
|
}
|
||||||
|
if (groupByColumns.length > 0) {
|
||||||
|
params.groupByColumns = JSON.stringify(groupByColumns);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🌐 [dataApi.getRecordDetail] API 호출:", {
|
||||||
|
tableName,
|
||||||
|
id,
|
||||||
|
enableEntityJoin,
|
||||||
|
groupByColumns,
|
||||||
|
params,
|
||||||
|
url: `/data/${tableName}/${id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await apiClient.get(`/data/${tableName}/${id}`, { params });
|
||||||
|
|
||||||
|
console.log("📥 [dataApi.getRecordDetail] API 응답:", {
|
||||||
|
success: response.data?.success,
|
||||||
|
dataType: Array.isArray(response.data?.data) ? "배열" : "객체",
|
||||||
|
dataCount: Array.isArray(response.data?.data) ? response.data.data.length : 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data; // { success: true, data: ... } 형식 그대로 반환
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ [dataApi.getRecordDetail] API 오류:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message || "레코드 조회 실패",
|
||||||
|
};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -55,6 +94,9 @@ export const dataApi = {
|
||||||
* @param leftColumn 좌측 컬럼명
|
* @param leftColumn 좌측 컬럼명
|
||||||
* @param rightColumn 우측 컬럼명 (외래키)
|
* @param rightColumn 우측 컬럼명 (외래키)
|
||||||
* @param leftValue 좌측 값 (필터링)
|
* @param leftValue 좌측 값 (필터링)
|
||||||
|
* @param dataFilter 데이터 필터
|
||||||
|
* @param enableEntityJoin Entity 조인 활성화
|
||||||
|
* @param displayColumns 표시할 컬럼 목록 (tableName.columnName 형식 포함)
|
||||||
*/
|
*/
|
||||||
getJoinedData: async (
|
getJoinedData: async (
|
||||||
leftTable: string,
|
leftTable: string,
|
||||||
|
|
@ -62,7 +104,15 @@ export const dataApi = {
|
||||||
leftColumn: string,
|
leftColumn: string,
|
||||||
rightColumn: string,
|
rightColumn: string,
|
||||||
leftValue?: any,
|
leftValue?: any,
|
||||||
dataFilter?: any, // 🆕 데이터 필터
|
dataFilter?: any,
|
||||||
|
enableEntityJoin?: boolean,
|
||||||
|
displayColumns?: Array<{ name: string; label?: string }>,
|
||||||
|
deduplication?: { // 🆕 중복 제거 설정
|
||||||
|
enabled: boolean;
|
||||||
|
groupByColumn: string;
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date";
|
||||||
|
sortColumn?: string;
|
||||||
|
},
|
||||||
): Promise<any[]> => {
|
): Promise<any[]> => {
|
||||||
const response = await apiClient.get(`/data/join`, {
|
const response = await apiClient.get(`/data/join`, {
|
||||||
params: {
|
params: {
|
||||||
|
|
@ -71,7 +121,10 @@ export const dataApi = {
|
||||||
leftColumn,
|
leftColumn,
|
||||||
rightColumn,
|
rightColumn,
|
||||||
leftValue,
|
leftValue,
|
||||||
dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined, // 🆕 데이터 필터 전달
|
dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined,
|
||||||
|
enableEntityJoin: enableEntityJoin ?? true,
|
||||||
|
displayColumns: displayColumns ? JSON.stringify(displayColumns) : undefined, // 🆕 표시 컬럼 전달
|
||||||
|
deduplication: deduplication ? JSON.stringify(deduplication) : undefined, // 🆕 중복 제거 설정 전달
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const raw = response.data || {};
|
const raw = response.data || {};
|
||||||
|
|
@ -115,4 +168,56 @@ export const dataApi = {
|
||||||
const response = await apiClient.delete(`/data/${tableName}/${id}`);
|
const response = await apiClient.delete(`/data/${tableName}/${id}`);
|
||||||
return response.data; // success, message 포함된 전체 응답 반환
|
return response.data; // success, message 포함된 전체 응답 반환
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 레코드 상세 조회
|
||||||
|
* @param tableName 테이블명
|
||||||
|
* @param id 레코드 ID
|
||||||
|
* @param enableEntityJoin Entity 조인 활성화 여부 (기본값: false)
|
||||||
|
*/
|
||||||
|
getRecordDetail: async (
|
||||||
|
tableName: string,
|
||||||
|
id: string | number,
|
||||||
|
enableEntityJoin: boolean = false
|
||||||
|
): Promise<{ success: boolean; data?: any; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const params: any = {};
|
||||||
|
if (enableEntityJoin) {
|
||||||
|
params.enableEntityJoin = "true";
|
||||||
|
}
|
||||||
|
const response = await apiClient.get(`/data/${tableName}/${id}`, { params });
|
||||||
|
return response.data; // { success: true, data: ... } 형식 그대로 반환
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message || "레코드 조회 실패",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹화된 데이터 UPSERT
|
||||||
|
* @param tableName 테이블명
|
||||||
|
* @param parentKeys 부모 키 (예: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" })
|
||||||
|
* @param records 레코드 배열
|
||||||
|
*/
|
||||||
|
upsertGroupedRecords: async (
|
||||||
|
tableName: string,
|
||||||
|
parentKeys: Record<string, any>,
|
||||||
|
records: Array<Record<string, any>>
|
||||||
|
): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/data/upsert-grouped', {
|
||||||
|
tableName,
|
||||||
|
parentKeys,
|
||||||
|
records,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message || "데이터 저장 실패",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -71,25 +71,6 @@ export const entityJoinApi = {
|
||||||
dataFilter?: any; // 🆕 데이터 필터
|
dataFilter?: any; // 🆕 데이터 필터
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<EntityJoinResponse> => {
|
): Promise<EntityJoinResponse> => {
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
|
|
||||||
if (params.page) searchParams.append("page", params.page.toString());
|
|
||||||
if (params.size) searchParams.append("size", params.size.toString());
|
|
||||||
if (params.sortBy) searchParams.append("sortBy", params.sortBy);
|
|
||||||
if (params.sortOrder) searchParams.append("sortOrder", params.sortOrder);
|
|
||||||
if (params.enableEntityJoin !== undefined) {
|
|
||||||
searchParams.append("enableEntityJoin", params.enableEntityJoin.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 검색 조건 추가
|
|
||||||
if (params.search) {
|
|
||||||
Object.entries(params.search).forEach(([key, value]) => {
|
|
||||||
if (value !== undefined && value !== null && value !== "") {
|
|
||||||
searchParams.append(key, String(value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
// 🔒 멀티테넌시: company_code 자동 필터링 활성화
|
||||||
const autoFilter = {
|
const autoFilter = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|
@ -99,7 +80,11 @@ export const entityJoinApi = {
|
||||||
|
|
||||||
const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, {
|
const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, {
|
||||||
params: {
|
params: {
|
||||||
...params,
|
page: params.page,
|
||||||
|
size: params.size,
|
||||||
|
sortBy: params.sortBy,
|
||||||
|
sortOrder: params.sortOrder,
|
||||||
|
enableEntityJoin: params.enableEntityJoin,
|
||||||
search: params.search ? JSON.stringify(params.search) : undefined,
|
search: params.search ? JSON.stringify(params.search) : undefined,
|
||||||
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
|
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
|
||||||
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,98 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
|
|
||||||
// 🆕 모달 데이터를 ItemData 구조로 변환 (그룹별 구조)
|
// 🆕 모달 데이터를 ItemData 구조로 변환 (그룹별 구조)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 🆕 수정 모드: formData에서 데이터 로드 (URL에 mode=edit이 있으면)
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const mode = urlParams.get("mode");
|
||||||
|
|
||||||
|
if (mode === "edit" && formData) {
|
||||||
|
// 배열인지 단일 객체인지 확인
|
||||||
|
const isArray = Array.isArray(formData);
|
||||||
|
const dataArray = isArray ? formData : [formData];
|
||||||
|
|
||||||
|
if (dataArray.length === 0 || (dataArray.length === 1 && Object.keys(dataArray[0]).length === 0)) {
|
||||||
|
console.warn("⚠️ [SelectedItemsDetailInput] formData가 비어있음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📝 [SelectedItemsDetailInput] 수정 모드 - ${isArray ? '그룹 레코드' : '단일 레코드'} (${dataArray.length}개)`);
|
||||||
|
console.log("📝 [SelectedItemsDetailInput] formData (JSON):", JSON.stringify(dataArray, null, 2));
|
||||||
|
|
||||||
|
const groups = componentConfig.fieldGroups || [];
|
||||||
|
const additionalFields = componentConfig.additionalFields || [];
|
||||||
|
|
||||||
|
// 🆕 첫 번째 레코드의 originalData를 기본 항목으로 설정
|
||||||
|
const firstRecord = dataArray[0];
|
||||||
|
const mainFieldGroups: Record<string, GroupEntry[]> = {};
|
||||||
|
|
||||||
|
// 🔧 각 그룹별로 고유한 엔트리만 수집 (중복 제거)
|
||||||
|
groups.forEach((group) => {
|
||||||
|
const groupFields = additionalFields.filter((field: any) => field.groupId === group.id);
|
||||||
|
|
||||||
|
if (groupFields.length === 0) {
|
||||||
|
mainFieldGroups[group.id] = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 각 레코드에서 그룹 데이터 추출
|
||||||
|
const entriesMap = new Map<string, GroupEntry>();
|
||||||
|
|
||||||
|
dataArray.forEach((record) => {
|
||||||
|
const entryData: Record<string, any> = {};
|
||||||
|
|
||||||
|
groupFields.forEach((field: any) => {
|
||||||
|
let fieldValue = record[field.name];
|
||||||
|
if (fieldValue !== undefined && fieldValue !== null) {
|
||||||
|
// 🔧 날짜 타입이면 YYYY-MM-DD 형식으로 변환 (타임존 제거)
|
||||||
|
if (field.type === "date" || field.type === "datetime") {
|
||||||
|
const dateStr = String(fieldValue);
|
||||||
|
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (match) {
|
||||||
|
const [, year, month, day] = match;
|
||||||
|
fieldValue = `${year}-${month}-${day}`; // ISO 형식 유지 (시간 제거)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entryData[field.name] = fieldValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔑 모든 필드 값을 합쳐서 고유 키 생성 (중복 제거 기준)
|
||||||
|
const entryKey = JSON.stringify(entryData);
|
||||||
|
|
||||||
|
if (!entriesMap.has(entryKey)) {
|
||||||
|
entriesMap.set(entryKey, {
|
||||||
|
id: `${group.id}_entry_${entriesMap.size + 1}`,
|
||||||
|
...entryData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mainFieldGroups[group.id] = Array.from(entriesMap.values());
|
||||||
|
});
|
||||||
|
|
||||||
|
// 그룹이 없으면 기본 그룹 생성
|
||||||
|
if (groups.length === 0) {
|
||||||
|
mainFieldGroups["default"] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItem: ItemData = {
|
||||||
|
id: String(firstRecord.id || firstRecord.item_id || "edit"),
|
||||||
|
originalData: firstRecord, // 첫 번째 레코드를 대표 데이터로 사용
|
||||||
|
fieldGroups: mainFieldGroups,
|
||||||
|
};
|
||||||
|
|
||||||
|
setItems([newItem]);
|
||||||
|
|
||||||
|
console.log("✅ [SelectedItemsDetailInput] 수정 모드 데이터 로드 완료:", {
|
||||||
|
recordCount: dataArray.length,
|
||||||
|
item: newItem,
|
||||||
|
fieldGroupsKeys: Object.keys(mainFieldGroups),
|
||||||
|
firstGroupEntries: mainFieldGroups[groups[0]?.id]?.length || 0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 생성 모드: modalData에서 데이터 로드
|
||||||
if (modalData && modalData.length > 0) {
|
if (modalData && modalData.length > 0) {
|
||||||
console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData);
|
console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData);
|
||||||
|
|
||||||
|
|
@ -253,11 +345,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [modalData, component.id, componentConfig.fieldGroups]); // onFormDataChange는 의존성에서 제외
|
}, [modalData, component.id, componentConfig.fieldGroups, formData]); // formData 의존성 추가
|
||||||
|
|
||||||
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
|
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSaveRequest = (event: Event) => {
|
const handleSaveRequest = async (event: Event) => {
|
||||||
// component.id를 문자열로 안전하게 변환
|
// component.id를 문자열로 안전하게 변환
|
||||||
const componentKey = String(component.id || "selected_items");
|
const componentKey = String(component.id || "selected_items");
|
||||||
|
|
||||||
|
|
@ -269,7 +361,88 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
componentKey,
|
componentKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (items.length > 0) {
|
if (items.length === 0) {
|
||||||
|
console.warn("⚠️ [SelectedItemsDetailInput] 저장할 데이터 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 수정 모드인지 확인 (URL에 mode=edit이 있으면)
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const mode = urlParams.get("mode");
|
||||||
|
const isEditMode = mode === "edit";
|
||||||
|
|
||||||
|
console.log("📝 [SelectedItemsDetailInput] 저장 모드:", { mode, isEditMode });
|
||||||
|
|
||||||
|
if (isEditMode && componentConfig.parentDataMapping && componentConfig.parentDataMapping.length > 0) {
|
||||||
|
// 🔄 수정 모드: UPSERT API 사용
|
||||||
|
try {
|
||||||
|
console.log("🔄 [SelectedItemsDetailInput] UPSERT 모드로 저장 시작");
|
||||||
|
|
||||||
|
// 부모 키 추출 (parentDataMapping에서)
|
||||||
|
const parentKeys: Record<string, any> = {};
|
||||||
|
|
||||||
|
// formData 또는 items[0].originalData에서 부모 데이터 가져오기
|
||||||
|
const sourceData = formData || items[0]?.originalData || {};
|
||||||
|
|
||||||
|
componentConfig.parentDataMapping.forEach((mapping) => {
|
||||||
|
const value = sourceData[mapping.sourceField];
|
||||||
|
if (value !== undefined && value !== null) {
|
||||||
|
parentKeys[mapping.targetField] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🔑 [SelectedItemsDetailInput] 부모 키:", parentKeys);
|
||||||
|
|
||||||
|
// items를 Cartesian Product로 변환
|
||||||
|
const records = generateCartesianProduct(items);
|
||||||
|
|
||||||
|
console.log("📦 [SelectedItemsDetailInput] UPSERT 레코드:", {
|
||||||
|
parentKeys,
|
||||||
|
recordCount: records.length,
|
||||||
|
records,
|
||||||
|
});
|
||||||
|
|
||||||
|
// UPSERT API 호출
|
||||||
|
const { dataApi } = await import("@/lib/api/data");
|
||||||
|
const result = await dataApi.upsertGroupedRecords(
|
||||||
|
componentConfig.targetTable || "",
|
||||||
|
parentKeys,
|
||||||
|
records
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log("✅ [SelectedItemsDetailInput] UPSERT 성공:", {
|
||||||
|
inserted: result.inserted,
|
||||||
|
updated: result.updated,
|
||||||
|
deleted: result.deleted,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 저장 성공 이벤트 발생
|
||||||
|
window.dispatchEvent(new CustomEvent("formSaveSuccess", {
|
||||||
|
detail: { message: "데이터가 저장되었습니다." },
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
console.error("❌ [SelectedItemsDetailInput] UPSERT 실패:", result.error);
|
||||||
|
window.dispatchEvent(new CustomEvent("formSaveError", {
|
||||||
|
detail: { message: result.error || "데이터 저장 실패" },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// event.preventDefault() 역할
|
||||||
|
if (event instanceof CustomEvent && event.detail) {
|
||||||
|
event.detail.skipDefaultSave = true; // 기본 저장 로직 건너뛰기
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ [SelectedItemsDetailInput] UPSERT 오류:", error);
|
||||||
|
window.dispatchEvent(new CustomEvent("formSaveError", {
|
||||||
|
detail: { message: "데이터 저장 중 오류가 발생했습니다." },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 📝 생성 모드: 기존 로직 (Cartesian Product 생성 후 formData에 추가)
|
||||||
|
console.log("📝 [SelectedItemsDetailInput] 생성 모드: 기존 저장 로직 사용");
|
||||||
|
|
||||||
console.log("📝 [SelectedItemsDetailInput] 저장 데이터 준비:", {
|
console.log("📝 [SelectedItemsDetailInput] 저장 데이터 준비:", {
|
||||||
key: componentKey,
|
key: componentKey,
|
||||||
itemsCount: items.length,
|
itemsCount: items.length,
|
||||||
|
|
@ -287,22 +460,16 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
if (onFormDataChange) {
|
if (onFormDataChange) {
|
||||||
onFormDataChange(componentKey, items);
|
onFormDataChange(componentKey, items);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.warn("⚠️ [SelectedItemsDetailInput] 저장할 데이터 없음:", {
|
|
||||||
hasItems: items.length > 0,
|
|
||||||
hasCallback: !!onFormDataChange,
|
|
||||||
itemsLength: items.length,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 저장 버튼 클릭 시 데이터 수집
|
// 저장 버튼 클릭 시 데이터 수집
|
||||||
window.addEventListener("beforeFormSave", handleSaveRequest);
|
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("beforeFormSave", handleSaveRequest);
|
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||||
};
|
};
|
||||||
}, [items, component.id, onFormDataChange]);
|
}, [items, component.id, onFormDataChange, componentConfig, formData]);
|
||||||
|
|
||||||
// 스타일 계산
|
// 스타일 계산
|
||||||
const componentStyle: React.CSSProperties = {
|
const componentStyle: React.CSSProperties = {
|
||||||
|
|
@ -768,7 +935,22 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
? f.groupId === groupId
|
? f.groupId === groupId
|
||||||
: true
|
: true
|
||||||
);
|
);
|
||||||
return fields.map((f) => entry[f.name] || "-").join(" / ");
|
return fields.map((f) => {
|
||||||
|
const value = entry[f.name];
|
||||||
|
if (!value) return "-";
|
||||||
|
|
||||||
|
const strValue = String(value);
|
||||||
|
|
||||||
|
// 🔧 ISO 날짜 형식 자동 감지 및 포맷팅 (필드 타입 무관)
|
||||||
|
// ISO 8601 형식: YYYY-MM-DDTHH:mm:ss.sssZ 또는 YYYY-MM-DD
|
||||||
|
const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/);
|
||||||
|
if (isoDateMatch) {
|
||||||
|
const [, year, month, day] = isoDateMatch;
|
||||||
|
return `${year}.${month}.${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strValue;
|
||||||
|
}).join(" / ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// displayItems 설정대로 렌더링
|
// displayItems 설정대로 렌더링
|
||||||
|
|
@ -856,6 +1038,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
case "date":
|
case "date":
|
||||||
// YYYY.MM.DD 형식
|
// YYYY.MM.DD 형식
|
||||||
if (fieldValue) {
|
if (fieldValue) {
|
||||||
|
// 날짜 문자열을 직접 파싱 (타임존 문제 방지)
|
||||||
|
const dateStr = String(fieldValue);
|
||||||
|
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||||
|
if (match) {
|
||||||
|
const [, year, month, day] = match;
|
||||||
|
formattedValue = `${year}.${month}.${day}`;
|
||||||
|
} else {
|
||||||
|
// Date 객체로 변환 시도 (fallback)
|
||||||
const date = new Date(fieldValue);
|
const date = new Date(fieldValue);
|
||||||
if (!isNaN(date.getTime())) {
|
if (!isNaN(date.getTime())) {
|
||||||
formattedValue = date.toLocaleDateString("ko-KR", {
|
formattedValue = date.toLocaleDateString("ko-KR", {
|
||||||
|
|
@ -865,6 +1055,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
}).replace(/\. /g, ".").replace(/\.$/, "");
|
}).replace(/\. /g, ".").replace(/\.$/, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "badge":
|
case "badge":
|
||||||
// 배지로 표시
|
// 배지로 표시
|
||||||
|
|
|
||||||
|
|
@ -369,9 +369,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
setIsLoadingRight(true);
|
setIsLoadingRight(true);
|
||||||
try {
|
try {
|
||||||
if (relationshipType === "detail") {
|
if (relationshipType === "detail") {
|
||||||
// 상세 모드: 동일 테이블의 상세 정보
|
// 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화)
|
||||||
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
|
const primaryKey = leftItem.id || leftItem.ID || Object.values(leftItem)[0];
|
||||||
const detail = await dataApi.getRecordDetail(rightTableName, primaryKey);
|
|
||||||
|
// 🆕 엔티티 조인 API 사용
|
||||||
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
|
||||||
|
search: { id: primaryKey },
|
||||||
|
enableEntityJoin: true, // 엔티티 조인 활성화
|
||||||
|
size: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const detail = result.items && result.items.length > 0 ? result.items[0] : null;
|
||||||
setRightData(detail);
|
setRightData(detail);
|
||||||
} else if (relationshipType === "join") {
|
} else if (relationshipType === "join") {
|
||||||
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
||||||
|
|
@ -388,6 +397,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
rightColumn,
|
rightColumn,
|
||||||
leftValue,
|
leftValue,
|
||||||
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달
|
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달
|
||||||
|
true, // 🆕 Entity 조인 활성화
|
||||||
|
componentConfig.rightPanel?.columns, // 🆕 표시 컬럼 전달 (item_info.item_name 등)
|
||||||
|
componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
|
||||||
);
|
);
|
||||||
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
||||||
}
|
}
|
||||||
|
|
@ -754,12 +766,91 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 수정 버튼 핸들러
|
// 수정 버튼 핸들러
|
||||||
const handleEditClick = useCallback((panel: "left" | "right", item: any) => {
|
const handleEditClick = useCallback(
|
||||||
|
(panel: "left" | "right", item: any) => {
|
||||||
|
// 🆕 우측 패널 수정 버튼 설정 확인
|
||||||
|
if (panel === "right" && componentConfig.rightPanel?.editButton?.mode === "modal") {
|
||||||
|
const modalScreenId = componentConfig.rightPanel?.editButton?.modalScreenId;
|
||||||
|
|
||||||
|
if (modalScreenId) {
|
||||||
|
// 커스텀 모달 화면 열기
|
||||||
|
const rightTableName = componentConfig.rightPanel?.tableName || "";
|
||||||
|
|
||||||
|
// Primary Key 찾기 (우선순위: id > ID > 첫 번째 필드)
|
||||||
|
let primaryKeyName = "id";
|
||||||
|
let primaryKeyValue: any;
|
||||||
|
|
||||||
|
if (item.id !== undefined && item.id !== null) {
|
||||||
|
primaryKeyName = "id";
|
||||||
|
primaryKeyValue = item.id;
|
||||||
|
} else if (item.ID !== undefined && item.ID !== null) {
|
||||||
|
primaryKeyName = "ID";
|
||||||
|
primaryKeyValue = item.ID;
|
||||||
|
} else {
|
||||||
|
// 첫 번째 필드를 Primary Key로 간주
|
||||||
|
const firstKey = Object.keys(item)[0];
|
||||||
|
primaryKeyName = firstKey;
|
||||||
|
primaryKeyValue = item[firstKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ 수정 모달 열기:`, {
|
||||||
|
tableName: rightTableName,
|
||||||
|
primaryKeyName,
|
||||||
|
primaryKeyValue,
|
||||||
|
screenId: modalScreenId,
|
||||||
|
fullItem: item,
|
||||||
|
});
|
||||||
|
|
||||||
|
// modalDataStore에도 저장 (호환성 유지)
|
||||||
|
import("@/stores/modalDataStore").then(({ useModalDataStore }) => {
|
||||||
|
useModalDataStore.getState().setData(rightTableName, [item]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 groupByColumns 추출
|
||||||
|
const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
|
||||||
|
|
||||||
|
console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", {
|
||||||
|
groupByColumns,
|
||||||
|
editButtonConfig: componentConfig.rightPanel?.editButton,
|
||||||
|
hasGroupByColumns: groupByColumns.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달)
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("openScreenModal", {
|
||||||
|
detail: {
|
||||||
|
screenId: modalScreenId,
|
||||||
|
urlParams: {
|
||||||
|
mode: "edit",
|
||||||
|
editId: primaryKeyValue,
|
||||||
|
tableName: rightTableName,
|
||||||
|
...(groupByColumns.length > 0 && {
|
||||||
|
groupByColumns: JSON.stringify(groupByColumns),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", {
|
||||||
|
screenId: modalScreenId,
|
||||||
|
editId: primaryKeyValue,
|
||||||
|
tableName: rightTableName,
|
||||||
|
groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음",
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 자동 편집 모드 (인라인 편집 모달)
|
||||||
setEditModalPanel(panel);
|
setEditModalPanel(panel);
|
||||||
setEditModalItem(item);
|
setEditModalItem(item);
|
||||||
setEditModalFormData({ ...item });
|
setEditModalFormData({ ...item });
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
}, []);
|
},
|
||||||
|
[componentConfig],
|
||||||
|
);
|
||||||
|
|
||||||
// 수정 모달 저장
|
// 수정 모달 저장
|
||||||
const handleEditModalSave = useCallback(async () => {
|
const handleEditModalSave = useCallback(async () => {
|
||||||
|
|
@ -1850,16 +1941,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
{!isDesignMode && (
|
{!isDesignMode && (
|
||||||
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
|
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
<button
|
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
||||||
|
<Button
|
||||||
|
variant={componentConfig.rightPanel?.editButton?.buttonVariant || "outline"}
|
||||||
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleEditClick("right", item);
|
handleEditClick("right", item);
|
||||||
}}
|
}}
|
||||||
className="rounded p-1 transition-colors hover:bg-gray-200"
|
className="h-7"
|
||||||
title="수정"
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4 text-gray-600" />
|
<Pencil className="h-3 w-3 mr-1" />
|
||||||
</button>
|
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -1898,45 +1993,97 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
// 우측 패널 표시 컬럼 설정 확인
|
// 우측 패널 표시 컬럼 설정 확인
|
||||||
const rightColumns = componentConfig.rightPanel?.columns;
|
const rightColumns = componentConfig.rightPanel?.columns;
|
||||||
let firstValues: [string, any][] = [];
|
let firstValues: [string, any, string][] = [];
|
||||||
let allValues: [string, any][] = [];
|
let allValues: [string, any, string][] = [];
|
||||||
|
|
||||||
if (index === 0) {
|
|
||||||
console.log("🔍 우측 패널 표시 로직:");
|
|
||||||
console.log(" - rightColumns:", rightColumns);
|
|
||||||
console.log(" - item keys:", Object.keys(item));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rightColumns && rightColumns.length > 0) {
|
if (rightColumns && rightColumns.length > 0) {
|
||||||
// 설정된 컬럼만 표시
|
// 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리)
|
||||||
|
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
|
||||||
firstValues = rightColumns
|
firstValues = rightColumns
|
||||||
.slice(0, 3)
|
.slice(0, summaryCount)
|
||||||
.map((col) => [col.name, item[col.name]] as [string, any])
|
.map((col) => {
|
||||||
|
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_number → item_number 또는 item_id_name)
|
||||||
|
let value = item[col.name];
|
||||||
|
if (value === undefined && col.name.includes('.')) {
|
||||||
|
const columnName = col.name.split('.').pop();
|
||||||
|
// 1차: 컬럼명 그대로 (예: item_number)
|
||||||
|
value = item[columnName || ''];
|
||||||
|
// 2차: item_info.item_number → item_id_name 또는 item_id_item_number 형식 확인
|
||||||
|
if (value === undefined) {
|
||||||
|
const parts = col.name.split('.');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const refTable = parts[0]; // item_info
|
||||||
|
const refColumn = parts[1]; // item_number 또는 item_name
|
||||||
|
// FK 컬럼명 추론: item_info → item_id
|
||||||
|
const fkColumn = refTable.replace('_info', '').replace('_mng', '') + '_id';
|
||||||
|
|
||||||
|
// 백엔드에서 반환하는 별칭 패턴:
|
||||||
|
// 1) item_id_name (기본 referenceColumn)
|
||||||
|
// 2) item_id_item_name (추가 컬럼)
|
||||||
|
if (refColumn === refTable.replace('_info', '').replace('_mng', '') + '_number' ||
|
||||||
|
refColumn === refTable.replace('_info', '').replace('_mng', '') + '_code') {
|
||||||
|
// 기본 참조 컬럼 (item_number, customer_code 등)
|
||||||
|
const aliasKey = fkColumn + '_name';
|
||||||
|
value = item[aliasKey];
|
||||||
|
} else {
|
||||||
|
// 추가 컬럼 (item_name, customer_name 등)
|
||||||
|
const aliasKey = `${fkColumn}_${refColumn}`;
|
||||||
|
value = item[aliasKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [col.name, value, col.label] as [string, any, string];
|
||||||
|
})
|
||||||
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
||||||
|
|
||||||
allValues = rightColumns
|
allValues = rightColumns
|
||||||
.map((col) => [col.name, item[col.name]] as [string, any])
|
.map((col) => {
|
||||||
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
// 🆕 엔티티 조인 컬럼 처리
|
||||||
|
let value = item[col.name];
|
||||||
|
if (value === undefined && col.name.includes('.')) {
|
||||||
|
const columnName = col.name.split('.').pop();
|
||||||
|
// 1차: 컬럼명 그대로
|
||||||
|
value = item[columnName || ''];
|
||||||
|
// 2차: {fk_column}_name 또는 {fk_column}_{ref_column} 형식 확인
|
||||||
|
if (value === undefined) {
|
||||||
|
const parts = col.name.split('.');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const refTable = parts[0]; // item_info
|
||||||
|
const refColumn = parts[1]; // item_number 또는 item_name
|
||||||
|
// FK 컬럼명 추론: item_info → item_id
|
||||||
|
const fkColumn = refTable.replace('_info', '').replace('_mng', '') + '_id';
|
||||||
|
|
||||||
if (index === 0) {
|
// 백엔드에서 반환하는 별칭 패턴:
|
||||||
console.log(
|
// 1) item_id_name (기본 referenceColumn)
|
||||||
" ✅ 설정된 컬럼 사용:",
|
// 2) item_id_item_name (추가 컬럼)
|
||||||
rightColumns.map((c) => c.name),
|
if (refColumn === refTable.replace('_info', '').replace('_mng', '') + '_number' ||
|
||||||
);
|
refColumn === refTable.replace('_info', '').replace('_mng', '') + '_code') {
|
||||||
|
// 기본 참조 컬럼
|
||||||
|
const aliasKey = fkColumn + '_name';
|
||||||
|
value = item[aliasKey];
|
||||||
|
} else {
|
||||||
|
// 추가 컬럼
|
||||||
|
const aliasKey = `${fkColumn}_${refColumn}`;
|
||||||
|
value = item[aliasKey];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [col.name, value, col.label] as [string, any, string];
|
||||||
|
})
|
||||||
|
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
||||||
} else {
|
} else {
|
||||||
// 설정 없으면 모든 컬럼 표시 (기존 로직)
|
// 설정 없으면 모든 컬럼 표시 (기존 로직)
|
||||||
|
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
|
||||||
firstValues = Object.entries(item)
|
firstValues = Object.entries(item)
|
||||||
.filter(([key]) => !key.toLowerCase().includes("id"))
|
.filter(([key]) => !key.toLowerCase().includes("id"))
|
||||||
.slice(0, 3);
|
.slice(0, summaryCount)
|
||||||
|
.map(([key, value]) => [key, value, ''] as [string, any, string]);
|
||||||
|
|
||||||
allValues = Object.entries(item).filter(
|
allValues = Object.entries(item)
|
||||||
([key, value]) => value !== null && value !== undefined && value !== "",
|
.filter(([key, value]) => value !== null && value !== undefined && value !== "")
|
||||||
);
|
.map(([key, value]) => [key, value, ''] as [string, any, string]);
|
||||||
|
|
||||||
if (index === 0) {
|
|
||||||
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1951,30 +2098,63 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
className="min-w-0 flex-1 cursor-pointer"
|
className="min-w-0 flex-1 cursor-pointer"
|
||||||
onClick={() => toggleRightItemExpansion(itemId)}
|
onClick={() => toggleRightItemExpansion(itemId)}
|
||||||
>
|
>
|
||||||
{firstValues.map(([key, value], idx) => (
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
<div key={key} className="mb-1 last:mb-0">
|
{firstValues.map(([key, value, label], idx) => {
|
||||||
<div className="text-muted-foreground text-xs font-medium">
|
// 포맷 설정 및 볼드 설정 찾기
|
||||||
{getColumnLabel(key)}
|
const colConfig = rightColumns?.find(c => c.name === key);
|
||||||
|
const format = colConfig?.format;
|
||||||
|
const boldValue = colConfig?.bold ?? false;
|
||||||
|
|
||||||
|
// 숫자 포맷 적용
|
||||||
|
let displayValue = String(value || "-");
|
||||||
|
if (value !== null && value !== undefined && value !== "" && format) {
|
||||||
|
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
displayValue = numValue.toLocaleString('ko-KR', {
|
||||||
|
minimumFractionDigits: format.decimalPlaces ?? 0,
|
||||||
|
maximumFractionDigits: format.decimalPlaces ?? 10,
|
||||||
|
useGrouping: format.thousandSeparator ?? false,
|
||||||
|
});
|
||||||
|
if (format.prefix) displayValue = format.prefix + displayValue;
|
||||||
|
if (format.suffix) displayValue = displayValue + format.suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-baseline gap-1">
|
||||||
|
{showLabel && (
|
||||||
|
<span className="text-muted-foreground text-xs font-medium whitespace-nowrap">
|
||||||
|
{label || getColumnLabel(key)}:
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`text-foreground text-sm ${boldValue ? 'font-semibold' : ''}`}
|
||||||
|
title={displayValue}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-foreground truncate text-sm" title={String(value || "-")}>
|
);
|
||||||
{String(value || "-")}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-shrink-0 items-start gap-1 pt-1">
|
<div className="flex flex-shrink-0 items-start gap-1 pt-1">
|
||||||
{/* 수정 버튼 */}
|
{/* 수정 버튼 */}
|
||||||
{!isDesignMode && (
|
{!isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
||||||
<button
|
<Button
|
||||||
|
variant={componentConfig.rightPanel?.editButton?.buttonVariant || "outline"}
|
||||||
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleEditClick("right", item);
|
handleEditClick("right", item);
|
||||||
}}
|
}}
|
||||||
className="rounded p-1 transition-colors hover:bg-gray-200"
|
className="h-7"
|
||||||
title="수정"
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4 text-gray-600" />
|
<Pencil className="h-3 w-3 mr-1" />
|
||||||
</button>
|
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* 삭제 버튼 */}
|
{/* 삭제 버튼 */}
|
||||||
{!isDesignMode && (
|
{!isDesignMode && (
|
||||||
|
|
@ -2011,14 +2191,35 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
<div className="bg-card overflow-auto rounded-md border">
|
<div className="bg-card overflow-auto rounded-md border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<tbody className="divide-border divide-y">
|
<tbody className="divide-border divide-y">
|
||||||
{allValues.map(([key, value]) => (
|
{allValues.map(([key, value, label]) => {
|
||||||
|
// 포맷 설정 찾기
|
||||||
|
const colConfig = rightColumns?.find(c => c.name === key);
|
||||||
|
const format = colConfig?.format;
|
||||||
|
|
||||||
|
// 숫자 포맷 적용
|
||||||
|
let displayValue = String(value);
|
||||||
|
if (value !== null && value !== undefined && value !== "" && format) {
|
||||||
|
const numValue = typeof value === 'number' ? value : parseFloat(String(value));
|
||||||
|
if (!isNaN(numValue)) {
|
||||||
|
displayValue = numValue.toLocaleString('ko-KR', {
|
||||||
|
minimumFractionDigits: format.decimalPlaces ?? 0,
|
||||||
|
maximumFractionDigits: format.decimalPlaces ?? 10,
|
||||||
|
useGrouping: format.thousandSeparator ?? false,
|
||||||
|
});
|
||||||
|
if (format.prefix) displayValue = format.prefix + displayValue;
|
||||||
|
if (format.suffix) displayValue = displayValue + format.suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<tr key={key} className="hover:bg-muted">
|
<tr key={key} className="hover:bg-muted">
|
||||||
<td className="text-muted-foreground px-3 py-2 font-medium whitespace-nowrap">
|
<td className="text-muted-foreground px-3 py-2 font-medium whitespace-nowrap">
|
||||||
{getColumnLabel(key)}
|
{label || getColumnLabel(key)}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-foreground px-3 py-2 break-all">{String(value)}</td>
|
<td className="text-foreground px-3 py-2 break-all">{displayValue}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2045,33 +2246,52 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 상세 모드: 단일 객체를 상세 정보로 표시
|
// 상세 모드: 단일 객체를 상세 정보로 표시
|
||||||
(() => {
|
(() => {
|
||||||
const rightColumns = componentConfig.rightPanel?.columns;
|
const rightColumns = componentConfig.rightPanel?.columns;
|
||||||
let displayEntries: [string, any][] = [];
|
let displayEntries: [string, any, string][] = [];
|
||||||
|
|
||||||
if (rightColumns && rightColumns.length > 0) {
|
if (rightColumns && rightColumns.length > 0) {
|
||||||
|
console.log("🔍 [디버깅] 상세 모드 표시 로직:");
|
||||||
|
console.log(" 📋 rightData 전체:", rightData);
|
||||||
|
console.log(" 📋 rightData keys:", Object.keys(rightData));
|
||||||
|
console.log(" ⚙️ 설정된 컬럼:", rightColumns.map((c) => `${c.name} (${c.label})`));
|
||||||
|
|
||||||
// 설정된 컬럼만 표시
|
// 설정된 컬럼만 표시
|
||||||
displayEntries = rightColumns
|
displayEntries = rightColumns
|
||||||
.map((col) => [col.name, rightData[col.name]] as [string, any])
|
.map((col) => {
|
||||||
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name)
|
||||||
|
let value = rightData[col.name];
|
||||||
|
console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`);
|
||||||
|
|
||||||
console.log("🔍 상세 모드 표시 로직:");
|
if (value === undefined && col.name.includes('.')) {
|
||||||
console.log(
|
const columnName = col.name.split('.').pop();
|
||||||
" ✅ 설정된 컬럼 사용:",
|
value = rightData[columnName || ''];
|
||||||
rightColumns.map((c) => c.name),
|
console.log(` → 변환 후 "${columnName}" 접근 = ${value}`);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
return [col.name, value, col.label] as [string, any, string];
|
||||||
|
})
|
||||||
|
.filter(([key, value]) => {
|
||||||
|
const filtered = value === null || value === undefined || value === "";
|
||||||
|
if (filtered) {
|
||||||
|
console.log(` ❌ 필터링됨: "${key}" (값: ${value})`);
|
||||||
|
}
|
||||||
|
return !filtered;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개");
|
||||||
} else {
|
} else {
|
||||||
// 설정 없으면 모든 컬럼 표시
|
// 설정 없으면 모든 컬럼 표시
|
||||||
displayEntries = Object.entries(rightData).filter(
|
displayEntries = Object.entries(rightData)
|
||||||
([_, value]) => value !== null && value !== undefined && value !== "",
|
.filter(([_, value]) => value !== null && value !== undefined && value !== "")
|
||||||
);
|
.map(([key, value]) => [key, value, ""] as [string, any, string]);
|
||||||
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
|
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{displayEntries.map(([key, value]) => (
|
{displayEntries.map(([key, value, label]) => (
|
||||||
<div key={key} className="bg-card rounded-lg border p-4 shadow-sm">
|
<div key={key} className="bg-card rounded-lg border p-4 shadow-sm">
|
||||||
<div className="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">
|
<div className="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">
|
||||||
{getColumnLabel(key)}
|
{label || getColumnLabel(key)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm">{String(value)}</div>
|
<div className="text-sm">{String(value)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,14 @@ import React, { useState, useMemo, useEffect } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
// Accordion 제거 - 단순 섹션으로 변경
|
// Accordion 제거 - 단순 섹션으로 변경
|
||||||
import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react";
|
import { Check, ChevronsUpDown, ArrowRight, Plus, X, ArrowUp, ArrowDown } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SplitPanelLayoutConfig } from "./types";
|
import { SplitPanelLayoutConfig } from "./types";
|
||||||
import { TableInfo, ColumnInfo } from "@/types/screen";
|
import { TableInfo, ColumnInfo } from "@/types/screen";
|
||||||
|
|
@ -24,6 +25,174 @@ interface SplitPanelLayoutConfigPanelProps {
|
||||||
screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용)
|
screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹핑 기준 컬럼 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
const GroupByColumnsSelector: React.FC<{
|
||||||
|
tableName?: string;
|
||||||
|
selectedColumns: string[];
|
||||||
|
onChange: (columns: string[]) => void;
|
||||||
|
}> = ({ tableName, selectedColumns, onChange }) => {
|
||||||
|
const [columns, setColumns] = useState<any[]>([]); // ColumnTypeInfo 타입
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!tableName) {
|
||||||
|
setColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadColumns = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
|
const response = await tableManagementApi.getColumnList(tableName);
|
||||||
|
if (response.success && response.data && response.data.columns) {
|
||||||
|
setColumns(response.data.columns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컬럼 정보 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadColumns();
|
||||||
|
}, [tableName]);
|
||||||
|
|
||||||
|
const toggleColumn = (columnName: string) => {
|
||||||
|
const newSelection = selectedColumns.includes(columnName)
|
||||||
|
? selectedColumns.filter((c) => c !== columnName)
|
||||||
|
: [...selectedColumns, columnName];
|
||||||
|
onChange(newSelection);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!tableName) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-dashed p-3">
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
먼저 우측 패널의 테이블을 선택하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">그룹핑 기준 컬럼</Label>
|
||||||
|
{loading ? (
|
||||||
|
<div className="rounded-md border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground text-center">로딩 중...</p>
|
||||||
|
</div>
|
||||||
|
) : columns.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-dashed p-3">
|
||||||
|
<p className="text-xs text-muted-foreground text-center">컬럼을 찾을 수 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 rounded-md border p-3 max-h-[200px] overflow-y-auto">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<div key={col.columnName} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`groupby-${col.columnName}`}
|
||||||
|
checked={selectedColumns.includes(col.columnName)}
|
||||||
|
onCheckedChange={() => toggleColumn(col.columnName)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`groupby-${col.columnName}`}
|
||||||
|
className="text-xs cursor-pointer flex-1"
|
||||||
|
>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
<span className="text-muted-foreground ml-1">({col.columnName})</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
선택된 컬럼: {selectedColumns.length > 0 ? selectedColumns.join(", ") : "없음"}
|
||||||
|
<br />
|
||||||
|
같은 값을 가진 모든 레코드를 함께 불러옵니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 선택 Combobox 컴포넌트
|
||||||
|
*/
|
||||||
|
const ScreenSelector: React.FC<{
|
||||||
|
value?: number;
|
||||||
|
onChange: (screenId?: number) => void;
|
||||||
|
}> = ({ value, onChange }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [screens, setScreens] = useState<Array<{ screenId: number; screenName: string; screenCode: string }>>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadScreens = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { screenApi } = await import("@/lib/api/screen");
|
||||||
|
const response = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||||
|
setScreens(response.data.map((s) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode })));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("화면 목록 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadScreens();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedScreen = screens.find((s) => s.screenId === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "로딩 중..." : selectedScreen ? selectedScreen.screenName : "화면 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0 w-[400px]" align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-xs py-6 text-center">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup className="max-h-[300px] overflow-auto">
|
||||||
|
{screens.map((screen) => (
|
||||||
|
<CommandItem
|
||||||
|
key={screen.screenId}
|
||||||
|
value={`${screen.screenName.toLowerCase()} ${screen.screenCode.toLowerCase()} ${screen.screenId}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(screen.screenId === value ? undefined : screen.screenId);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{screen.screenName}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">{screen.screenCode}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SplitPanelLayout 설정 패널
|
* SplitPanelLayout 설정 패널
|
||||||
*/
|
*/
|
||||||
|
|
@ -39,6 +208,9 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
const [loadedTableColumns, setLoadedTableColumns] = useState<Record<string, ColumnInfo[]>>({});
|
const [loadedTableColumns, setLoadedTableColumns] = useState<Record<string, ColumnInfo[]>>({});
|
||||||
const [loadingColumns, setLoadingColumns] = useState<Record<string, boolean>>({});
|
const [loadingColumns, setLoadingColumns] = useState<Record<string, boolean>>({});
|
||||||
const [allTables, setAllTables] = useState<any[]>([]); // 조인 모드용 전체 테이블 목록
|
const [allTables, setAllTables] = useState<any[]>([]); // 조인 모드용 전체 테이블 목록
|
||||||
|
// 엔티티 참조 테이블 컬럼
|
||||||
|
type EntityRefTable = { tableName: string; columns: ColumnInfo[] };
|
||||||
|
const [entityReferenceTables, setEntityReferenceTables] = useState<Record<string, EntityRefTable[]>>({});
|
||||||
|
|
||||||
// 관계 타입
|
// 관계 타입
|
||||||
const relationshipType = config.rightPanel?.relation?.type || "detail";
|
const relationshipType = config.rightPanel?.relation?.type || "detail";
|
||||||
|
|
@ -158,10 +330,16 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
isPrimaryKey: col.isPrimaryKey || false, // PK 여부 추가
|
isPrimaryKey: col.isPrimaryKey || false, // PK 여부 추가
|
||||||
codeCategory: col.codeCategory || col.code_category,
|
codeCategory: col.codeCategory || col.code_category,
|
||||||
codeValue: col.codeValue || col.code_value,
|
codeValue: col.codeValue || col.code_value,
|
||||||
|
referenceTable: col.referenceTable || col.reference_table, // 🆕 참조 테이블
|
||||||
|
referenceColumn: col.referenceColumn || col.reference_column, // 🆕 참조 컬럼
|
||||||
|
displayColumn: col.displayColumn || col.display_column, // 🆕 표시 컬럼
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(`✅ 테이블 ${tableName} 컬럼 ${columns.length}개 로드됨:`, columns);
|
console.log(`✅ 테이블 ${tableName} 컬럼 ${columns.length}개 로드됨:`, columns);
|
||||||
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: columns }));
|
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: columns }));
|
||||||
|
|
||||||
|
// 🆕 엔티티 타입 컬럼의 참조 테이블 컬럼도 로드
|
||||||
|
await loadEntityReferenceColumns(tableName, columns);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error);
|
||||||
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: [] }));
|
setLoadedTableColumns((prev) => ({ ...prev, [tableName]: [] }));
|
||||||
|
|
@ -170,6 +348,59 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 엔티티 참조 테이블의 컬럼 로드
|
||||||
|
const loadEntityReferenceColumns = async (sourceTableName: string, columns: ColumnInfo[]) => {
|
||||||
|
const entityColumns = columns.filter(
|
||||||
|
col => (col.input_type === 'entity' || col.webType === 'entity') && col.referenceTable
|
||||||
|
);
|
||||||
|
|
||||||
|
if (entityColumns.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔗 테이블 ${sourceTableName}의 엔티티 참조 ${entityColumns.length}개 발견:`,
|
||||||
|
entityColumns.map(c => `${c.columnName} -> ${c.referenceTable}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const referenceTableData: Array<{tableName: string, columns: ColumnInfo[]}> = [];
|
||||||
|
|
||||||
|
// 각 참조 테이블의 컬럼 로드
|
||||||
|
for (const entityCol of entityColumns) {
|
||||||
|
const refTableName = entityCol.referenceTable!;
|
||||||
|
|
||||||
|
// 이미 로드했으면 스킵
|
||||||
|
if (referenceTableData.some(t => t.tableName === refTableName)) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refColumnsResponse = await tableTypeApi.getColumns(refTableName);
|
||||||
|
const refColumns: ColumnInfo[] = (refColumnsResponse || []).map((col: any) => ({
|
||||||
|
tableName: col.tableName || refTableName,
|
||||||
|
columnName: col.columnName || col.column_name,
|
||||||
|
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||||
|
dataType: col.dataType || col.data_type || col.dbType,
|
||||||
|
input_type: col.inputType || col.input_type,
|
||||||
|
}));
|
||||||
|
|
||||||
|
referenceTableData.push({ tableName: refTableName, columns: refColumns });
|
||||||
|
console.log(` ✅ 참조 테이블 ${refTableName} 컬럼 ${refColumns.length}개 로드됨`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ❌ 참조 테이블 ${refTableName} 컬럼 로드 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 참조 테이블 정보 저장
|
||||||
|
setEntityReferenceTables(prev => ({
|
||||||
|
...prev,
|
||||||
|
[sourceTableName]: referenceTableData
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`✅ [엔티티 참조] ${sourceTableName}의 참조 테이블 저장 완료:`, {
|
||||||
|
sourceTableName,
|
||||||
|
referenceTableCount: referenceTableData.length,
|
||||||
|
referenceTables: referenceTableData.map(t => `${t.tableName}(${t.columns.length}개)`),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 좌측/우측 테이블이 변경되면 해당 테이블의 컬럼 로드
|
// 좌측/우측 테이블이 변경되면 해당 테이블의 컬럼 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.leftPanel?.tableName) {
|
if (config.leftPanel?.tableName) {
|
||||||
|
|
@ -253,17 +484,21 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
onChange(newConfig);
|
onChange(newConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 좌측 테이블명
|
||||||
|
const leftTableName = config.leftPanel?.tableName || screenTableName || "";
|
||||||
|
|
||||||
// 좌측 테이블 컬럼 (로드된 컬럼 사용)
|
// 좌측 테이블 컬럼 (로드된 컬럼 사용)
|
||||||
const leftTableColumns = useMemo(() => {
|
const leftTableColumns = useMemo(() => {
|
||||||
const tableName = config.leftPanel?.tableName || screenTableName;
|
return leftTableName ? loadedTableColumns[leftTableName] || [] : [];
|
||||||
return tableName ? loadedTableColumns[tableName] || [] : [];
|
}, [loadedTableColumns, leftTableName]);
|
||||||
}, [loadedTableColumns, config.leftPanel?.tableName, screenTableName]);
|
|
||||||
|
// 우측 테이블명
|
||||||
|
const rightTableName = config.rightPanel?.tableName || "";
|
||||||
|
|
||||||
// 우측 테이블 컬럼 (로드된 컬럼 사용)
|
// 우측 테이블 컬럼 (로드된 컬럼 사용)
|
||||||
const rightTableColumns = useMemo(() => {
|
const rightTableColumns = useMemo(() => {
|
||||||
const tableName = config.rightPanel?.tableName;
|
return rightTableName ? loadedTableColumns[rightTableName] || [] : [];
|
||||||
return tableName ? loadedTableColumns[tableName] || [] : [];
|
}, [loadedTableColumns, rightTableName]);
|
||||||
}, [loadedTableColumns, config.rightPanel?.tableName]);
|
|
||||||
|
|
||||||
// 테이블 데이터 로딩 상태 확인
|
// 테이블 데이터 로딩 상태 확인
|
||||||
if (!tables || tables.length === 0) {
|
if (!tables || tables.length === 0) {
|
||||||
|
|
@ -737,6 +972,41 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
className="space-y-2 rounded-md border bg-white p-2"
|
className="space-y-2 rounded-md border bg-white p-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 순서 변경 버튼 */}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (index === 0) return;
|
||||||
|
const newColumns = [...(config.leftPanel?.columns || [])];
|
||||||
|
[newColumns[index - 1], newColumns[index]] = [newColumns[index], newColumns[index - 1]];
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="h-4 w-6 p-0"
|
||||||
|
title="위로 이동"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const columns = config.leftPanel?.columns || [];
|
||||||
|
if (index === columns.length - 1) return;
|
||||||
|
const newColumns = [...columns];
|
||||||
|
[newColumns[index], newColumns[index + 1]] = [newColumns[index + 1], newColumns[index]];
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
disabled={index === (config.leftPanel?.columns || []).length - 1}
|
||||||
|
className="h-4 w-6 p-0"
|
||||||
|
title="아래로 이동"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
@ -745,7 +1015,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className="h-8 w-full justify-between text-xs"
|
className="h-8 w-full justify-between text-xs"
|
||||||
>
|
>
|
||||||
{col.name || "컬럼 선택"}
|
{col.label || col.name || "컬럼 선택"}
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -753,7 +1023,9 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
<div className="max-h-[300px] overflow-auto">
|
||||||
|
{/* 기본 테이블 컬럼 */}
|
||||||
|
<CommandGroup heading={leftTableName ? `📋 ${leftTableName} 컬럼` : "📋 기본 컬럼"}>
|
||||||
{leftTableColumns.map((column) => (
|
{leftTableColumns.map((column) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
|
|
@ -766,6 +1038,8 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
label: column.columnLabel || value,
|
label: column.columnLabel || value,
|
||||||
};
|
};
|
||||||
updateLeftPanel({ columns: newColumns });
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
// Popover 닫기
|
||||||
|
document.body.click();
|
||||||
}}
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
|
|
@ -782,6 +1056,45 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
|
{/* 🆕 엔티티 참조 테이블 컬럼 */}
|
||||||
|
{leftTableName && entityReferenceTables[leftTableName]?.map((refTable) => (
|
||||||
|
<CommandGroup key={refTable.tableName} heading={`🔗 ${refTable.tableName} (엔티티)`}>
|
||||||
|
{refTable.columns.map((column) => {
|
||||||
|
const fullColumnName = `${refTable.tableName}.${column.columnName}`;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={fullColumnName}
|
||||||
|
value={fullColumnName}
|
||||||
|
onSelect={(value) => {
|
||||||
|
const newColumns = [...(config.leftPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
name: value,
|
||||||
|
label: column.columnLabel || column.columnName,
|
||||||
|
};
|
||||||
|
updateLeftPanel({ columns: newColumns });
|
||||||
|
// Popover 닫기
|
||||||
|
document.body.click();
|
||||||
|
}}
|
||||||
|
className="text-xs pl-6"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
col.name === fullColumnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{column.columnLabel || column.columnName}
|
||||||
|
<span className="ml-2 text-[10px] text-gray-500">
|
||||||
|
({column.columnName})
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
@ -1133,6 +1446,44 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 요약 표시 설정 (LIST 모드에서만) */}
|
||||||
|
{config.rightPanel?.displayMode === "list" && (
|
||||||
|
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||||
|
<Label className="text-sm font-semibold">요약 표시 설정</Label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs">표시할 컬럼 개수</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={config.rightPanel?.summaryColumnCount ?? 3}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = parseInt(e.target.value) || 3;
|
||||||
|
updateRightPanel({ summaryColumnCount: value });
|
||||||
|
}}
|
||||||
|
className="bg-white"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
접기 전에 표시할 컬럼 개수 (기본: 3개)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between space-x-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-xs">라벨 표시</Label>
|
||||||
|
<p className="text-xs text-gray-500">컬럼명 표시 여부</p>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
checked={config.rightPanel?.summaryShowLabel ?? true}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
updateRightPanel({ summaryShowLabel: checked as boolean });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 컬럼 매핑 - 조인 모드에서만 표시 */}
|
{/* 컬럼 매핑 - 조인 모드에서만 표시 */}
|
||||||
{relationshipType !== "detail" && (
|
{relationshipType !== "detail" && (
|
||||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||||
|
|
@ -1304,6 +1655,41 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
className="space-y-2 rounded-md border bg-white p-2"
|
className="space-y-2 rounded-md border bg-white p-2"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 순서 변경 버튼 */}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (index === 0) return;
|
||||||
|
const newColumns = [...(config.rightPanel?.columns || [])];
|
||||||
|
[newColumns[index - 1], newColumns[index]] = [newColumns[index], newColumns[index - 1]];
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
disabled={index === 0}
|
||||||
|
className="h-4 w-6 p-0"
|
||||||
|
title="위로 이동"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const columns = config.rightPanel?.columns || [];
|
||||||
|
if (index === columns.length - 1) return;
|
||||||
|
const newColumns = [...columns];
|
||||||
|
[newColumns[index], newColumns[index + 1]] = [newColumns[index + 1], newColumns[index]];
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
disabled={index === (config.rightPanel?.columns || []).length - 1}
|
||||||
|
className="h-4 w-6 p-0"
|
||||||
|
title="아래로 이동"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
|
|
@ -1312,7 +1698,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className="h-8 w-full justify-between text-xs"
|
className="h-8 w-full justify-between text-xs"
|
||||||
>
|
>
|
||||||
{col.name || "컬럼 선택"}
|
{col.label || col.name || "컬럼 선택"}
|
||||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
@ -1320,7 +1706,9 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
<div className="max-h-[300px] overflow-auto">
|
||||||
|
{/* 기본 테이블 컬럼 */}
|
||||||
|
<CommandGroup heading={rightTableName ? `📋 ${rightTableName} 컬럼` : "📋 기본 컬럼"}>
|
||||||
{rightTableColumns.map((column) => (
|
{rightTableColumns.map((column) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={column.columnName}
|
key={column.columnName}
|
||||||
|
|
@ -1333,6 +1721,8 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
label: column.columnLabel || value,
|
label: column.columnLabel || value,
|
||||||
};
|
};
|
||||||
updateRightPanel({ columns: newColumns });
|
updateRightPanel({ columns: newColumns });
|
||||||
|
// Popover 닫기
|
||||||
|
document.body.click();
|
||||||
}}
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
|
|
@ -1349,6 +1739,45 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
|
{/* 🆕 엔티티 참조 테이블 컬럼 */}
|
||||||
|
{rightTableName && entityReferenceTables[rightTableName]?.map((refTable) => (
|
||||||
|
<CommandGroup key={refTable.tableName} heading={`🔗 ${refTable.tableName} (엔티티)`}>
|
||||||
|
{refTable.columns.map((column) => {
|
||||||
|
const fullColumnName = `${refTable.tableName}.${column.columnName}`;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={fullColumnName}
|
||||||
|
value={fullColumnName}
|
||||||
|
onSelect={(value) => {
|
||||||
|
const newColumns = [...(config.rightPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
name: value,
|
||||||
|
label: column.columnLabel || column.columnName,
|
||||||
|
};
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
// Popover 닫기
|
||||||
|
document.body.click();
|
||||||
|
}}
|
||||||
|
className="text-xs pl-6"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
col.name === fullColumnName ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{column.columnLabel || column.columnName}
|
||||||
|
<span className="ml-2 text-[10px] text-gray-500">
|
||||||
|
({column.columnName})
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
@ -1431,6 +1860,150 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* LIST 모드: 볼드 설정 */}
|
||||||
|
{!isTableMode && (
|
||||||
|
<div className="space-y-2 border-t pt-2">
|
||||||
|
<Label className="text-[10px] text-gray-600">요약 표시 옵션</Label>
|
||||||
|
<label className="flex items-center gap-1.5 text-[10px] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={col.bold ?? false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(config.rightPanel?.columns || [])];
|
||||||
|
newColumns[index] = { ...newColumns[index], bold: e.target.checked };
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
값 굵게 표시
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 🆕 숫자 타입 포맷 설정 */}
|
||||||
|
{(() => {
|
||||||
|
// 컬럼 타입 확인
|
||||||
|
const column = rightTableColumns.find(c => c.columnName === col.name);
|
||||||
|
const isNumeric = column && ['numeric', 'decimal', 'integer', 'bigint', 'double precision', 'real'].includes(column.dataType?.toLowerCase() || '');
|
||||||
|
|
||||||
|
if (!isNumeric) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 border-t pt-2">
|
||||||
|
<Label className="text-[10px] text-gray-600">숫자 포맷 설정</Label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* 천 단위 구분자 */}
|
||||||
|
<label className="flex items-center gap-1.5 text-[10px] cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={col.format?.thousandSeparator ?? false}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(config.rightPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
format: {
|
||||||
|
...newColumns[index].format,
|
||||||
|
thousandSeparator: e.target.checked,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
천 단위 구분자 (,)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* 소수점 자릿수 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">소수점</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
placeholder="0"
|
||||||
|
value={col.format?.decimalPlaces ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(config.rightPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
format: {
|
||||||
|
...newColumns[index].format,
|
||||||
|
decimalPlaces: e.target.value ? parseInt(e.target.value) : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{/* 접두사 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">접두사</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="₩, $, 등"
|
||||||
|
value={col.format?.prefix ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(config.rightPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
format: {
|
||||||
|
...newColumns[index].format,
|
||||||
|
prefix: e.target.value || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 접미사 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] text-gray-600">접미사</Label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="원, 개, 등"
|
||||||
|
value={col.format?.suffix ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColumns = [...(config.rightPanel?.columns || [])];
|
||||||
|
newColumns[index] = {
|
||||||
|
...newColumns[index],
|
||||||
|
format: {
|
||||||
|
...newColumns[index].format,
|
||||||
|
suffix: e.target.value || undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 미리보기 */}
|
||||||
|
{(col.format?.thousandSeparator || col.format?.prefix || col.format?.suffix || col.format?.decimalPlaces !== undefined) && (
|
||||||
|
<div className="rounded bg-gray-100 p-2">
|
||||||
|
<p className="text-[10px] text-gray-600 mb-1">미리보기:</p>
|
||||||
|
<p className="text-xs font-medium">
|
||||||
|
{col.format?.prefix || ''}
|
||||||
|
{(1234567.89).toLocaleString('ko-KR', {
|
||||||
|
minimumFractionDigits: col.format?.decimalPlaces ?? 0,
|
||||||
|
maximumFractionDigits: col.format?.decimalPlaces ?? 10,
|
||||||
|
useGrouping: col.format?.thousandSeparator ?? false,
|
||||||
|
})}
|
||||||
|
{col.format?.suffix || ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|
@ -1700,6 +2273,272 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 패널 중복 제거 */}
|
||||||
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">중복 데이터 제거</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
같은 값을 가진 데이터를 하나로 통합하여 표시
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.rightPanel?.deduplication?.enabled ?? false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
updateRightPanel({
|
||||||
|
deduplication: {
|
||||||
|
enabled: true,
|
||||||
|
groupByColumn: "",
|
||||||
|
keepStrategy: "latest",
|
||||||
|
sortColumn: "start_date",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateRightPanel({ deduplication: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.rightPanel?.deduplication?.enabled && (
|
||||||
|
<div className="space-y-3 pl-4 border-l-2">
|
||||||
|
{/* 중복 제거 기준 컬럼 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">중복 제거 기준 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.deduplication?.groupByColumn || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateRightPanel({
|
||||||
|
deduplication: { ...config.rightPanel?.deduplication!, groupByColumn: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="기준 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{rightTableColumns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
이 컬럼의 값이 같은 데이터들 중 하나만 표시합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 유지 전략 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">유지 전략</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.deduplication?.keepStrategy || "latest"}
|
||||||
|
onValueChange={(value: any) =>
|
||||||
|
updateRightPanel({
|
||||||
|
deduplication: { ...config.rightPanel?.deduplication!, keepStrategy: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="latest">최신 데이터 (가장 최근)</SelectItem>
|
||||||
|
<SelectItem value="earliest">최초 데이터 (가장 오래된)</SelectItem>
|
||||||
|
<SelectItem value="current_date">현재 유효한 데이터 (날짜 기준)</SelectItem>
|
||||||
|
<SelectItem value="base_price">기준단가로 설정된 데이터</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
{config.rightPanel?.deduplication?.keepStrategy === "latest" && "가장 최근에 추가된 데이터를 표시합니다"}
|
||||||
|
{config.rightPanel?.deduplication?.keepStrategy === "earliest" && "가장 먼저 추가된 데이터를 표시합니다"}
|
||||||
|
{config.rightPanel?.deduplication?.keepStrategy === "current_date" && "오늘 날짜 기준으로 유효한 기간의 데이터를 표시합니다"}
|
||||||
|
{config.rightPanel?.deduplication?.keepStrategy === "base_price" && "기준단가(base_price)로 체크된 데이터를 표시합니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정렬 기준 컬럼 (latest/earliest만) */}
|
||||||
|
{(config.rightPanel?.deduplication?.keepStrategy === "latest" ||
|
||||||
|
config.rightPanel?.deduplication?.keepStrategy === "earliest") && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">정렬 기준 컬럼</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.deduplication?.sortColumn || ""}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateRightPanel({
|
||||||
|
deduplication: { ...config.rightPanel?.deduplication!, sortColumn: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="정렬 기준 컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{rightTableColumns
|
||||||
|
.filter(col => col.dataType === 'date' || col.dataType === 'timestamp' || col.columnName.includes('date'))
|
||||||
|
.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName}>
|
||||||
|
{col.columnLabel || col.columnName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
이 컬럼의 값으로 최신/최초를 판단합니다 (보통 날짜 컬럼)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 우측 패널 수정 버튼 설정 */}
|
||||||
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold">수정 버튼 설정</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
우측 리스트의 수정 버튼 동작 방식 설정
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={config.rightPanel?.editButton?.enabled ?? true}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
updateRightPanel({
|
||||||
|
editButton: {
|
||||||
|
enabled: checked,
|
||||||
|
mode: config.rightPanel?.editButton?.mode || "auto",
|
||||||
|
buttonLabel: config.rightPanel?.editButton?.buttonLabel,
|
||||||
|
buttonVariant: config.rightPanel?.editButton?.buttonVariant,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(config.rightPanel?.editButton?.enabled ?? true) && (
|
||||||
|
<div className="space-y-3 pl-4 border-l-2">
|
||||||
|
{/* 수정 모드 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">수정 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.editButton?.mode || "auto"}
|
||||||
|
onValueChange={(value: "auto" | "modal") =>
|
||||||
|
updateRightPanel({
|
||||||
|
editButton: {
|
||||||
|
...config.rightPanel?.editButton,
|
||||||
|
mode: value,
|
||||||
|
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="auto">자동 편집 (인라인)</SelectItem>
|
||||||
|
<SelectItem value="modal">커스텀 모달 화면</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
{config.rightPanel?.editButton?.mode === "modal"
|
||||||
|
? "지정한 화면을 모달로 열어 데이터를 수정합니다"
|
||||||
|
: "현재 위치에서 직접 데이터를 수정합니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 화면 선택 (modal 모드일 때만) */}
|
||||||
|
{config.rightPanel?.editButton?.mode === "modal" && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">모달 화면</Label>
|
||||||
|
<ScreenSelector
|
||||||
|
value={config.rightPanel?.editButton?.modalScreenId}
|
||||||
|
onChange={(screenId) =>
|
||||||
|
updateRightPanel({
|
||||||
|
editButton: {
|
||||||
|
...config.rightPanel?.editButton!,
|
||||||
|
modalScreenId: screenId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">
|
||||||
|
수정 버튼 클릭 시 열릴 화면을 선택하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 버튼 라벨 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버튼 라벨</Label>
|
||||||
|
<Input
|
||||||
|
value={config.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateRightPanel({
|
||||||
|
editButton: {
|
||||||
|
...config.rightPanel?.editButton!,
|
||||||
|
buttonLabel: e.target.value,
|
||||||
|
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||||
|
mode: config.rightPanel?.editButton?.mode || "auto",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
placeholder="수정"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 스타일 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">버튼 스타일</Label>
|
||||||
|
<Select
|
||||||
|
value={config.rightPanel?.editButton?.buttonVariant || "outline"}
|
||||||
|
onValueChange={(value: any) =>
|
||||||
|
updateRightPanel({
|
||||||
|
editButton: {
|
||||||
|
...config.rightPanel?.editButton!,
|
||||||
|
buttonVariant: value,
|
||||||
|
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||||
|
mode: config.rightPanel?.editButton?.mode || "auto",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="default">기본 (파란색)</SelectItem>
|
||||||
|
<SelectItem value="outline">외곽선</SelectItem>
|
||||||
|
<SelectItem value="ghost">투명</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 그룹핑 기준 컬럼 설정 (modal 모드일 때만 표시) */}
|
||||||
|
{config.rightPanel?.editButton?.mode === "modal" && (
|
||||||
|
<GroupByColumnsSelector
|
||||||
|
tableName={config.rightPanel?.tableName}
|
||||||
|
selectedColumns={config.rightPanel?.editButton?.groupByColumns || []}
|
||||||
|
onChange={(columns) => {
|
||||||
|
updateRightPanel({
|
||||||
|
editButton: {
|
||||||
|
...config.rightPanel?.editButton!,
|
||||||
|
groupByColumns: columns,
|
||||||
|
enabled: config.rightPanel?.editButton?.enabled ?? true,
|
||||||
|
mode: config.rightPanel?.editButton?.mode || "auto",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 레이아웃 설정 */}
|
{/* 레이아웃 설정 */}
|
||||||
<div className="space-y-4 border-t pt-4 mt-4">
|
<div className="space-y-4 border-t pt-4 mt-4">
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,14 @@ export interface SplitPanelLayoutConfig {
|
||||||
width?: number;
|
width?: number;
|
||||||
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
||||||
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
|
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
|
||||||
|
format?: {
|
||||||
|
type?: "number" | "currency" | "date" | "text"; // 포맷 타입
|
||||||
|
thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency")
|
||||||
|
decimalPlaces?: number; // 소수점 자릿수
|
||||||
|
prefix?: string; // 접두사 (예: "₩", "$")
|
||||||
|
suffix?: string; // 접미사 (예: "원", "개")
|
||||||
|
dateFormat?: string; // 날짜 포맷 (type: "date")
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
// 추가 모달에서 입력받을 컬럼 설정
|
// 추가 모달에서 입력받을 컬럼 설정
|
||||||
addModalColumns?: Array<{
|
addModalColumns?: Array<{
|
||||||
|
|
@ -69,12 +77,23 @@ export interface SplitPanelLayoutConfig {
|
||||||
showAdd?: boolean;
|
showAdd?: boolean;
|
||||||
showEdit?: boolean; // 수정 버튼
|
showEdit?: boolean; // 수정 버튼
|
||||||
showDelete?: boolean; // 삭제 버튼
|
showDelete?: boolean; // 삭제 버튼
|
||||||
|
summaryColumnCount?: number; // 요약에서 표시할 컬럼 개수 (기본: 3)
|
||||||
|
summaryShowLabel?: boolean; // 요약에서 라벨 표시 여부 (기본: true)
|
||||||
columns?: Array<{
|
columns?: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
||||||
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
|
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
|
||||||
|
bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드)
|
||||||
|
format?: {
|
||||||
|
type?: "number" | "currency" | "date" | "text"; // 포맷 타입
|
||||||
|
thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency")
|
||||||
|
decimalPlaces?: number; // 소수점 자릿수
|
||||||
|
prefix?: string; // 접두사 (예: "₩", "$")
|
||||||
|
suffix?: string; // 접미사 (예: "원", "개")
|
||||||
|
dateFormat?: string; // 날짜 포맷 (type: "date")
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
// 추가 모달에서 입력받을 컬럼 설정
|
// 추가 모달에서 입력받을 컬럼 설정
|
||||||
addModalColumns?: Array<{
|
addModalColumns?: Array<{
|
||||||
|
|
@ -113,6 +132,24 @@ export interface SplitPanelLayoutConfig {
|
||||||
|
|
||||||
// 🆕 컬럼 값 기반 데이터 필터링
|
// 🆕 컬럼 값 기반 데이터 필터링
|
||||||
dataFilter?: DataFilterConfig;
|
dataFilter?: DataFilterConfig;
|
||||||
|
|
||||||
|
// 🆕 중복 제거 설정
|
||||||
|
deduplication?: {
|
||||||
|
enabled: boolean; // 중복 제거 활성화
|
||||||
|
groupByColumn: string; // 중복 제거 기준 컬럼 (예: "item_id")
|
||||||
|
keepStrategy: "latest" | "earliest" | "base_price" | "current_date"; // 어떤 행을 유지할지
|
||||||
|
sortColumn?: string; // keepStrategy가 latest/earliest일 때 정렬 기준 컬럼
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🆕 수정 버튼 설정
|
||||||
|
editButton?: {
|
||||||
|
enabled: boolean; // 수정 버튼 표시 여부 (기본: true)
|
||||||
|
mode: "auto" | "modal"; // auto: 자동 편집 (인라인), modal: 커스텀 모달
|
||||||
|
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
|
||||||
|
buttonLabel?: string; // 버튼 라벨 (기본: "수정")
|
||||||
|
buttonVariant?: "default" | "outline" | "ghost"; // 버튼 스타일 (기본: "outline")
|
||||||
|
groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"])
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 레이아웃 설정
|
// 레이아웃 설정
|
||||||
|
|
|
||||||
|
|
@ -563,20 +563,6 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`🔍 [handleBatchSave] 부모 데이터:`, {
|
|
||||||
hasParentData: Object.keys(parentData).length > 0,
|
|
||||||
parentDataKeys: Object.keys(parentData),
|
|
||||||
parentDataFull: parentData,
|
|
||||||
selectedRowsData,
|
|
||||||
originalData,
|
|
||||||
modalDataStoreKeys: Object.keys(modalDataStore),
|
|
||||||
modalDataStoreDetails: Object.fromEntries(
|
|
||||||
Object.entries(modalDataStore).map(([key, data]) => [
|
|
||||||
key,
|
|
||||||
{ count: Array.isArray(data) ? data.length : 1, hasData: !!data }
|
|
||||||
])
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리
|
// 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리
|
||||||
for (const key of selectedItemsKeys) {
|
for (const key of selectedItemsKeys) {
|
||||||
|
|
@ -587,24 +573,13 @@ export class ButtonActionExecutor {
|
||||||
fieldGroups: Record<string, Array<{ id: string; [key: string]: any }>>;
|
fieldGroups: Record<string, Array<{ id: string; [key: string]: any }>>;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
console.log(`📦 [handleBatchSave] ${key} 처리 중 (${items.length}개 품목)`);
|
|
||||||
|
|
||||||
// 🆕 이 컴포넌트의 parentDataMapping 설정 가져오기
|
// 🆕 이 컴포넌트의 parentDataMapping 설정 가져오기
|
||||||
const componentConfig = context.componentConfigs?.[key];
|
const componentConfig = context.componentConfigs?.[key];
|
||||||
const parentDataMapping = componentConfig?.parentDataMapping || [];
|
const parentDataMapping = componentConfig?.parentDataMapping || [];
|
||||||
|
|
||||||
console.log(`🔍 [handleBatchSave] parentDataMapping 설정:`, {
|
|
||||||
componentId: key,
|
|
||||||
hasComponentConfig: !!componentConfig,
|
|
||||||
hasMapping: parentDataMapping.length > 0,
|
|
||||||
mappings: parentDataMapping,
|
|
||||||
sourceTable: componentConfig?.sourceTable,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🆕 각 품목의 그룹 간 조합(카티션 곱) 생성
|
// 🆕 각 품목의 그룹 간 조합(카티션 곱) 생성
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const groupKeys = Object.keys(item.fieldGroups);
|
const groupKeys = Object.keys(item.fieldGroups);
|
||||||
console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${groupKeys.length}개 그룹)`);
|
|
||||||
|
|
||||||
// 각 그룹의 항목 배열 가져오기
|
// 각 그룹의 항목 배열 가져오기
|
||||||
const groupArrays = groupKeys.map(groupKey => ({
|
const groupArrays = groupKeys.map(groupKey => ({
|
||||||
|
|
@ -612,10 +587,6 @@ export class ButtonActionExecutor {
|
||||||
entries: item.fieldGroups[groupKey] || []
|
entries: item.fieldGroups[groupKey] || []
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(`📊 [handleBatchSave] 그룹별 항목 수:`,
|
|
||||||
groupArrays.map(g => `${g.groupKey}: ${g.entries.length}개`).join(", ")
|
|
||||||
);
|
|
||||||
|
|
||||||
// 카티션 곱 계산 함수
|
// 카티션 곱 계산 함수
|
||||||
const cartesianProduct = (arrays: any[][]): any[][] => {
|
const cartesianProduct = (arrays: any[][]): any[][] => {
|
||||||
if (arrays.length === 0) return [[]];
|
if (arrays.length === 0) return [[]];
|
||||||
|
|
@ -633,8 +604,6 @@ export class ButtonActionExecutor {
|
||||||
const entryArrays = groupArrays.map(g => g.entries);
|
const entryArrays = groupArrays.map(g => g.entries);
|
||||||
const combinations = cartesianProduct(entryArrays);
|
const combinations = cartesianProduct(entryArrays);
|
||||||
|
|
||||||
console.log(`🔢 [handleBatchSave] 생성된 조합 수: ${combinations.length}개`);
|
|
||||||
|
|
||||||
// 각 조합을 개별 레코드로 저장
|
// 각 조합을 개별 레코드로 저장
|
||||||
for (let i = 0; i < combinations.length; i++) {
|
for (let i = 0; i < combinations.length; i++) {
|
||||||
const combination = combinations[i];
|
const combination = combinations[i];
|
||||||
|
|
@ -644,75 +613,38 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
// 1. parentDataMapping 설정이 있으면 적용
|
// 1. parentDataMapping 설정이 있으면 적용
|
||||||
if (parentDataMapping.length > 0) {
|
if (parentDataMapping.length > 0) {
|
||||||
console.log(` 🔗 [parentDataMapping] 매핑 시작 (${parentDataMapping.length}개 매핑)`);
|
|
||||||
|
|
||||||
for (const mapping of parentDataMapping) {
|
for (const mapping of parentDataMapping) {
|
||||||
// sourceTable을 기준으로 데이터 소스 결정
|
|
||||||
let sourceData: any;
|
let sourceData: any;
|
||||||
|
|
||||||
// 🔍 sourceTable과 실제 데이터 테이블 비교
|
|
||||||
// - modalDataStore는 모든 이전 화면의 누적 데이터 (예: 거래처, 품목)
|
|
||||||
// - item.originalData는 현재 선택된 항목 데이터 (예: 품목 테이블)
|
|
||||||
|
|
||||||
// 원본 데이터 테이블명 확인 (sourceTable이 config에 명시되어 있음)
|
|
||||||
const sourceTableName = mapping.sourceTable;
|
const sourceTableName = mapping.sourceTable;
|
||||||
|
|
||||||
// 현재 선택된 항목의 테이블 = config.sourceTable
|
|
||||||
const selectedItemTable = componentConfig?.sourceTable;
|
const selectedItemTable = componentConfig?.sourceTable;
|
||||||
|
|
||||||
if (sourceTableName === selectedItemTable) {
|
if (sourceTableName === selectedItemTable) {
|
||||||
// 선택된 항목 데이터 사용
|
|
||||||
sourceData = item.originalData;
|
sourceData = item.originalData;
|
||||||
console.log(` 📦 소스: 선택된 항목 데이터 (${sourceTableName})`);
|
|
||||||
} else {
|
} else {
|
||||||
// 🆕 modalDataStore에서 해당 테이블 데이터 가져오기
|
|
||||||
const tableData = modalDataStore[sourceTableName];
|
const tableData = modalDataStore[sourceTableName];
|
||||||
if (tableData && Array.isArray(tableData) && tableData.length > 0) {
|
if (tableData && Array.isArray(tableData) && tableData.length > 0) {
|
||||||
sourceData = tableData[0]; // 첫 번째 항목 사용
|
sourceData = tableData[0];
|
||||||
console.log(` 🌐 소스: modalDataStore (${sourceTableName})`);
|
|
||||||
} else {
|
} else {
|
||||||
// 폴백: 이전 화면 데이터 사용
|
|
||||||
sourceData = parentData;
|
sourceData = parentData;
|
||||||
console.log(` 👤 소스: 이전 화면 데이터 (${sourceTableName}) [폴백]`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceValue = sourceData[mapping.sourceField];
|
const sourceValue = sourceData[mapping.sourceField];
|
||||||
|
|
||||||
console.log(` 🔍 데이터 소스 상세:`, {
|
|
||||||
sourceTable: sourceTableName,
|
|
||||||
selectedItemTable,
|
|
||||||
isFromSelectedItem: sourceTableName === selectedItemTable,
|
|
||||||
sourceDataKeys: Object.keys(sourceData),
|
|
||||||
sourceField: mapping.sourceField,
|
|
||||||
sourceValue,
|
|
||||||
targetField: mapping.targetField
|
|
||||||
});
|
|
||||||
|
|
||||||
if (sourceValue !== undefined && sourceValue !== null) {
|
if (sourceValue !== undefined && sourceValue !== null) {
|
||||||
mappedData[mapping.targetField] = sourceValue;
|
mappedData[mapping.targetField] = sourceValue;
|
||||||
console.log(` ✅ [${sourceTableName}] ${mapping.sourceField} → ${mapping.targetField}: ${sourceValue}`);
|
|
||||||
} else if (mapping.defaultValue !== undefined) {
|
} else if (mapping.defaultValue !== undefined) {
|
||||||
mappedData[mapping.targetField] = mapping.defaultValue;
|
mappedData[mapping.targetField] = mapping.defaultValue;
|
||||||
console.log(` ⚠️ [${sourceTableName}] ${mapping.sourceField} 없음, 기본값 사용 → ${mapping.targetField}: ${mapping.defaultValue}`);
|
|
||||||
} else {
|
|
||||||
console.log(` ⚠️ [${sourceTableName}] ${mapping.sourceField} 없음, 건너뜀`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 🔧 parentDataMapping 설정이 없는 경우 기본 매핑 (하위 호환성)
|
// 🔧 parentDataMapping 설정이 없는 경우 기본 매핑 (하위 호환성)
|
||||||
console.log(` ⚠️ [parentDataMapping] 설정 없음, 기본 매핑 적용`);
|
|
||||||
|
|
||||||
// 기본 item_id 매핑 (item.originalData의 id)
|
|
||||||
if (item.originalData.id) {
|
if (item.originalData.id) {
|
||||||
mappedData.item_id = item.originalData.id;
|
mappedData.item_id = item.originalData.id;
|
||||||
console.log(` ✅ [기본] item_id 매핑: ${item.originalData.id}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기본 customer_id 매핑 (parentData의 id 또는 customer_id)
|
|
||||||
if (parentData.id || parentData.customer_id) {
|
if (parentData.id || parentData.customer_id) {
|
||||||
mappedData.customer_id = parentData.customer_id || parentData.id;
|
mappedData.customer_id = parentData.customer_id || parentData.id;
|
||||||
console.log(` ✅ [기본] customer_id 매핑: ${mappedData.customer_id}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -727,33 +659,16 @@ export class ButtonActionExecutor {
|
||||||
// 원본 데이터로 시작 (매핑된 데이터 사용)
|
// 원본 데이터로 시작 (매핑된 데이터 사용)
|
||||||
let mergedData = { ...mappedData };
|
let mergedData = { ...mappedData };
|
||||||
|
|
||||||
console.log(`🔍 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 병합 시작:`, {
|
|
||||||
originalDataKeys: Object.keys(item.originalData),
|
|
||||||
mappedDataKeys: Object.keys(mappedData),
|
|
||||||
combinationLength: combination.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// 각 그룹의 항목 데이터를 순차적으로 병합
|
// 각 그룹의 항목 데이터를 순차적으로 병합
|
||||||
for (let j = 0; j < combination.length; j++) {
|
for (let j = 0; j < combination.length; j++) {
|
||||||
const entry = combination[j];
|
const entry = combination[j];
|
||||||
const { id, ...entryData } = entry; // id 제외
|
const { id, ...entryData } = entry; // id 제외
|
||||||
|
|
||||||
console.log(` 🔸 그룹 ${j + 1} 데이터 병합:`, entryData);
|
|
||||||
|
|
||||||
mergedData = { ...mergedData, ...entryData };
|
mergedData = { ...mergedData, ...entryData };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📝 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 최종 데이터:`, mergedData);
|
|
||||||
|
|
||||||
// 🆕 조합 저장 시 id 필드 제거 (각 조합이 독립된 새 레코드가 되도록)
|
// 🆕 조합 저장 시 id 필드 제거 (각 조합이 독립된 새 레코드가 되도록)
|
||||||
// originalData의 id는 원본 품목의 ID이므로, 새로운 customer_item_mapping 레코드 생성 시 제거 필요
|
|
||||||
const { id: _removedId, ...dataWithoutId } = mergedData;
|
const { id: _removedId, ...dataWithoutId } = mergedData;
|
||||||
|
|
||||||
console.log(`🔧 [handleBatchSave] 조합 ${i + 1}/${combinations.length} id 제거됨:`, {
|
|
||||||
removedId: _removedId,
|
|
||||||
hasId: 'id' in dataWithoutId
|
|
||||||
});
|
|
||||||
|
|
||||||
// 사용자 정보 추가
|
// 사용자 정보 추가
|
||||||
if (!context.userId) {
|
if (!context.userId) {
|
||||||
throw new Error("사용자 정보를 불러올 수 없습니다.");
|
throw new Error("사용자 정보를 불러올 수 없습니다.");
|
||||||
|
|
@ -763,20 +678,13 @@ export class ButtonActionExecutor {
|
||||||
const companyCodeValue = context.companyCode || "";
|
const companyCodeValue = context.companyCode || "";
|
||||||
|
|
||||||
const dataWithUserInfo = {
|
const dataWithUserInfo = {
|
||||||
...dataWithoutId, // id가 제거된 데이터 사용
|
...dataWithoutId,
|
||||||
writer: dataWithoutId.writer || writerValue,
|
writer: dataWithoutId.writer || writerValue,
|
||||||
created_by: writerValue,
|
created_by: writerValue,
|
||||||
updated_by: writerValue,
|
updated_by: writerValue,
|
||||||
company_code: dataWithoutId.company_code || companyCodeValue,
|
company_code: dataWithoutId.company_code || companyCodeValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`💾 [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 요청:`, {
|
|
||||||
itemId: item.id,
|
|
||||||
combinationIndex: i + 1,
|
|
||||||
totalCombinations: combinations.length,
|
|
||||||
data: dataWithUserInfo
|
|
||||||
});
|
|
||||||
|
|
||||||
// INSERT 실행
|
// INSERT 실행
|
||||||
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||||
const saveResult = await DynamicFormApi.saveFormData({
|
const saveResult = await DynamicFormApi.saveFormData({
|
||||||
|
|
@ -787,19 +695,13 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
if (saveResult.success) {
|
if (saveResult.success) {
|
||||||
successCount++;
|
successCount++;
|
||||||
console.log(`✅ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 성공!`, {
|
|
||||||
savedId: saveResult.data?.id,
|
|
||||||
itemId: item.id
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
failCount++;
|
failCount++;
|
||||||
errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${saveResult.message}`);
|
errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${saveResult.message}`);
|
||||||
console.error(`❌ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 실패:`, saveResult.message);
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
failCount++;
|
failCount++;
|
||||||
errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${error.message}`);
|
errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${error.message}`);
|
||||||
console.error(`❌ [handleBatchSave] 조합 ${i + 1}/${combinations.length} 저장 오류:`, error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -446,9 +446,29 @@ export interface DataTableFilter {
|
||||||
export interface ColumnFilter {
|
export interface ColumnFilter {
|
||||||
id: string;
|
id: string;
|
||||||
columnName: string; // 필터링할 컬럼명
|
columnName: string; // 필터링할 컬럼명
|
||||||
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null";
|
operator:
|
||||||
value: string | string[]; // 필터 값 (in/not_in은 배열)
|
| "equals"
|
||||||
valueType: "static" | "category" | "code"; // 값 타입
|
| "not_equals"
|
||||||
|
| "in"
|
||||||
|
| "not_in"
|
||||||
|
| "contains"
|
||||||
|
| "starts_with"
|
||||||
|
| "ends_with"
|
||||||
|
| "is_null"
|
||||||
|
| "is_not_null"
|
||||||
|
| "greater_than"
|
||||||
|
| "less_than"
|
||||||
|
| "greater_than_or_equal"
|
||||||
|
| "less_than_or_equal"
|
||||||
|
| "between"
|
||||||
|
| "date_range_contains"; // 날짜 범위 포함 (start_date <= value <= end_date)
|
||||||
|
value: string | string[]; // 필터 값 (in/not_in은 배열, date_range_contains는 비교할 날짜)
|
||||||
|
valueType: "static" | "category" | "code" | "dynamic"; // 값 타입 (dynamic: 현재 날짜 등)
|
||||||
|
// date_range_contains 전용 설정
|
||||||
|
rangeConfig?: {
|
||||||
|
startColumn: string; // 시작일 컬럼명
|
||||||
|
endColumn: string; // 종료일 컬럼명
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue