Merge pull request 'feature/screen-management' (#213) from feature/screen-management into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/213
This commit is contained in:
commit
703183699f
|
|
@ -23,7 +23,8 @@ export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
|
|||
const result = await screenManagementService.getScreensByCompany(
|
||||
targetCompanyCode,
|
||||
parseInt(page as string),
|
||||
parseInt(size as string)
|
||||
parseInt(size as string),
|
||||
searchTerm as string // 검색어 전달
|
||||
);
|
||||
|
||||
res.json({
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ router.get(
|
|||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } =
|
||||
const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter, enableEntityJoin, displayColumns, deduplication } =
|
||||
req.query;
|
||||
|
||||
// 입력값 검증
|
||||
|
|
@ -37,6 +37,9 @@ router.get(
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 enableEntityJoin 파싱
|
||||
const enableEntityJoinFlag = enableEntityJoin === "true" || enableEntityJoin === true;
|
||||
|
||||
// SQL 인젝션 방지를 위한 검증
|
||||
const tables = [leftTable as string, rightTable as string];
|
||||
const columns = [leftColumn as string, rightColumn as string];
|
||||
|
|
@ -64,6 +67,31 @@ router.get(
|
|||
// 회사 코드 추출 (멀티테넌시 필터링)
|
||||
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(`🔗 조인 데이터 조회:`, {
|
||||
leftTable,
|
||||
rightTable,
|
||||
|
|
@ -71,10 +99,13 @@ router.get(
|
|||
rightColumn,
|
||||
leftValue,
|
||||
userCompany,
|
||||
dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그
|
||||
dataFilter: parsedDataFilter,
|
||||
enableEntityJoin: enableEntityJoinFlag,
|
||||
displayColumns: parsedDisplayColumns, // 🆕 표시 컬럼 로그
|
||||
deduplication: parsedDeduplication, // 🆕 중복 제거 로그
|
||||
});
|
||||
|
||||
// 조인 데이터 조회 (회사 코드 + 데이터 필터 전달)
|
||||
// 조인 데이터 조회 (회사 코드 + 데이터 필터 + Entity 조인 + 표시 컬럼 + 중복 제거 전달)
|
||||
const result = await dataService.getJoinedData(
|
||||
leftTable as string,
|
||||
rightTable as string,
|
||||
|
|
@ -82,7 +113,10 @@ router.get(
|
|||
rightColumn as string,
|
||||
leftValue as string,
|
||||
userCompany,
|
||||
parsedDataFilter // 🆕 데이터 필터 전달
|
||||
parsedDataFilter,
|
||||
enableEntityJoinFlag,
|
||||
parsedDisplayColumns, // 🆕 표시 컬럼 전달
|
||||
parsedDeduplication // 🆕 중복 제거 설정 전달
|
||||
);
|
||||
|
||||
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 문자열 또는 쉼표 구분)
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 레코드 상세 조회
|
||||
const result = await dataService.getRecordDetail(tableName, id);
|
||||
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
|
||||
enableEntityJoin: enableEntityJoinFlag,
|
||||
groupByColumns: groupByColumnsArray
|
||||
});
|
||||
|
||||
// 레코드 상세 조회 (Entity Join 옵션 + 그룹핑 옵션 포함)
|
||||
const result = await dataService.getRecordDetail(tableName, id, enableEntityJoinFlag, groupByColumnsArray);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
|
|
@ -338,6 +393,86 @@ router.get(
|
|||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 그룹화된 데이터 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,
|
||||
userCompany: req.user?.companyCode,
|
||||
userId: req.user?.userId,
|
||||
});
|
||||
|
||||
// UPSERT 수행
|
||||
const result = await dataService.upsertGroupedRecords(
|
||||
tableName,
|
||||
parentKeys,
|
||||
records,
|
||||
req.user?.companyCode,
|
||||
req.user?.userId
|
||||
);
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 레코드 생성 API
|
||||
* POST /api/data/{tableName}
|
||||
|
|
@ -523,6 +658,46 @@ router.post(
|
|||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 그룹 삭제 API
|
||||
* POST /api/data/:tableName/delete-group
|
||||
*/
|
||||
router.post(
|
||||
"/:tableName/delete-group",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const filterConditions = req.body;
|
||||
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions });
|
||||
|
||||
const result = await dataService.deleteGroupRecords(tableName, filterConditions);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(400).json(result);
|
||||
}
|
||||
|
||||
console.log(`✅ 그룹 삭제: ${result.data?.deleted}개`);
|
||||
return res.json(result);
|
||||
} catch (error: any) {
|
||||
console.error("그룹 삭제 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "그룹 삭제 실패",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:tableName/:id",
|
||||
authenticateToken,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
* - 최고 관리자(company_code = "*")만 전체 데이터 조회 가능
|
||||
*/
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool import
|
||||
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
||||
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
|
||||
|
||||
interface GetTableDataParams {
|
||||
tableName: string;
|
||||
|
|
@ -53,6 +55,103 @@ const BLOCKED_TABLES = [
|
|||
const TABLE_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
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 +473,13 @@ class DataService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 레코드 상세 조회
|
||||
* 레코드 상세 조회 (Entity Join 지원 + 그룹핑 기반 다중 레코드 조회)
|
||||
*/
|
||||
async getRecordDetail(
|
||||
tableName: string,
|
||||
id: string | number
|
||||
id: string | number,
|
||||
enableEntityJoin: boolean = false,
|
||||
groupByColumns: string[] = []
|
||||
): Promise<ServiceResponse<any>> {
|
||||
try {
|
||||
// 테이블 접근 검증
|
||||
|
|
@ -401,6 +502,108 @@ class DataService {
|
|||
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",
|
||||
};
|
||||
}
|
||||
|
||||
// 🔧 날짜 타입 타임존 문제 해결: Date 객체를 YYYY-MM-DD 문자열로 변환
|
||||
const normalizeDates = (rows: any[]) => {
|
||||
return rows.map(row => {
|
||||
const normalized: any = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (value instanceof Date) {
|
||||
// Date 객체를 YYYY-MM-DD 형식으로 변환 (타임존 무시)
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
normalized[key] = `${year}-${month}-${day}`;
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
};
|
||||
|
||||
const normalizedRows = normalizeDates(result.rows);
|
||||
console.log(`✅ Entity Join 데이터 조회 성공 (날짜 정규화됨):`, normalizedRows[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 = normalizedRows[0][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);
|
||||
|
||||
const normalizedGroupRows = normalizeDates(groupResult.rows);
|
||||
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: normalizedGroupRows, // 🔧 배열로 반환!
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 쿼리 (Entity Join 없음)
|
||||
const queryText = `SELECT * FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
const result = await query<any>(queryText, [id]);
|
||||
|
||||
|
|
@ -427,7 +630,7 @@ class DataService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 조인된 데이터 조회
|
||||
* 조인된 데이터 조회 (🆕 Entity 조인 지원)
|
||||
*/
|
||||
async getJoinedData(
|
||||
leftTable: string,
|
||||
|
|
@ -436,7 +639,15 @@ class DataService {
|
|||
rightColumn: string,
|
||||
leftValue?: string | number,
|
||||
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[]>> {
|
||||
try {
|
||||
// 왼쪽 테이블 접근 검증
|
||||
|
|
@ -451,6 +662,162 @@ class DataService {
|
|||
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);
|
||||
|
||||
// 🔧 날짜 타입 타임존 문제 해결
|
||||
const normalizeDates = (rows: any[]) => {
|
||||
return rows.map(row => {
|
||||
const normalized: any = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (value instanceof Date) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(value.getDate()).padStart(2, '0');
|
||||
normalized[key] = `${year}-${month}-${day}`;
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
};
|
||||
|
||||
const normalizedRows = normalizeDates(result.rows);
|
||||
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${normalizedRows.length}개 (날짜 정규화됨)`);
|
||||
|
||||
// 🆕 중복 제거 처리
|
||||
let finalData = normalizedRows;
|
||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
||||
finalData = this.deduplicateData(normalizedRows, deduplication);
|
||||
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: finalData,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Entity 조인 처리 실패, 기본 조인으로 폴백:", error);
|
||||
// Entity 조인 실패 시 기본 조인으로 폴백
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 조인 쿼리 (Entity 조인 미사용 또는 실패 시)
|
||||
let queryText = `
|
||||
SELECT DISTINCT r.*
|
||||
FROM "${rightTable}" r
|
||||
|
|
@ -501,9 +868,17 @@ class DataService {
|
|||
|
||||
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 {
|
||||
success: true,
|
||||
data: result,
|
||||
data: finalData,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
|
|
@ -728,6 +1103,284 @@ class DataService {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
||||
*/
|
||||
async deleteGroupRecords(
|
||||
tableName: string,
|
||||
filterConditions: Record<string, any>
|
||||
): Promise<ServiceResponse<{ deleted: number }>> {
|
||||
try {
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
if (!validation.valid) {
|
||||
return validation.error!;
|
||||
}
|
||||
|
||||
const whereConditions: string[] = [];
|
||||
const whereValues: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const [key, value] of Object.entries(filterConditions)) {
|
||||
whereConditions.push(`"${key}" = $${paramIndex}`);
|
||||
whereValues.push(value);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (whereConditions.length === 0) {
|
||||
return { success: false, message: "삭제 조건이 없습니다.", error: "NO_CONDITIONS" };
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
|
||||
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions });
|
||||
|
||||
const result = await pool.query(deleteQuery, whereValues);
|
||||
|
||||
console.log(`✅ 그룹 삭제 성공: ${result.rowCount}개`);
|
||||
|
||||
return { success: true, data: { deleted: result.rowCount || 0 } };
|
||||
} catch (error) {
|
||||
console.error("그룹 삭제 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "그룹 삭제 실패",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹화된 데이터 UPSERT
|
||||
* - 부모 키(예: customer_id, item_id)와 레코드 배열을 받아
|
||||
* - 기존 DB의 레코드들과 비교하여 INSERT/UPDATE/DELETE 수행
|
||||
* - 각 레코드의 모든 필드 조합을 고유 키로 사용
|
||||
*/
|
||||
async upsertGroupedRecords(
|
||||
tableName: string,
|
||||
parentKeys: Record<string, any>,
|
||||
records: Array<Record<string, any>>,
|
||||
userCompany?: string,
|
||||
userId?: string
|
||||
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
|
||||
try {
|
||||
// 테이블 접근 권한 검증
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
if (!validation.valid) {
|
||||
return validation.error!;
|
||||
}
|
||||
|
||||
// Primary Key 감지
|
||||
const pkColumns = await this.getPrimaryKeyColumns(tableName);
|
||||
if (!pkColumns || pkColumns.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `테이블 '${tableName}'의 Primary Key를 찾을 수 없습니다.`,
|
||||
error: "PRIMARY_KEY_NOT_FOUND",
|
||||
};
|
||||
}
|
||||
const pkColumn = pkColumns[0]; // 첫 번째 PK 사용
|
||||
|
||||
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;
|
||||
|
||||
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
|
||||
const normalizeDateValue = (value: any): any => {
|
||||
if (value == null) return value;
|
||||
|
||||
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
return value.split('T')[0]; // YYYY-MM-DD 만 추출
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
// 새 레코드 처리 (INSERT or UPDATE)
|
||||
for (const newRecord of records) {
|
||||
// 날짜 필드 정규화
|
||||
const normalizedRecord: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(newRecord)) {
|
||||
normalizedRecord[key] = normalizeDateValue(value);
|
||||
}
|
||||
|
||||
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
|
||||
const fullRecord = { ...parentKeys, ...normalizedRecord };
|
||||
|
||||
// 고유 키: parentKeys 제외한 나머지 필드들
|
||||
const uniqueFields = Object.keys(normalizedRecord);
|
||||
|
||||
// 기존 레코드에서 일치하는 것 찾기
|
||||
const existingRecord = existingRecords.rows.find((existing) => {
|
||||
return uniqueFields.every((field) => {
|
||||
const existingValue = existing[field];
|
||||
const newValue = normalizedRecord[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: 기존 레코드가 없으면 삽입
|
||||
|
||||
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
||||
const recordWithMeta: Record<string, any> = {
|
||||
...fullRecord,
|
||||
id: uuidv4(), // 새 ID 생성
|
||||
created_date: "NOW()",
|
||||
updated_date: "NOW()",
|
||||
};
|
||||
|
||||
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
||||
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
|
||||
recordWithMeta.company_code = userCompany;
|
||||
}
|
||||
|
||||
// writer가 없으면 userId 사용
|
||||
if (!recordWithMeta.writer && userId) {
|
||||
recordWithMeta.writer = userId;
|
||||
}
|
||||
|
||||
const insertFields = Object.keys(recordWithMeta).filter(key =>
|
||||
recordWithMeta[key] !== "NOW()"
|
||||
);
|
||||
const insertPlaceholders: string[] = [];
|
||||
const insertValues: any[] = [];
|
||||
let insertParamIndex = 1;
|
||||
|
||||
for (const field of Object.keys(recordWithMeta)) {
|
||||
if (recordWithMeta[field] === "NOW()") {
|
||||
insertPlaceholders.push("NOW()");
|
||||
} else {
|
||||
insertPlaceholders.push(`$${insertParamIndex}`);
|
||||
insertValues.push(recordWithMeta[field]);
|
||||
insertParamIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
const insertQuery = `
|
||||
INSERT INTO "${tableName}" (${Object.keys(recordWithMeta).map(f => `"${f}"`).join(", ")})
|
||||
VALUES (${insertPlaceholders.join(", ")})
|
||||
`;
|
||||
|
||||
console.log(`➕ INSERT 쿼리:`, { query: insertQuery, values: insertValues });
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -81,18 +81,18 @@ export class EntityJoinService {
|
|||
let referenceColumn = column.reference_column;
|
||||
let displayColumn = column.display_column;
|
||||
|
||||
if (column.input_type === 'category') {
|
||||
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
|
||||
referenceTable = referenceTable || 'table_column_category_values';
|
||||
referenceColumn = referenceColumn || 'value_code';
|
||||
displayColumn = displayColumn || 'value_label';
|
||||
|
||||
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
});
|
||||
}
|
||||
if (column.input_type === "category") {
|
||||
// 카테고리 타입: reference 정보가 비어있어도 자동 설정
|
||||
referenceTable = referenceTable || "table_column_category_values";
|
||||
referenceColumn = referenceColumn || "value_code";
|
||||
displayColumn = displayColumn || "value_label";
|
||||
|
||||
logger.info(`🏷️ 카테고리 타입 자동 설정: ${column.column_name}`, {
|
||||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
|
||||
column_name: column.column_name,
|
||||
|
|
@ -200,6 +200,25 @@ export class EntityJoinService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 컬럼을 YYYY-MM-DD 형식으로 변환하는 SQL 표현식
|
||||
*/
|
||||
private formatDateColumn(
|
||||
tableAlias: string,
|
||||
columnName: string,
|
||||
dataType?: string
|
||||
): string {
|
||||
// date, timestamp 타입이면 TO_CHAR로 변환
|
||||
if (
|
||||
dataType &&
|
||||
(dataType.includes("date") || dataType.includes("timestamp"))
|
||||
) {
|
||||
return `TO_CHAR(${tableAlias}.${columnName}, 'YYYY-MM-DD')`;
|
||||
}
|
||||
// 기본은 TEXT 캐스팅
|
||||
return `${tableAlias}.${columnName}::TEXT`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity 조인이 포함된 SQL 쿼리 생성
|
||||
*/
|
||||
|
|
@ -210,13 +229,30 @@ export class EntityJoinService {
|
|||
whereClause: string = "",
|
||||
orderBy: string = "",
|
||||
limit?: number,
|
||||
offset?: number
|
||||
offset?: number,
|
||||
columnTypes?: Map<string, string> // 컬럼명 → 데이터 타입 매핑
|
||||
): { query: string; aliasMap: Map<string, string> } {
|
||||
try {
|
||||
// 기본 SELECT 컬럼들 (TEXT로 캐스팅하여 record 타입 오류 방지)
|
||||
const baseColumns = selectColumns
|
||||
.map((col) => `main.${col}::TEXT AS ${col}`)
|
||||
.join(", ");
|
||||
// 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅)
|
||||
// 🔧 "*"는 전체 조회하되, 날짜 타입 타임존 문제를 피하기 위해
|
||||
// jsonb_build_object를 사용하여 명시적으로 변환
|
||||
let baseColumns: string;
|
||||
if (selectColumns.length === 1 && selectColumns[0] === "*") {
|
||||
// main.* 사용 시 날짜 타입 필드만 TO_CHAR로 변환
|
||||
// PostgreSQL의 날짜 → 타임스탬프 자동 변환으로 인한 타임존 문제 방지
|
||||
baseColumns = `main.*`;
|
||||
logger.info(
|
||||
`⚠️ [buildJoinQuery] main.* 사용 - 날짜 타임존 변환 주의 필요`
|
||||
);
|
||||
} else {
|
||||
baseColumns = selectColumns
|
||||
.map((col) => {
|
||||
const dataType = columnTypes?.get(col);
|
||||
const formattedCol = this.formatDateColumn("main", col, dataType);
|
||||
return `${formattedCol} AS ${col}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
// Entity 조인 컬럼들 (COALESCE로 NULL을 빈 문자열로 처리)
|
||||
// 별칭 매핑 생성 (JOIN 절과 동일한 로직)
|
||||
|
|
@ -255,7 +291,9 @@ export class EntityJoinService {
|
|||
// 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응)
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
aliasMap.set(aliasKey, alias);
|
||||
logger.info(`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}`);
|
||||
logger.info(
|
||||
`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn} → ${alias}`
|
||||
);
|
||||
});
|
||||
|
||||
const joinColumns = joinConfigs
|
||||
|
|
@ -266,64 +304,55 @@ export class EntityJoinService {
|
|||
config.displayColumn,
|
||||
];
|
||||
const separator = config.separator || " - ";
|
||||
|
||||
|
||||
// 결과 컬럼 배열 (aliasColumn + _label 필드)
|
||||
const resultColumns: string[] = [];
|
||||
|
||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
||||
// 조인 테이블의 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) {
|
||||
// 단일 컬럼인 경우
|
||||
const col = displayColumns[0];
|
||||
const isJoinTableColumn = [
|
||||
"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);
|
||||
|
||||
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
|
||||
// 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
|
||||
if (isJoinTableColumn) {
|
||||
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`);
|
||||
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
|
||||
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
|
||||
// sourceColumn_label 형식으로 추가
|
||||
resultColumns.push(`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`);
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.sourceColumn}_label`
|
||||
);
|
||||
|
||||
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
|
||||
// 예: customer_code, item_number 등
|
||||
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
|
||||
);
|
||||
} else {
|
||||
resultColumns.push(`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`);
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 여러 컬럼인 경우 CONCAT으로 연결
|
||||
// 기본 테이블과 조인 테이블의 컬럼을 구분해서 처리
|
||||
const concatParts = displayColumns
|
||||
.map((col) => {
|
||||
// 조인 테이블의 컬럼인지 확인 (조인 테이블에 존재하는 컬럼만 조인 별칭 사용)
|
||||
// 현재는 dept_info 테이블의 컬럼들을 확인
|
||||
const isJoinTableColumn = [
|
||||
"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);
|
||||
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
|
||||
if (isJoinTableColumn) {
|
||||
// 조인 테이블 컬럼은 조인 별칭 사용
|
||||
|
|
@ -336,8 +365,20 @@ export class EntityJoinService {
|
|||
.join(` || '${separator}' || `);
|
||||
|
||||
resultColumns.push(`(${concatParts}) AS ${config.aliasColumn}`);
|
||||
|
||||
// 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용)
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
if (
|
||||
isJoinTableColumn &&
|
||||
!displayColumns.includes(config.referenceColumn)
|
||||
) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 모든 resultColumns를 반환
|
||||
return resultColumns.join(", ");
|
||||
})
|
||||
|
|
@ -356,13 +397,13 @@ export class EntityJoinService {
|
|||
.map((config) => {
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
|
||||
|
||||
// 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}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
|
@ -424,7 +465,7 @@ export class EntityJoinService {
|
|||
}
|
||||
|
||||
// table_column_category_values는 특수 조인 조건이 필요하므로 캐시 불가
|
||||
if (config.referenceTable === 'table_column_category_values') {
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
logger.info(
|
||||
`🎯 table_column_category_values는 캐시 전략 불가: ${config.sourceColumn}`
|
||||
);
|
||||
|
|
@ -578,13 +619,13 @@ export class EntityJoinService {
|
|||
.map((config) => {
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
|
||||
|
||||
// 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}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
|
|
|||
|
|
@ -98,7 +98,8 @@ export class ScreenManagementService {
|
|||
async getScreensByCompany(
|
||||
companyCode: string,
|
||||
page: number = 1,
|
||||
size: number = 20
|
||||
size: number = 20,
|
||||
searchTerm?: string // 검색어 추가
|
||||
): Promise<PaginatedResponse<ScreenDefinition>> {
|
||||
const offset = (page - 1) * size;
|
||||
|
||||
|
|
@ -111,6 +112,16 @@ export class ScreenManagementService {
|
|||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// 검색어 필터링 추가 (화면명, 화면 코드, 테이블명 검색)
|
||||
if (searchTerm && searchTerm.trim() !== "") {
|
||||
whereConditions.push(`(
|
||||
screen_name ILIKE $${params.length + 1} OR
|
||||
screen_code ILIKE $${params.length + 1} OR
|
||||
table_name ILIKE $${params.length + 1}
|
||||
)`);
|
||||
params.push(`%${searchTerm.trim()}%`);
|
||||
}
|
||||
|
||||
const whereSQL = whereConditions.join(" AND ");
|
||||
|
||||
// 페이징 쿼리 (Raw Query)
|
||||
|
|
@ -2101,55 +2112,109 @@ export class ScreenManagementService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 화면에 연결된 모달 화면들을 자동 감지
|
||||
* 버튼 컴포넌트의 popup 액션에서 targetScreenId를 추출
|
||||
* 화면에 연결된 모달/화면들을 재귀적으로 자동 감지
|
||||
* - 버튼 컴포넌트: popup/modal/edit/openModalWithData 액션의 targetScreenId
|
||||
* - 조건부 컨테이너: sections[].screenId (조건별 화면 할당)
|
||||
* - 중첩된 화면들도 모두 감지 (재귀)
|
||||
*/
|
||||
async detectLinkedModalScreens(
|
||||
screenId: number
|
||||
): Promise<{ screenId: number; screenName: string; screenCode: string }[]> {
|
||||
// 화면의 모든 레이아웃 조회
|
||||
const layouts = await query<any>(
|
||||
`SELECT layout_id, properties
|
||||
FROM screen_layouts
|
||||
WHERE screen_id = $1
|
||||
AND component_type = 'component'
|
||||
AND properties IS NOT NULL`,
|
||||
[screenId]
|
||||
);
|
||||
console.log(`\n🔍 [재귀 감지 시작] 화면 ID: ${screenId}`);
|
||||
|
||||
const allLinkedScreenIds = new Set<number>();
|
||||
const visited = new Set<number>(); // 무한 루프 방지
|
||||
const queue: number[] = [screenId]; // BFS 큐
|
||||
|
||||
const linkedScreenIds = new Set<number>();
|
||||
// BFS로 연결된 모든 화면 탐색
|
||||
while (queue.length > 0) {
|
||||
const currentScreenId = queue.shift()!;
|
||||
|
||||
// 이미 방문한 화면은 스킵 (순환 참조 방지)
|
||||
if (visited.has(currentScreenId)) {
|
||||
console.log(`⏭️ 이미 방문한 화면 스킵: ${currentScreenId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.add(currentScreenId);
|
||||
console.log(`\n📋 현재 탐색 중인 화면: ${currentScreenId} (깊이: ${visited.size})`);
|
||||
|
||||
// 각 레이아웃에서 버튼의 popup/modal/edit 액션 확인
|
||||
for (const layout of layouts) {
|
||||
try {
|
||||
const properties = layout.properties;
|
||||
|
||||
// 버튼 컴포넌트인지 확인
|
||||
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
|
||||
const action = properties?.componentConfig?.action;
|
||||
// 현재 화면의 모든 레이아웃 조회
|
||||
const layouts = await query<any>(
|
||||
`SELECT layout_id, properties
|
||||
FROM screen_layouts
|
||||
WHERE screen_id = $1
|
||||
AND component_type = 'component'
|
||||
AND properties IS NOT NULL`,
|
||||
[currentScreenId]
|
||||
);
|
||||
|
||||
console.log(` 📦 레이아웃 개수: ${layouts.length}`);
|
||||
|
||||
// 각 레이아웃에서 연결된 화면 ID 확인
|
||||
for (const layout of layouts) {
|
||||
try {
|
||||
const properties = layout.properties;
|
||||
|
||||
// popup, modal, edit 액션이고 targetScreenId가 있는 경우
|
||||
// edit 액션도 수정 폼 모달을 열기 때문에 포함
|
||||
if ((action?.type === "popup" || action?.type === "modal" || action?.type === "edit") && action?.targetScreenId) {
|
||||
const targetScreenId = parseInt(action.targetScreenId);
|
||||
if (!isNaN(targetScreenId)) {
|
||||
linkedScreenIds.add(targetScreenId);
|
||||
console.log(`🔗 연결된 모달 화면 발견: screenId=${targetScreenId}, actionType=${action.type} (레이아웃 ${layout.layout_id})`);
|
||||
// 1. 버튼 컴포넌트의 액션 확인
|
||||
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
|
||||
const action = properties?.componentConfig?.action;
|
||||
|
||||
const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"];
|
||||
if (modalActionTypes.includes(action?.type) && action?.targetScreenId) {
|
||||
const targetScreenId = parseInt(action.targetScreenId);
|
||||
if (!isNaN(targetScreenId) && targetScreenId !== currentScreenId) {
|
||||
// 메인 화면이 아닌 경우에만 추가
|
||||
if (targetScreenId !== screenId) {
|
||||
allLinkedScreenIds.add(targetScreenId);
|
||||
}
|
||||
// 아직 방문하지 않은 화면이면 큐에 추가
|
||||
if (!visited.has(targetScreenId)) {
|
||||
queue.push(targetScreenId);
|
||||
console.log(` 🔗 [버튼] 연결된 화면 발견: ${targetScreenId} (action: ${action.type}) → 큐에 추가`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. conditional-container 컴포넌트의 sections 확인
|
||||
if (properties?.componentType === "conditional-container") {
|
||||
const sections = properties?.componentConfig?.sections || [];
|
||||
|
||||
for (const section of sections) {
|
||||
if (section?.screenId) {
|
||||
const sectionScreenId = parseInt(section.screenId);
|
||||
if (!isNaN(sectionScreenId) && sectionScreenId !== currentScreenId) {
|
||||
// 메인 화면이 아닌 경우에만 추가
|
||||
if (sectionScreenId !== screenId) {
|
||||
allLinkedScreenIds.add(sectionScreenId);
|
||||
}
|
||||
// 아직 방문하지 않은 화면이면 큐에 추가
|
||||
if (!visited.has(sectionScreenId)) {
|
||||
queue.push(sectionScreenId);
|
||||
console.log(` 🔗 [조건부컨테이너] 연결된 화면 발견: ${sectionScreenId} (condition: ${section.condition}) → 큐에 추가`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(` ⚠️ 레이아웃 ${layout.layout_id} 파싱 오류:`, error);
|
||||
}
|
||||
} catch (error) {
|
||||
// JSON 파싱 오류 등은 무시하고 계속 진행
|
||||
console.warn(`레이아웃 ${layout.layout_id} 파싱 오류:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ [재귀 감지 완료] 총 방문한 화면: ${visited.size}개, 연결된 화면: ${allLinkedScreenIds.size}개`);
|
||||
console.log(` 방문한 화면 ID: [${Array.from(visited).join(", ")}]`);
|
||||
console.log(` 연결된 화면 ID: [${Array.from(allLinkedScreenIds).join(", ")}]`);
|
||||
|
||||
// 감지된 화면 ID들의 정보 조회
|
||||
if (linkedScreenIds.size === 0) {
|
||||
if (allLinkedScreenIds.size === 0) {
|
||||
console.log(`ℹ️ 연결된 화면이 없습니다.`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const screenIds = Array.from(linkedScreenIds);
|
||||
const screenIds = Array.from(allLinkedScreenIds);
|
||||
const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
const linkedScreens = await query<any>(
|
||||
|
|
@ -2161,6 +2226,11 @@ export class ScreenManagementService {
|
|||
screenIds
|
||||
);
|
||||
|
||||
console.log(`\n📋 최종 감지된 화면 목록:`);
|
||||
linkedScreens.forEach((s: any) => {
|
||||
console.log(` - ${s.screen_name} (ID: ${s.screen_id}, 코드: ${s.screen_code})`);
|
||||
});
|
||||
|
||||
return linkedScreens.map((s) => ({
|
||||
screenId: s.screen_id,
|
||||
screenName: s.screen_name,
|
||||
|
|
@ -2430,23 +2500,23 @@ export class ScreenManagementService {
|
|||
for (const layout of layouts) {
|
||||
try {
|
||||
const properties = layout.properties;
|
||||
let needsUpdate = false;
|
||||
|
||||
// 버튼 컴포넌트인지 확인
|
||||
// 1. 버튼 컴포넌트의 targetScreenId 업데이트
|
||||
if (
|
||||
properties?.componentType === "button" ||
|
||||
properties?.componentType?.startsWith("button-")
|
||||
) {
|
||||
const action = properties?.componentConfig?.action;
|
||||
|
||||
// targetScreenId가 있는 액션 (popup, modal, edit)
|
||||
// targetScreenId가 있는 액션 (popup, modal, edit, openModalWithData)
|
||||
const modalActionTypes = ["popup", "modal", "edit", "openModalWithData"];
|
||||
if (
|
||||
(action?.type === "popup" ||
|
||||
action?.type === "modal" ||
|
||||
action?.type === "edit") &&
|
||||
modalActionTypes.includes(action?.type) &&
|
||||
action?.targetScreenId
|
||||
) {
|
||||
const oldScreenId = parseInt(action.targetScreenId);
|
||||
console.log(`🔍 버튼 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`);
|
||||
console.log(`🔍 [버튼] 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`);
|
||||
|
||||
// 매핑에 있으면 업데이트
|
||||
if (screenIdMapping.has(oldScreenId)) {
|
||||
|
|
@ -2456,31 +2526,63 @@ export class ScreenManagementService {
|
|||
// properties 업데이트
|
||||
properties.componentConfig.action.targetScreenId =
|
||||
newScreenId.toString();
|
||||
needsUpdate = true;
|
||||
|
||||
// 데이터베이스 업데이트
|
||||
await query(
|
||||
`UPDATE screen_layouts
|
||||
SET properties = $1
|
||||
WHERE layout_id = $2`,
|
||||
[JSON.stringify(properties), layout.layout_id]
|
||||
);
|
||||
|
||||
updateCount++;
|
||||
console.log(
|
||||
`🔗 버튼 targetScreenId 업데이트: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})`
|
||||
`🔗 [버튼] targetScreenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id})`
|
||||
);
|
||||
} else {
|
||||
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. conditional-container 컴포넌트의 sections[].screenId 업데이트
|
||||
if (properties?.componentType === "conditional-container") {
|
||||
const sections = properties?.componentConfig?.sections || [];
|
||||
|
||||
for (const section of sections) {
|
||||
if (section?.screenId) {
|
||||
const oldScreenId = parseInt(section.screenId);
|
||||
console.log(`🔍 [조건부컨테이너] section 발견: layout ${layout.layout_id}, condition=${section.condition}, screenId=${oldScreenId}`);
|
||||
|
||||
// 매핑에 있으면 업데이트
|
||||
if (screenIdMapping.has(oldScreenId)) {
|
||||
const newScreenId = screenIdMapping.get(oldScreenId)!;
|
||||
console.log(`✅ 매핑 발견: ${oldScreenId} → ${newScreenId}`);
|
||||
|
||||
// section.screenId 업데이트
|
||||
section.screenId = newScreenId;
|
||||
needsUpdate = true;
|
||||
|
||||
console.log(
|
||||
`🔗 [조건부컨테이너] screenId 업데이트 준비: ${oldScreenId} → ${newScreenId} (layout ${layout.layout_id}, condition=${section.condition})`
|
||||
);
|
||||
} else {
|
||||
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 업데이트가 필요한 경우 DB 저장
|
||||
if (needsUpdate) {
|
||||
await query(
|
||||
`UPDATE screen_layouts
|
||||
SET properties = $1
|
||||
WHERE layout_id = $2`,
|
||||
[JSON.stringify(properties), layout.layout_id]
|
||||
);
|
||||
updateCount++;
|
||||
console.log(`💾 레이아웃 ${layout.layout_id} 업데이트 완료`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error);
|
||||
// 개별 레이아웃 오류는 무시하고 계속 진행
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ 총 ${updateCount}개 버튼의 targetScreenId 업데이트 완료`);
|
||||
console.log(`✅ 총 ${updateCount}개 레이아웃의 연결된 화면 ID 업데이트 완료 (버튼 + 조건부컨테이너)`);
|
||||
return updateCount;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,28 @@
|
|||
export interface ColumnFilter {
|
||||
id: 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[];
|
||||
valueType: "static" | "category" | "code";
|
||||
valueType: "static" | "category" | "code" | "dynamic";
|
||||
rangeConfig?: {
|
||||
startColumn: string;
|
||||
endColumn: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DataFilterConfig {
|
||||
|
|
@ -123,6 +142,71 @@ export function buildDataFilterWhereClause(
|
|||
conditions.push(`${columnRef} IS NOT NULL`);
|
||||
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:
|
||||
// 알 수 없는 연산자는 무시
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -221,6 +221,125 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
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));
|
||||
}
|
||||
|
||||
// 🔧 날짜 필드 정규화 (타임존 제거)
|
||||
const normalizeDates = (data: any): any => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(normalizeDates);
|
||||
}
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const normalized: any = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
// ISO 날짜 형식 감지: YYYY-MM-DD만 추출
|
||||
const before = value;
|
||||
const after = value.split('T')[0];
|
||||
console.log(`🔧 [날짜 정규화] ${key}: ${before} → ${after}`);
|
||||
normalized[key] = after;
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2));
|
||||
const normalizedData = normalizeDates(response.data);
|
||||
console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2));
|
||||
setFormData(normalizedData);
|
||||
|
||||
// setFormData 직후 확인
|
||||
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
|
||||
} else {
|
||||
console.error("❌ 수정 데이터 로드 실패:", response.error);
|
||||
toast.error("데이터를 불러올 수 없습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 수정 데이터 조회 오류:", error);
|
||||
toast.error("데이터를 불러오는 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// screenApi는 직접 데이터를 반환하므로 .success 체크 불필요
|
||||
if (screenInfo && layoutData) {
|
||||
const components = layoutData.components || [];
|
||||
|
|
@ -268,6 +387,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// 🔧 URL 파라미터 제거 (mode, editId, tableName 등)
|
||||
if (typeof window !== "undefined") {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.delete("mode");
|
||||
currentUrl.searchParams.delete("editId");
|
||||
currentUrl.searchParams.delete("tableName");
|
||||
currentUrl.searchParams.delete("groupByColumns");
|
||||
window.history.pushState({}, "", currentUrl.toString());
|
||||
console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)");
|
||||
}
|
||||
|
||||
setModalState({
|
||||
isOpen: false,
|
||||
screenId: null,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -66,17 +67,31 @@ type DeletedScreenDefinition = ScreenDefinition & {
|
|||
};
|
||||
|
||||
export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScreen }: ScreenListProps) {
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*";
|
||||
|
||||
const [activeTab, setActiveTab] = useState("active");
|
||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [deletedScreens, setDeletedScreens] = useState<DeletedScreenDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(true); // 초기 로딩
|
||||
const [isSearching, setIsSearching] = useState(false); // 검색 중 로딩 (포커스 유지)
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
const [selectedCompanyCode, setSelectedCompanyCode] = useState<string>("all");
|
||||
const [companies, setCompanies] = useState<any[]>([]);
|
||||
const [loadingCompanies, setLoadingCompanies] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isCopyOpen, setIsCopyOpen] = useState(false);
|
||||
const [screenToCopy, setScreenToCopy] = useState<ScreenDefinition | null>(null);
|
||||
|
||||
// 검색어 디바운스를 위한 타이머 ref
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 첫 로딩 여부를 추적 (한 번만 true)
|
||||
const isFirstLoad = useRef(true);
|
||||
|
||||
// 삭제 관련 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [screenToDelete, setScreenToDelete] = useState<ScreenDefinition | null>(null);
|
||||
|
|
@ -119,14 +134,75 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
||||
const [previewFormData, setPreviewFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 화면 목록 로드 (실제 API)
|
||||
// 최고 관리자인 경우 회사 목록 로드
|
||||
useEffect(() => {
|
||||
if (isSuperAdmin) {
|
||||
loadCompanies();
|
||||
}
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
const loadCompanies = async () => {
|
||||
try {
|
||||
setLoadingCompanies(true);
|
||||
const { apiClient } = await import("@/lib/api/client"); // named export
|
||||
const response = await apiClient.get("/admin/companies");
|
||||
const data = response.data.data || response.data || [];
|
||||
setCompanies(data.map((c: any) => ({
|
||||
companyCode: c.company_code || c.companyCode,
|
||||
companyName: c.company_name || c.companyName,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error("회사 목록 조회 실패:", error);
|
||||
} finally {
|
||||
setLoadingCompanies(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 검색어 디바운스 처리 (150ms 지연 - 빠른 응답)
|
||||
useEffect(() => {
|
||||
// 이전 타이머 취소
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
|
||||
// 새 타이머 설정
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
}, 150);
|
||||
|
||||
// 클린업
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current);
|
||||
}
|
||||
};
|
||||
}, [searchTerm]);
|
||||
|
||||
// 화면 목록 로드 (실제 API) - debouncedSearchTerm 사용
|
||||
useEffect(() => {
|
||||
let abort = false;
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// 첫 로딩인 경우에만 loading=true, 그 외에는 isSearching=true
|
||||
if (isFirstLoad.current) {
|
||||
setLoading(true);
|
||||
isFirstLoad.current = false; // 첫 로딩 완료 표시
|
||||
} else {
|
||||
setIsSearching(true);
|
||||
}
|
||||
|
||||
if (activeTab === "active") {
|
||||
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
|
||||
const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm };
|
||||
|
||||
// 최고 관리자이고 특정 회사를 선택한 경우
|
||||
if (isSuperAdmin && selectedCompanyCode !== "all") {
|
||||
params.companyCode = selectedCompanyCode;
|
||||
}
|
||||
|
||||
console.log("🔍 화면 목록 API 호출:", params); // 디버깅용
|
||||
const resp = await screenApi.getScreens(params);
|
||||
console.log("✅ 화면 목록 응답:", resp); // 디버깅용
|
||||
|
||||
if (abort) return;
|
||||
setScreens(resp.data || []);
|
||||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||
|
|
@ -137,7 +213,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||
}
|
||||
} catch (e) {
|
||||
// console.error("화면 목록 조회 실패", e);
|
||||
console.error("화면 목록 조회 실패", e);
|
||||
if (activeTab === "active") {
|
||||
setScreens([]);
|
||||
} else {
|
||||
|
|
@ -145,28 +221,38 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
}
|
||||
setTotalPages(1);
|
||||
} finally {
|
||||
if (!abort) setLoading(false);
|
||||
if (!abort) {
|
||||
setLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
abort = true;
|
||||
};
|
||||
}, [currentPage, searchTerm, activeTab]);
|
||||
}, [currentPage, debouncedSearchTerm, activeTab, selectedCompanyCode, isSuperAdmin]);
|
||||
|
||||
const filteredScreens = screens; // 서버 필터 기준 사용
|
||||
|
||||
// 화면 목록 다시 로드
|
||||
const reloadScreens = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const resp = await screenApi.getScreens({ page: currentPage, size: 20, searchTerm });
|
||||
setIsSearching(true);
|
||||
const params: any = { page: currentPage, size: 20, searchTerm: debouncedSearchTerm };
|
||||
|
||||
// 최고 관리자이고 특정 회사를 선택한 경우
|
||||
if (isSuperAdmin && selectedCompanyCode !== "all") {
|
||||
params.companyCode = selectedCompanyCode;
|
||||
}
|
||||
|
||||
const resp = await screenApi.getScreens(params);
|
||||
setScreens(resp.data || []);
|
||||
setTotalPages(Math.max(1, Math.ceil((resp.total || 0) / 20)));
|
||||
} catch (e) {
|
||||
// console.error("화면 목록 조회 실패", e);
|
||||
console.error("화면 목록 조회 실패", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -405,18 +491,48 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
<div className="space-y-4">
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="화면명, 코드, 테이블명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
disabled={activeTab === "trash"}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{/* 최고 관리자 전용: 회사 필터 */}
|
||||
{isSuperAdmin && (
|
||||
<div className="w-full sm:w-[200px]">
|
||||
<Select value={selectedCompanyCode} onValueChange={setSelectedCompanyCode} disabled={activeTab === "trash"}>
|
||||
<SelectTrigger className="h-10 text-sm">
|
||||
<SelectValue placeholder="전체 회사" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 회사</SelectItem>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.companyCode} value={company.companyCode}>
|
||||
{company.companyName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
key="screen-search-input" // 리렌더링 시에도 동일한 Input 유지
|
||||
placeholder="화면명, 코드, 테이블명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
disabled={activeTab === "trash"}
|
||||
/>
|
||||
{/* 검색 중 인디케이터 */}
|
||||
{isSearching && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
disabled={activeTab === "trash"}
|
||||
|
|
|
|||
|
|
@ -186,75 +186,93 @@ export function DataFilterConfigPanel({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">컬럼</Label>
|
||||
<Select
|
||||
value={filter.columnName}
|
||||
onValueChange={(value) => {
|
||||
const column = columns.find((col) => col.columnName === value);
|
||||
|
||||
console.log("🔍 컬럼 선택:", {
|
||||
columnName: value,
|
||||
input_type: column?.input_type,
|
||||
column,
|
||||
});
|
||||
|
||||
// 컬럼 타입에 따라 valueType 자동 설정
|
||||
let valueType: "static" | "category" | "code" = "static";
|
||||
if (column?.input_type === "category") {
|
||||
valueType = "category";
|
||||
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
|
||||
loadCategoryValues(value); // 카테고리 값 로드
|
||||
} else if (column?.input_type === "code") {
|
||||
valueType = "code";
|
||||
}
|
||||
|
||||
// 한 번에 모든 변경사항 적용
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, columnName: value, valueType, value: "" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
|
||||
console.log("✅ 필터 설정 업데이트:", {
|
||||
filterId: filter.id,
|
||||
columnName: value,
|
||||
valueType,
|
||||
newConfig,
|
||||
});
|
||||
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
{(col.input_type === "category" || col.input_type === "code") && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({col.input_type})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 컬럼 선택 (날짜 범위 포함이 아닐 때만 표시) */}
|
||||
{filter.operator !== "date_range_contains" && (
|
||||
<div>
|
||||
<Label className="text-xs">컬럼</Label>
|
||||
<Select
|
||||
value={filter.columnName}
|
||||
onValueChange={(value) => {
|
||||
const column = columns.find((col) => col.columnName === value);
|
||||
|
||||
console.log("🔍 컬럼 선택:", {
|
||||
columnName: value,
|
||||
input_type: column?.input_type,
|
||||
column,
|
||||
});
|
||||
|
||||
// 컬럼 타입에 따라 valueType 자동 설정
|
||||
let valueType: "static" | "category" | "code" = "static";
|
||||
if (column?.input_type === "category") {
|
||||
valueType = "category";
|
||||
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
|
||||
loadCategoryValues(value); // 카테고리 값 로드
|
||||
} else if (column?.input_type === "code") {
|
||||
valueType = "code";
|
||||
}
|
||||
|
||||
// 한 번에 모든 변경사항 적용
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, columnName: value, valueType, value: "" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
|
||||
console.log("✅ 필터 설정 업데이트:", {
|
||||
filterId: filter.id,
|
||||
columnName: value,
|
||||
valueType,
|
||||
newConfig,
|
||||
});
|
||||
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
{(col.input_type === "category" || col.input_type === "code") && (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({col.input_type})
|
||||
</span>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">연산자</Label>
|
||||
<Select
|
||||
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">
|
||||
<SelectValue />
|
||||
|
|
@ -262,6 +280,11 @@ export function DataFilterConfigPanel({
|
|||
<SelectContent>
|
||||
<SelectItem value="equals">같음 (=)</SelectItem>
|
||||
<SelectItem value="not_equals">같지 않음 (≠)</SelectItem>
|
||||
<SelectItem value="greater_than">크다 (>)</SelectItem>
|
||||
<SelectItem value="less_than">작다 (<)</SelectItem>
|
||||
<SelectItem value="greater_than_or_equal">크거나 같다 (≥)</SelectItem>
|
||||
<SelectItem value="less_than_or_equal">작거나 같다 (≤)</SelectItem>
|
||||
<SelectItem value="between">사이 (BETWEEN)</SelectItem>
|
||||
<SelectItem value="in">포함됨 (IN)</SelectItem>
|
||||
<SelectItem value="not_in">포함되지 않음 (NOT IN)</SelectItem>
|
||||
<SelectItem value="contains">포함 (LIKE %value%)</SelectItem>
|
||||
|
|
@ -269,34 +292,138 @@ export function DataFilterConfigPanel({
|
|||
<SelectItem value="ends_with">끝 (LIKE %value)</SelectItem>
|
||||
<SelectItem value="is_null">NULL</SelectItem>
|
||||
<SelectItem value="is_not_null">NOT NULL</SelectItem>
|
||||
<SelectItem value="date_range_contains">날짜 범위 포함 (기간 내)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 값 타입 선택 (카테고리/코드 컬럼만) */}
|
||||
{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>
|
||||
<Label className="text-xs">값 타입</Label>
|
||||
<Select
|
||||
value={filter.valueType}
|
||||
onValueChange={(value: any) =>
|
||||
handleFilterChange(filter.id, "valueType", value)
|
||||
}
|
||||
onValueChange={(value: any) => {
|
||||
// dynamic 선택 시 한 번에 valueType과 value를 설정
|
||||
if (value === "dynamic" && filter.operator === "date_range_contains") {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, valueType: value, value: "TODAY" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
} else {
|
||||
// static이나 다른 타입은 value를 빈 문자열로 초기화
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
filters: localConfig.filters.map((f) =>
|
||||
f.id === filter.id
|
||||
? { ...f, valueType: value, value: "" }
|
||||
: f
|
||||
),
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">직접 입력</SelectItem>
|
||||
<SelectItem value="category">카테고리 선택</SelectItem>
|
||||
<SelectItem value="code">코드 선택</SelectItem>
|
||||
{filter.operator === "date_range_contains" && (
|
||||
<SelectItem value="dynamic">동적 값 (오늘 날짜)</SelectItem>
|
||||
)}
|
||||
{isCategoryOrCodeColumn(filter.columnName) && (
|
||||
<>
|
||||
<SelectItem value="category">카테고리 선택</SelectItem>
|
||||
<SelectItem value="code">코드 선택</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 값 입력 (NULL 체크 제외) */}
|
||||
{filter.operator !== "is_null" && filter.operator !== "is_not_null" && (
|
||||
{/* 값 입력 (NULL 체크 및 date_range_contains의 dynamic 제외) */}
|
||||
{filter.operator !== "is_null" &&
|
||||
filter.operator !== "is_not_null" &&
|
||||
!(filter.operator === "date_range_contains" && filter.valueType === "dynamic") && (
|
||||
<div>
|
||||
<Label className="text-xs">값</Label>
|
||||
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
|
||||
|
|
@ -328,11 +455,22 @@ export function DataFilterConfigPanel({
|
|||
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : filter.operator === "between" ? (
|
||||
<Input
|
||||
value={Array.isArray(filter.value) ? filter.value.join(" ~ ") : filter.value}
|
||||
onChange={(e) => {
|
||||
const values = e.target.value.split("~").map((v) => v.trim());
|
||||
handleFilterChange(filter.id, "value", values.length === 2 ? values : [values[0] || "", ""]);
|
||||
}}
|
||||
placeholder="시작 ~ 종료 (예: 2025-01-01 ~ 2025-12-31)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={filter.operator === "date_range_contains" ? "date" : "text"}
|
||||
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
|
||||
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
|
||||
placeholder="필터 값 입력"
|
||||
placeholder={filter.operator === "date_range_contains" ? "비교할 날짜 선택" : "필터 값 입력"}
|
||||
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 === "between"
|
||||
? "시작과 종료 값을 ~로 구분하세요"
|
||||
: filter.operator === "date_range_contains"
|
||||
? "기간 내에 포함되는지 확인할 날짜를 선택하세요"
|
||||
: "필터링할 값을 입력하세요"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* date_range_contains의 dynamic 타입 안내 */}
|
||||
{filter.operator === "date_range_contains" && filter.valueType === "dynamic" && (
|
||||
<div className="rounded-md bg-blue-50 p-2">
|
||||
<p className="text-[10px] text-blue-700">
|
||||
ℹ️ 오늘 날짜를 기준으로 기간 내 데이터를 필터링합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,10 +42,49 @@ export const dataApi = {
|
|||
* 특정 레코드 상세 조회
|
||||
* @param tableName 테이블명
|
||||
* @param id 레코드 ID
|
||||
* @param enableEntityJoin Entity 조인 활성화 여부 (기본값: false)
|
||||
* @param groupByColumns 그룹핑 기준 컬럼들 (배열)
|
||||
*/
|
||||
getRecordDetail: async (tableName: string, id: string | number): Promise<any> => {
|
||||
const response = await apiClient.get(`/data/${tableName}/${id}`);
|
||||
return response.data?.data || response.data;
|
||||
getRecordDetail: async (
|
||||
tableName: string,
|
||||
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 rightColumn 우측 컬럼명 (외래키)
|
||||
* @param leftValue 좌측 값 (필터링)
|
||||
* @param dataFilter 데이터 필터
|
||||
* @param enableEntityJoin Entity 조인 활성화
|
||||
* @param displayColumns 표시할 컬럼 목록 (tableName.columnName 형식 포함)
|
||||
*/
|
||||
getJoinedData: async (
|
||||
leftTable: string,
|
||||
|
|
@ -62,7 +104,15 @@ export const dataApi = {
|
|||
leftColumn: string,
|
||||
rightColumn: string,
|
||||
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[]> => {
|
||||
const response = await apiClient.get(`/data/join`, {
|
||||
params: {
|
||||
|
|
@ -71,7 +121,10 @@ export const dataApi = {
|
|||
leftColumn,
|
||||
rightColumn,
|
||||
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 || {};
|
||||
|
|
@ -115,4 +168,98 @@ export const dataApi = {
|
|||
const response = await apiClient.delete(`/data/${tableName}/${id}`);
|
||||
return response.data; // success, message 포함된 전체 응답 반환
|
||||
},
|
||||
|
||||
/**
|
||||
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
||||
* @param tableName 테이블명
|
||||
* @param filterConditions 삭제 조건 (예: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" })
|
||||
*/
|
||||
deleteGroupRecords: async (
|
||||
tableName: string,
|
||||
filterConditions: Record<string, any>
|
||||
): Promise<{ success: boolean; deleted?: number; message?: string; error?: string }> => {
|
||||
try {
|
||||
console.log(`🗑️ [dataApi] 그룹 삭제 요청:`, { tableName, filterConditions });
|
||||
|
||||
const response = await apiClient.post(`/data/${tableName}/delete-group`, filterConditions);
|
||||
|
||||
console.log(`✅ [dataApi] 그룹 삭제 성공:`, response.data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [dataApi] 그룹 삭제 실패:`, error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.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 {
|
||||
console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", {
|
||||
tableName,
|
||||
tableNameType: typeof tableName,
|
||||
tableNameValue: JSON.stringify(tableName),
|
||||
parentKeys,
|
||||
recordsCount: records.length,
|
||||
});
|
||||
|
||||
const requestBody = {
|
||||
tableName,
|
||||
parentKeys,
|
||||
records,
|
||||
};
|
||||
console.log("📦 [dataApi.upsertGroupedRecords] 요청 본문 (JSON):", JSON.stringify(requestBody, null, 2));
|
||||
|
||||
const response = await apiClient.post('/data/upsert-grouped', requestBody);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("❌ [dataApi.upsertGroupedRecords] 에러:", {
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
data: error.response?.data,
|
||||
message: error.message,
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || error.message || "데이터 저장 실패",
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -71,25 +71,6 @@ export const entityJoinApi = {
|
|||
dataFilter?: any; // 🆕 데이터 필터
|
||||
} = {},
|
||||
): 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 자동 필터링 활성화
|
||||
const autoFilter = {
|
||||
enabled: true,
|
||||
|
|
@ -99,7 +80,11 @@ export const entityJoinApi = {
|
|||
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, {
|
||||
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,
|
||||
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
|
||||
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
|
||||
|
|
|
|||
|
|
@ -393,6 +393,16 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// 🆕 모든 컴포넌트의 설정 수집 (parentDataMapping 등)
|
||||
const componentConfigs: Record<string, any> = {};
|
||||
if (allComponents && Array.isArray(allComponents)) {
|
||||
for (const comp of allComponents) {
|
||||
if (comp.id && comp.componentConfig) {
|
||||
componentConfigs[comp.id] = comp.componentConfig;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const context: ButtonActionContext = {
|
||||
formData: formData || {},
|
||||
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
|
||||
|
|
@ -418,7 +428,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
// 플로우 선택된 데이터 정보 추가
|
||||
flowSelectedData,
|
||||
flowSelectedStepId,
|
||||
};
|
||||
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
|
||||
componentConfigs,
|
||||
} as ButtonActionContext;
|
||||
|
||||
// 확인이 필요한 액션인지 확인
|
||||
if (confirmationRequiredActions.includes(processedConfig.action.type)) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,429 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Plus, X, Calculator } from "lucide-react";
|
||||
import { CalculationNode, CalculationStep, AdditionalFieldDefinition } from "./types";
|
||||
|
||||
interface CalculationBuilderProps {
|
||||
steps: CalculationStep[];
|
||||
availableFields: AdditionalFieldDefinition[];
|
||||
onChange: (steps: CalculationStep[]) => void;
|
||||
}
|
||||
|
||||
export const CalculationBuilder: React.FC<CalculationBuilderProps> = ({
|
||||
steps,
|
||||
availableFields,
|
||||
onChange,
|
||||
}) => {
|
||||
const [previewValues, setPreviewValues] = useState<Record<string, number>>({});
|
||||
|
||||
// 새 단계 추가
|
||||
const addStep = () => {
|
||||
const newStep: CalculationStep = {
|
||||
id: `step_${Date.now()}`,
|
||||
label: `단계 ${steps.length + 1}`,
|
||||
expression: {
|
||||
type: "field",
|
||||
fieldName: "",
|
||||
},
|
||||
};
|
||||
onChange([...steps, newStep]);
|
||||
};
|
||||
|
||||
// 단계 삭제
|
||||
const removeStep = (stepId: string) => {
|
||||
onChange(steps.filter((s) => s.id !== stepId));
|
||||
};
|
||||
|
||||
// 단계 업데이트
|
||||
const updateStep = (stepId: string, updates: Partial<CalculationStep>) => {
|
||||
onChange(
|
||||
steps.map((s) => (s.id === stepId ? { ...s, ...updates } : s))
|
||||
);
|
||||
};
|
||||
|
||||
// 간단한 표현식 렌더링
|
||||
const renderSimpleExpression = (step: CalculationStep) => {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 왼쪽 항 */}
|
||||
<Select
|
||||
value={step.expression.type === "field" ? step.expression.fieldName || "" : step.expression.type}
|
||||
onValueChange={(value) => {
|
||||
if (value === "previous") {
|
||||
updateStep(step.id, {
|
||||
expression: { type: "previous" },
|
||||
});
|
||||
} else if (value === "constant") {
|
||||
updateStep(step.id, {
|
||||
expression: { type: "constant", value: 0 },
|
||||
});
|
||||
} else {
|
||||
updateStep(step.id, {
|
||||
expression: { type: "field", fieldName: value },
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||
<SelectValue placeholder="항목 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="previous">이전 결과</SelectItem>
|
||||
<SelectItem value="constant">상수값</SelectItem>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{step.expression.type === "constant" && (
|
||||
<Input
|
||||
type="number"
|
||||
value={step.expression.value || 0}
|
||||
onChange={(e) => {
|
||||
updateStep(step.id, {
|
||||
expression: {
|
||||
...step.expression,
|
||||
value: parseFloat(e.target.value) || 0,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-8 w-24 text-xs"
|
||||
placeholder="값"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 연산 추가 버튼 */}
|
||||
{step.expression.type !== "operation" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const currentExpression = step.expression;
|
||||
updateStep(step.id, {
|
||||
expression: {
|
||||
type: "operation",
|
||||
operator: "+",
|
||||
left: currentExpression,
|
||||
right: { type: "constant", value: 0 },
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
연산 추가
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 연산식 */}
|
||||
{step.expression.type === "operation" && (
|
||||
<div className="space-y-2 border-l-2 border-primary pl-3 ml-2">
|
||||
{renderOperationExpression(step)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 연산식 렌더링
|
||||
const renderOperationExpression = (step: CalculationStep) => {
|
||||
if (step.expression.type !== "operation") return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 왼쪽 항 */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{renderNodeLabel(step.expression.left)}
|
||||
</div>
|
||||
|
||||
{/* 연산자 */}
|
||||
<Select
|
||||
value={step.expression.operator || "+"}
|
||||
onValueChange={(value) => {
|
||||
updateStep(step.id, {
|
||||
expression: {
|
||||
...step.expression,
|
||||
operator: value as any,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-16 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="+">+</SelectItem>
|
||||
<SelectItem value="-">-</SelectItem>
|
||||
<SelectItem value="*">×</SelectItem>
|
||||
<SelectItem value="/">÷</SelectItem>
|
||||
<SelectItem value="%">%</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 오른쪽 항 */}
|
||||
<Select
|
||||
value={
|
||||
step.expression.right?.type === "field"
|
||||
? step.expression.right.fieldName || ""
|
||||
: step.expression.right?.type || ""
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
if (value === "constant") {
|
||||
updateStep(step.id, {
|
||||
expression: {
|
||||
...step.expression,
|
||||
right: { type: "constant", value: 0 },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
updateStep(step.id, {
|
||||
expression: {
|
||||
...step.expression,
|
||||
right: { type: "field", fieldName: value },
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue placeholder="항목 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="constant">상수값</SelectItem>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{step.expression.right?.type === "constant" && (
|
||||
<Input
|
||||
type="number"
|
||||
value={step.expression.right.value || 0}
|
||||
onChange={(e) => {
|
||||
updateStep(step.id, {
|
||||
expression: {
|
||||
...step.expression,
|
||||
right: {
|
||||
...step.expression.right!,
|
||||
value: parseFloat(e.target.value) || 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-7 w-24 text-xs"
|
||||
placeholder="값"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 노드 라벨 표시
|
||||
const renderNodeLabel = (node?: CalculationNode): string => {
|
||||
if (!node) return "";
|
||||
|
||||
switch (node.type) {
|
||||
case "field":
|
||||
const field = availableFields.find((f) => f.name === node.fieldName);
|
||||
return field?.label || node.fieldName || "필드";
|
||||
case "constant":
|
||||
return String(node.value || 0);
|
||||
case "previous":
|
||||
return "이전 결과";
|
||||
case "operation":
|
||||
const left = renderNodeLabel(node.left);
|
||||
const right = renderNodeLabel(node.right);
|
||||
const op = node.operator === "*" ? "×" : node.operator === "/" ? "÷" : node.operator;
|
||||
return `(${left} ${op} ${right})`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
// 함수 적용 UI
|
||||
const renderFunctionStep = (step: CalculationStep) => {
|
||||
if (step.expression.type !== "function") return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={step.expression.functionName || "round"}
|
||||
onValueChange={(value) => {
|
||||
updateStep(step.id, {
|
||||
expression: {
|
||||
type: "function",
|
||||
functionName: value as any,
|
||||
params: [{ type: "previous" }],
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32 text-xs">
|
||||
<SelectValue placeholder="함수 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="round">반올림</SelectItem>
|
||||
<SelectItem value="floor">절삭</SelectItem>
|
||||
<SelectItem value="ceil">올림</SelectItem>
|
||||
<SelectItem value="abs">절댓값</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(step.expression.functionName === "round" ||
|
||||
step.expression.functionName === "floor" ||
|
||||
step.expression.functionName === "ceil") && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">단위:</span>
|
||||
<Select
|
||||
value={
|
||||
step.expression.params?.[1]?.type === "field"
|
||||
? step.expression.params[1].fieldName || ""
|
||||
: String(step.expression.params?.[1]?.value || "1")
|
||||
}
|
||||
onValueChange={(value) => {
|
||||
const isField = availableFields.some((f) => f.name === value);
|
||||
updateStep(step.id, {
|
||||
expression: {
|
||||
...step.expression,
|
||||
params: [
|
||||
{ type: "previous" },
|
||||
isField
|
||||
? { type: "field", fieldName: value }
|
||||
: { type: "constant", value: parseFloat(value) },
|
||||
],
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-32 text-xs">
|
||||
<SelectValue placeholder="단위" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="1000">1,000</SelectItem>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold">계산식 빌더</Label>
|
||||
<Button variant="outline" size="sm" onClick={addStep} className="h-7 text-xs">
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
단계 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{steps.length === 0 ? (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-6 text-center">
|
||||
<Calculator className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
계산 단계를 추가하여 계산식을 만드세요
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, idx) => (
|
||||
<Card key={step.id} className="border-primary/30">
|
||||
<CardHeader className="pb-2 pt-3 px-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-xs font-medium">
|
||||
{step.label || `단계 ${idx + 1}`}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
value={step.label}
|
||||
onChange={(e) => updateStep(step.id, { label: e.target.value })}
|
||||
placeholder={`단계 ${idx + 1}`}
|
||||
className="h-6 w-24 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeStep(step.id)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pb-3 px-3">
|
||||
{step.expression.type === "function"
|
||||
? renderFunctionStep(step)
|
||||
: renderSimpleExpression(step)}
|
||||
|
||||
{/* 함수 적용 버튼 */}
|
||||
{step.expression.type !== "function" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
updateStep(step.id, {
|
||||
expression: {
|
||||
type: "function",
|
||||
functionName: "round",
|
||||
params: [{ type: "previous" }, { type: "constant", value: 1 }],
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-7 text-xs mt-2"
|
||||
>
|
||||
함수 적용
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 미리보기 */}
|
||||
{steps.length > 0 && (
|
||||
<Card className="bg-muted/30">
|
||||
<CardContent className="py-3 px-3">
|
||||
<div className="text-xs">
|
||||
<span className="font-semibold">계산식:</span>
|
||||
<div className="mt-1 font-mono text-muted-foreground">
|
||||
{steps.map((step, idx) => (
|
||||
<div key={step.id}>
|
||||
{idx + 1}. {renderNodeLabel(step.expression)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -216,6 +216,111 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
// 🆕 모달 데이터를 ItemData 구조로 변환 (그룹별 구조)
|
||||
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];
|
||||
|
||||
// 🔧 값이 없으면 기본값 사용 (false, 0, "" 등 falsy 값도 유효한 값으로 처리)
|
||||
if (fieldValue === undefined || fieldValue === null) {
|
||||
// 기본값이 있으면 사용, 없으면 필드 타입에 따라 기본값 설정
|
||||
if (field.defaultValue !== undefined) {
|
||||
fieldValue = field.defaultValue;
|
||||
} else if (field.type === "checkbox") {
|
||||
fieldValue = false; // checkbox는 기본값 false
|
||||
} else {
|
||||
// 다른 타입은 null로 유지 (필수 필드가 아니면 표시 안 됨)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 날짜 타입이면 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) {
|
||||
console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData);
|
||||
|
||||
|
|
@ -253,25 +358,225 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modalData, component.id, componentConfig.fieldGroups]); // onFormDataChange는 의존성에서 제외
|
||||
}, [modalData, component.id, componentConfig.fieldGroups, formData]); // formData 의존성 추가
|
||||
|
||||
// 🆕 Cartesian Product 생성 함수 (items에서 모든 그룹의 조합을 생성)
|
||||
const generateCartesianProduct = useCallback((itemsList: ItemData[]): Record<string, any>[] => {
|
||||
const allRecords: Record<string, any>[] = [];
|
||||
const groups = componentConfig.fieldGroups || [];
|
||||
const additionalFields = componentConfig.additionalFields || [];
|
||||
|
||||
itemsList.forEach((item) => {
|
||||
// 각 그룹의 엔트리 배열들을 준비
|
||||
const groupEntriesArrays: GroupEntry[][] = groups.map(group => item.fieldGroups[group.id] || []);
|
||||
|
||||
// Cartesian Product 재귀 함수
|
||||
const cartesian = (arrays: GroupEntry[][], currentIndex: number, currentCombination: Record<string, any>) => {
|
||||
if (currentIndex === arrays.length) {
|
||||
// 모든 그룹을 순회했으면 조합 완성
|
||||
allRecords.push({ ...currentCombination });
|
||||
return;
|
||||
}
|
||||
|
||||
const currentGroupEntries = arrays[currentIndex];
|
||||
if (currentGroupEntries.length === 0) {
|
||||
// 현재 그룹에 데이터가 없으면 빈 조합으로 다음 그룹 진행
|
||||
cartesian(arrays, currentIndex + 1, currentCombination);
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 그룹의 각 엔트리마다 재귀
|
||||
currentGroupEntries.forEach(entry => {
|
||||
const newCombination = { ...currentCombination };
|
||||
|
||||
// 현재 그룹의 필드들을 조합에 추가
|
||||
const groupFields = additionalFields.filter(f => f.groupId === groups[currentIndex].id);
|
||||
groupFields.forEach(field => {
|
||||
if (entry[field.name] !== undefined) {
|
||||
newCombination[field.name] = entry[field.name];
|
||||
}
|
||||
});
|
||||
|
||||
cartesian(arrays, currentIndex + 1, newCombination);
|
||||
});
|
||||
};
|
||||
|
||||
// 재귀 시작
|
||||
cartesian(groupEntriesArrays, 0, {});
|
||||
});
|
||||
|
||||
console.log("🔀 [generateCartesianProduct] 생성된 레코드:", {
|
||||
count: allRecords.length,
|
||||
records: allRecords,
|
||||
});
|
||||
|
||||
return allRecords;
|
||||
}, [componentConfig.fieldGroups, componentConfig.additionalFields]);
|
||||
|
||||
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
|
||||
useEffect(() => {
|
||||
const handleSaveRequest = () => {
|
||||
if (items.length > 0 && onFormDataChange) {
|
||||
const dataToSave = { [component.id || "selected_items"]: items };
|
||||
console.log("📝 [SelectedItemsDetailInput] 저장 요청 시 데이터 전달:", dataToSave);
|
||||
onFormDataChange(dataToSave);
|
||||
const handleSaveRequest = async (event: Event) => {
|
||||
// component.id를 문자열로 안전하게 변환
|
||||
const componentKey = String(component.id || "selected_items");
|
||||
|
||||
console.log("🔔 [SelectedItemsDetailInput] beforeFormSave 이벤트 수신!", {
|
||||
itemsCount: items.length,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
componentId: component.id,
|
||||
componentIdType: typeof component.id,
|
||||
componentKey,
|
||||
});
|
||||
|
||||
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 모드로 저장 시작");
|
||||
console.log("📋 [SelectedItemsDetailInput] componentConfig:", {
|
||||
targetTable: componentConfig.targetTable,
|
||||
parentDataMapping: componentConfig.parentDataMapping,
|
||||
fieldGroups: componentConfig.fieldGroups,
|
||||
additionalFields: componentConfig.additionalFields,
|
||||
});
|
||||
|
||||
// 부모 키 추출 (parentDataMapping에서)
|
||||
const parentKeys: Record<string, any> = {};
|
||||
|
||||
// formData 또는 items[0].originalData에서 부모 데이터 가져오기
|
||||
// formData가 배열이면 첫 번째 항목 사용
|
||||
let sourceData: any = formData;
|
||||
if (Array.isArray(formData) && formData.length > 0) {
|
||||
sourceData = formData[0];
|
||||
} else if (!formData) {
|
||||
sourceData = items[0]?.originalData || {};
|
||||
}
|
||||
|
||||
console.log("📦 [SelectedItemsDetailInput] 부모 데이터 소스:", {
|
||||
formDataType: Array.isArray(formData) ? "배열" : typeof formData,
|
||||
sourceData,
|
||||
sourceDataKeys: Object.keys(sourceData),
|
||||
parentDataMapping: componentConfig.parentDataMapping,
|
||||
});
|
||||
|
||||
console.log("🔍 [SelectedItemsDetailInput] sourceData 전체 내용 (JSON):", JSON.stringify(sourceData, null, 2));
|
||||
|
||||
componentConfig.parentDataMapping.forEach((mapping) => {
|
||||
const value = sourceData[mapping.sourceField];
|
||||
if (value !== undefined && value !== null) {
|
||||
parentKeys[mapping.targetField] = value;
|
||||
} else {
|
||||
console.warn(`⚠️ [SelectedItemsDetailInput] 부모 키 누락: ${mapping.sourceField} → ${mapping.targetField}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log("🔑 [SelectedItemsDetailInput] 부모 키:", parentKeys);
|
||||
|
||||
// items를 Cartesian Product로 변환
|
||||
const records = generateCartesianProduct(items);
|
||||
|
||||
console.log("📦 [SelectedItemsDetailInput] UPSERT 레코드:", {
|
||||
parentKeys,
|
||||
recordCount: records.length,
|
||||
records,
|
||||
});
|
||||
|
||||
// targetTable 검증
|
||||
if (!componentConfig.targetTable) {
|
||||
console.error("❌ [SelectedItemsDetailInput] targetTable이 설정되지 않았습니다!");
|
||||
window.dispatchEvent(new CustomEvent("formSaveError", {
|
||||
detail: { message: "대상 테이블이 설정되지 않았습니다." },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🎯 [SelectedItemsDetailInput] targetTable:", componentConfig.targetTable);
|
||||
console.log("📡 [SelectedItemsDetailInput] UPSERT API 호출 직전:", {
|
||||
tableName: componentConfig.targetTable,
|
||||
tableNameType: typeof componentConfig.targetTable,
|
||||
tableNameLength: componentConfig.targetTable?.length,
|
||||
parentKeys,
|
||||
recordsCount: records.length,
|
||||
});
|
||||
|
||||
// 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] 저장 데이터 준비:", {
|
||||
key: componentKey,
|
||||
itemsCount: items.length,
|
||||
firstItem: items[0],
|
||||
});
|
||||
|
||||
// ✅ CustomEvent의 detail에 데이터 첨부
|
||||
if (event instanceof CustomEvent && event.detail) {
|
||||
// context.formData에 직접 추가
|
||||
event.detail.formData[componentKey] = items;
|
||||
console.log("✅ [SelectedItemsDetailInput] context.formData에 데이터 직접 추가 완료");
|
||||
}
|
||||
|
||||
// 기존 onFormDataChange도 호출 (호환성)
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(componentKey, items);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 저장 버튼 클릭 시 데이터 수집
|
||||
window.addEventListener("beforeFormSave", handleSaveRequest);
|
||||
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeFormSave", handleSaveRequest);
|
||||
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||
};
|
||||
}, [items, component.id, onFormDataChange]);
|
||||
}, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct]);
|
||||
|
||||
// 스타일 계산
|
||||
const componentStyle: React.CSSProperties = {
|
||||
|
|
@ -342,6 +647,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
// 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName
|
||||
const handleFieldChange = useCallback((itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => {
|
||||
console.log("📝 [handleFieldChange] 필드 값 변경:", {
|
||||
itemId,
|
||||
groupId,
|
||||
entryId,
|
||||
fieldName,
|
||||
value,
|
||||
});
|
||||
|
||||
setItems((prevItems) => {
|
||||
return prevItems.map((item) => {
|
||||
if (item.id !== itemId) return item;
|
||||
|
|
@ -357,6 +670,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
[fieldName]: value,
|
||||
};
|
||||
|
||||
console.log("✅ [handleFieldChange] Entry 업데이트:", {
|
||||
beforeKeys: Object.keys(updatedEntries[existingEntryIndex]),
|
||||
afterKeys: Object.keys(updatedEntry),
|
||||
updatedEntry,
|
||||
});
|
||||
|
||||
// 🆕 가격 관련 필드가 변경되면 자동 계산
|
||||
if (componentConfig.autoCalculation) {
|
||||
const { inputFields, targetField } = componentConfig.autoCalculation;
|
||||
|
|
@ -723,7 +1042,22 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
? f.groupId === groupId
|
||||
: 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 설정대로 렌더링
|
||||
|
|
@ -800,6 +1134,15 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
// 값이 있는 경우, 형식에 맞게 표시
|
||||
let formattedValue = fieldValue;
|
||||
|
||||
// 🔧 자동 날짜 감지 (format 설정 없어도 ISO 날짜 자동 변환)
|
||||
const strValue = String(fieldValue);
|
||||
const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/);
|
||||
if (isoDateMatch && !displayItem.format) {
|
||||
const [, year, month, day] = isoDateMatch;
|
||||
formattedValue = `${year}.${month}.${day}`;
|
||||
}
|
||||
|
||||
switch (displayItem.format) {
|
||||
case "currency":
|
||||
// 천 단위 구분
|
||||
|
|
@ -811,13 +1154,22 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
case "date":
|
||||
// YYYY.MM.DD 형식
|
||||
if (fieldValue) {
|
||||
const date = new Date(fieldValue);
|
||||
if (!isNaN(date.getTime())) {
|
||||
formattedValue = date.toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).replace(/\. /g, ".").replace(/\.$/, "");
|
||||
// 날짜 문자열을 직접 파싱 (타임존 문제 방지)
|
||||
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);
|
||||
if (!isNaN(date.getTime())) {
|
||||
formattedValue = date.toLocaleDateString("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).replace(/\. /g, ".").replace(/\.$/, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
|
@ -839,9 +1191,19 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
break;
|
||||
}
|
||||
|
||||
// 🔧 마지막 안전장치: formattedValue가 여전히 ISO 형식이면 한번 더 변환
|
||||
let finalValue = formattedValue;
|
||||
if (typeof formattedValue === 'string') {
|
||||
const isoCheck = formattedValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/);
|
||||
if (isoCheck) {
|
||||
const [, year, month, day] = isoCheck;
|
||||
finalValue = `${year}.${month}.${day}`;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={displayItem.id} className={styleClasses} style={inlineStyle}>
|
||||
{displayItem.label}{formattedValue}
|
||||
{displayItem.label}{finalValue}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1173,7 +1535,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
{/* 🆕 이미 입력된 상세 항목들 표시 */}
|
||||
{editingItem.details.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium">입력된 품번 ({editingItem.details.length}개)</div>
|
||||
<div className="text-xs font-medium">입력된 항목 ({editingItem.details.length}개)</div>
|
||||
{editingItem.details.map((detail, idx) => (
|
||||
<div key={detail.id} className="flex items-center justify-between border rounded p-2 text-xs bg-muted/30">
|
||||
<span>{idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}</span>
|
||||
|
|
@ -1527,7 +1889,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
{/* 🆕 이미 입력된 상세 항목들 표시 */}
|
||||
{editingItem.details.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium">입력된 품번 ({editingItem.details.length}개)</div>
|
||||
<div className="text-xs font-medium">입력된 항목 ({editingItem.details.length}개)</div>
|
||||
{editingItem.details.map((detail, idx) => (
|
||||
<div key={detail.id} className="flex items-center justify-between border rounded p-2 text-xs bg-muted/30">
|
||||
<span>{idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}</span>
|
||||
|
|
@ -1595,15 +1957,9 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
{index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")}
|
||||
</div>
|
||||
{/* 입력된 값 표시 */}
|
||||
{item.additionalData && Object.keys(item.additionalData).length > 0 && (
|
||||
<div className="text-xs text-primary mt-1">
|
||||
품번: {item.additionalData.customer_item_name} / 품명: {item.additionalData.customer_item_code}
|
||||
</div>
|
||||
)}
|
||||
{componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ import { Card, CardContent } from "@/components/ui/card";
|
|||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Plus, X, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat } from "./types";
|
||||
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 { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSecondLevelMenus, getCategoryColumns, getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { CalculationBuilder } from "./CalculationBuilder";
|
||||
|
||||
export interface SelectedItemsDetailInputConfigPanelProps {
|
||||
config: SelectedItemsDetailInputConfig;
|
||||
|
|
@ -68,6 +69,36 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
const [secondLevelMenus, setSecondLevelMenus] = useState<Array<{ menuObjid: number; menuName: string; parentMenuName: string }>>([]);
|
||||
const [categoryColumns, setCategoryColumns] = useState<Record<string, Array<{ columnName: string; columnLabel: string }>>>({});
|
||||
const [categoryValues, setCategoryValues] = useState<Record<string, Array<{ valueCode: string; valueLabel: string }>>>({});
|
||||
|
||||
// 🆕 부모 데이터 매핑: 각 매핑별 소스 테이블 컬럼 상태
|
||||
const [mappingSourceColumns, setMappingSourceColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
|
||||
|
||||
// 🆕 소스 테이블 선택 시 컬럼 로드
|
||||
const loadMappingSourceColumns = async (tableName: string, mappingIndex: number) => {
|
||||
try {
|
||||
console.log(`🔍 [매핑 ${mappingIndex}] 소스 테이블 컬럼 로드:`, tableName);
|
||||
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getColumnList(tableName);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const columns = response.data.columns || [];
|
||||
setMappingSourceColumns(prev => ({
|
||||
...prev,
|
||||
[mappingIndex]: columns.map((col: any) => ({
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
}))
|
||||
}));
|
||||
console.log(`✅ [매핑 ${mappingIndex}] 컬럼 로드 성공:`, columns.length);
|
||||
} else {
|
||||
console.error(`❌ [매핑 ${mappingIndex}] 컬럼 로드 실패:`, response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [매핑 ${mappingIndex}] 컬럼 로드 오류:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// 2레벨 메뉴 목록 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -1127,132 +1158,223 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<div className="space-y-3 rounded-lg border p-3 sm:p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold sm:text-sm">자동 계산 설정</Label>
|
||||
<Checkbox
|
||||
id="enable-auto-calc"
|
||||
checked={!!config.autoCalculation}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
handleChange("autoCalculation", {
|
||||
targetField: "calculated_price",
|
||||
inputFields: {
|
||||
basePrice: "current_unit_price",
|
||||
discountType: "discount_type",
|
||||
discountValue: "discount_value",
|
||||
roundingType: "rounding_type",
|
||||
roundingUnit: "rounding_unit_value",
|
||||
},
|
||||
calculationType: "price",
|
||||
});
|
||||
} else {
|
||||
handleChange("autoCalculation", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Checkbox
|
||||
id="enable-auto-calc"
|
||||
checked={!!config.autoCalculation}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
handleChange("autoCalculation", {
|
||||
targetField: "",
|
||||
mode: "template",
|
||||
inputFields: {
|
||||
basePrice: "",
|
||||
discountType: "",
|
||||
discountValue: "",
|
||||
roundingType: "",
|
||||
roundingUnit: "",
|
||||
},
|
||||
calculationType: "price",
|
||||
valueMapping: {},
|
||||
calculationSteps: [],
|
||||
});
|
||||
} else {
|
||||
handleChange("autoCalculation", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.autoCalculation && (
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
{/* 계산 모드 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">계산 결과 필드</Label>
|
||||
<Input
|
||||
value={config.autoCalculation.targetField || ""}
|
||||
onChange={(e) => handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
targetField: e.target.value,
|
||||
})}
|
||||
placeholder="calculated_price"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Label className="text-[10px] sm:text-xs">계산 방식</Label>
|
||||
<Select
|
||||
value={config.autoCalculation.mode || "template"}
|
||||
onValueChange={(value: "template" | "custom") => {
|
||||
handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
mode: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="template">템플릿 (가격 계산)</SelectItem>
|
||||
<SelectItem value="custom">커스텀 (계산식 빌더)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">기준 단가 필드</Label>
|
||||
<Input
|
||||
value={config.autoCalculation.inputFields?.basePrice || ""}
|
||||
onChange={(e) => handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
inputFields: {
|
||||
...config.autoCalculation.inputFields,
|
||||
basePrice: e.target.value,
|
||||
},
|
||||
})}
|
||||
placeholder="current_unit_price"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{/* 템플릿 모드 */}
|
||||
{config.autoCalculation.mode === "template" && (
|
||||
<>
|
||||
{/* 계산 필드 선택 */}
|
||||
<div className="space-y-2 border-t pt-2 mt-2">
|
||||
<Label className="text-[10px] font-semibold sm:text-xs">계산에 사용할 필드 선택</Label>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 계산 결과 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">계산 결과 필드</Label>
|
||||
<Select
|
||||
value={config.autoCalculation.targetField || ""}
|
||||
onValueChange={(value) => handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
targetField: value,
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(config.additionalFields || []).map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">할인 방식</Label>
|
||||
<Input
|
||||
value={config.autoCalculation.inputFields?.discountType || ""}
|
||||
onChange={(e) => handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
inputFields: {
|
||||
...config.autoCalculation.inputFields,
|
||||
discountType: e.target.value,
|
||||
},
|
||||
})}
|
||||
placeholder="discount_type"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{/* 기준 단가 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">기준 단가 필드</Label>
|
||||
<Select
|
||||
value={config.autoCalculation.inputFields?.basePrice || ""}
|
||||
onValueChange={(value) => handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
inputFields: {
|
||||
...config.autoCalculation.inputFields,
|
||||
basePrice: value,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(config.additionalFields || []).map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">할인값</Label>
|
||||
<Input
|
||||
value={config.autoCalculation.inputFields?.discountValue || ""}
|
||||
onChange={(e) => handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
inputFields: {
|
||||
...config.autoCalculation.inputFields,
|
||||
discountValue: e.target.value,
|
||||
},
|
||||
})}
|
||||
placeholder="discount_value"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* 할인 방식 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">할인 방식</Label>
|
||||
<Select
|
||||
value={config.autoCalculation.inputFields?.discountType || ""}
|
||||
onValueChange={(value) => handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
inputFields: {
|
||||
...config.autoCalculation.inputFields,
|
||||
discountType: value,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(config.additionalFields || []).map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 할인값 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">할인값</Label>
|
||||
<Select
|
||||
value={config.autoCalculation.inputFields?.discountValue || ""}
|
||||
onValueChange={(value) => handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
inputFields: {
|
||||
...config.autoCalculation.inputFields,
|
||||
discountValue: value,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(config.additionalFields || []).map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{/* 반올림 방식 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">반올림 방식</Label>
|
||||
<Select
|
||||
value={config.autoCalculation.inputFields?.roundingType || ""}
|
||||
onValueChange={(value) => handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
inputFields: {
|
||||
...config.autoCalculation.inputFields,
|
||||
roundingType: value,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(config.additionalFields || []).map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 반올림 단위 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">반올림 단위</Label>
|
||||
<Select
|
||||
value={config.autoCalculation.inputFields?.roundingUnit || ""}
|
||||
onValueChange={(value) => handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
inputFields: {
|
||||
...config.autoCalculation.inputFields,
|
||||
roundingUnit: value,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(config.additionalFields || []).map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">반올림 방식</Label>
|
||||
<Input
|
||||
value={config.autoCalculation.inputFields?.roundingType || ""}
|
||||
onChange={(e) => handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
inputFields: {
|
||||
...config.autoCalculation.inputFields,
|
||||
roundingType: e.target.value,
|
||||
},
|
||||
})}
|
||||
placeholder="rounding_type"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">반올림 단위</Label>
|
||||
<Input
|
||||
value={config.autoCalculation.inputFields?.roundingUnit || ""}
|
||||
onChange={(e) => handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
inputFields: {
|
||||
...config.autoCalculation.inputFields,
|
||||
roundingUnit: e.target.value,
|
||||
},
|
||||
})}
|
||||
placeholder="rounding_unit_value"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[9px] text-amber-600 sm:text-[10px]">
|
||||
💡 위 필드명들이 추가 입력 필드에 있어야 자동 계산이 작동합니다
|
||||
</p>
|
||||
|
||||
{/* 카테고리 값 매핑 */}
|
||||
<div className="space-y-3 border-t pt-3 mt-3">
|
||||
<Label className="text-[10px] font-semibold sm:text-xs">카테고리 값 매핑</Label>
|
||||
|
|
@ -1591,6 +1713,24 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
💡 1단계: 메뉴 선택 → 2단계: 카테고리 선택 → 3단계: 값 매핑
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 커스텀 모드 (계산식 빌더) */}
|
||||
{config.autoCalculation.mode === "custom" && (
|
||||
<div className="space-y-2 border-t pt-2 mt-2">
|
||||
<CalculationBuilder
|
||||
steps={config.autoCalculation.calculationSteps || []}
|
||||
availableFields={config.additionalFields || []}
|
||||
onChange={(steps) => {
|
||||
handleChange("autoCalculation", {
|
||||
...config.autoCalculation,
|
||||
calculationSteps: steps,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1631,6 +1771,294 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 부모 데이터 매핑 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold sm:text-sm">부모 데이터 매핑</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
const newMapping = {
|
||||
sourceTable: "", // 사용자가 선택
|
||||
sourceField: "",
|
||||
targetField: "",
|
||||
defaultValue: undefined,
|
||||
};
|
||||
handleChange("parentDataMapping", [
|
||||
...(config.parentDataMapping || []),
|
||||
newMapping,
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-[9px] text-muted-foreground sm:text-[10px]">
|
||||
이전 화면(거래처 선택 등)에서 넘어온 데이터를 자동으로 매핑합니다.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{(config.parentDataMapping || []).map((mapping, index) => (
|
||||
<Card key={index} className="p-3">
|
||||
<div className="space-y-2">
|
||||
{/* 소스 테이블 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">소스 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs font-normal"
|
||||
>
|
||||
{mapping.sourceTable
|
||||
? allTables.find((t) => t.tableName === mapping.sourceTable)?.displayName ||
|
||||
mapping.sourceTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{allTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={table.tableName}
|
||||
onSelect={(currentValue) => {
|
||||
const updated = [...(config.parentDataMapping || [])];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
sourceTable: currentValue,
|
||||
sourceField: "", // 테이블 변경 시 필드 초기화
|
||||
};
|
||||
handleChange("parentDataMapping", updated);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
if (currentValue) {
|
||||
loadMappingSourceColumns(currentValue, index);
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mapping.sourceTable === table.tableName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{table.displayName || table.tableName}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[8px] text-muted-foreground">
|
||||
품목, 거래처, 사용자 등 데이터를 가져올 테이블을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 원본 필드 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">원본 필드</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs font-normal"
|
||||
disabled={!mapping.sourceTable}
|
||||
>
|
||||
{mapping.sourceField
|
||||
? mappingSourceColumns[index]?.find((c) => c.columnName === mapping.sourceField)
|
||||
?.columnLabel || mapping.sourceField
|
||||
: "컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
{!mapping.sourceTable ? (
|
||||
<CommandEmpty className="text-xs">소스 테이블을 먼저 선택하세요</CommandEmpty>
|
||||
) : !mappingSourceColumns[index] || mappingSourceColumns[index].length === 0 ? (
|
||||
<CommandEmpty className="text-xs">컬럼 로딩 중...</CommandEmpty>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mappingSourceColumns[index].map((col) => {
|
||||
const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase();
|
||||
return (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={searchValue}
|
||||
onSelect={() => {
|
||||
const updated = [...(config.parentDataMapping || [])];
|
||||
updated[index] = { ...updated[index], sourceField: col.columnName };
|
||||
handleChange("parentDataMapping", updated);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mapping.sourceField === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{col.columnLabel || col.columnName}</span>
|
||||
{col.dataType && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{col.dataType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 저장 필드 (현재 화면 테이블 컬럼) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">저장 필드 (현재 테이블)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs font-normal"
|
||||
disabled={targetTableColumns.length === 0}
|
||||
>
|
||||
{mapping.targetField
|
||||
? targetTableColumns.find((c) => c.columnName === mapping.targetField)?.columnLabel ||
|
||||
mapping.targetField
|
||||
: "저장 테이블 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
{targetTableColumns.length === 0 ? (
|
||||
<CommandEmpty className="text-xs">저장 테이블을 먼저 선택하세요</CommandEmpty>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{targetTableColumns.map((col) => {
|
||||
const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase();
|
||||
return (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={searchValue}
|
||||
onSelect={() => {
|
||||
const updated = [...(config.parentDataMapping || [])];
|
||||
updated[index] = { ...updated[index], targetField: col.columnName };
|
||||
handleChange("parentDataMapping", updated);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
mapping.targetField === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{col.columnLabel || col.columnName}</span>
|
||||
{col.dataType && (
|
||||
<span className="text-[10px] text-muted-foreground">{col.dataType}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 기본값 (선택사항) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">기본값 (선택사항)</Label>
|
||||
<Input
|
||||
value={mapping.defaultValue || ""}
|
||||
onChange={(e) => {
|
||||
const updated = [...(config.parentDataMapping || [])];
|
||||
updated[index] = { ...updated[index], defaultValue: e.target.value };
|
||||
handleChange("parentDataMapping", updated);
|
||||
}}
|
||||
placeholder="값이 없을 때 사용할 기본값"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-full text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
const updated = (config.parentDataMapping || []).filter((_, i) => i !== index);
|
||||
handleChange("parentDataMapping", updated);
|
||||
}}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(config.parentDataMapping || []).length === 0 && (
|
||||
<p className="text-center text-[10px] text-muted-foreground py-4">
|
||||
매핑 설정이 없습니다. "추가" 버튼을 클릭하여 설정하세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 예시 */}
|
||||
<div className="rounded-lg bg-green-50 p-2 text-xs">
|
||||
<p className="mb-1 text-[10px] font-medium text-green-900">💡 예시</p>
|
||||
<div className="space-y-1 text-[9px] text-green-700">
|
||||
<p><strong>매핑 1: 거래처 ID</strong></p>
|
||||
<p className="ml-2">• 소스 테이블: <code className="bg-green-100 px-1">customer_mng</code></p>
|
||||
<p className="ml-2">• 원본 필드: <code className="bg-green-100 px-1">id</code> → 저장 필드: <code className="bg-green-100 px-1">customer_id</code></p>
|
||||
|
||||
<p className="mt-1"><strong>매핑 2: 품목 ID</strong></p>
|
||||
<p className="ml-2">• 소스 테이블: <code className="bg-green-100 px-1">item_info</code></p>
|
||||
<p className="ml-2">• 원본 필드: <code className="bg-green-100 px-1">id</code> → 저장 필드: <code className="bg-green-100 px-1">item_id</code></p>
|
||||
|
||||
<p className="mt-1"><strong>매핑 3: 품목 기준단가</strong></p>
|
||||
<p className="ml-2">• 소스 테이블: <code className="bg-green-100 px-1">item_info</code></p>
|
||||
<p className="ml-2">• 원본 필드: <code className="bg-green-100 px-1">standard_price</code> → 저장 필드: <code className="bg-green-100 px-1">base_price</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용 예시 */}
|
||||
<div className="rounded-lg bg-blue-50 p-2 text-xs sm:p-3 sm:text-sm">
|
||||
<p className="mb-1 font-medium text-blue-900">💡 사용 예시</p>
|
||||
|
|
|
|||
|
|
@ -58,37 +58,81 @@ export interface FieldGroup {
|
|||
displayItems?: DisplayItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 계산식 노드 타입
|
||||
*/
|
||||
export type CalculationNodeType = "field" | "constant" | "operation" | "function" | "previous";
|
||||
|
||||
export interface CalculationNode {
|
||||
type: CalculationNodeType;
|
||||
// field: 필드명
|
||||
fieldName?: string;
|
||||
// constant: 상수값
|
||||
value?: number;
|
||||
// operation: 연산
|
||||
operator?: "+" | "-" | "*" | "/" | "%" | "^";
|
||||
left?: CalculationNode;
|
||||
right?: CalculationNode;
|
||||
// function: 함수
|
||||
functionName?: "round" | "floor" | "ceil" | "abs" | "max" | "min";
|
||||
params?: CalculationNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 계산 단계
|
||||
*/
|
||||
export interface CalculationStep {
|
||||
id: string;
|
||||
label: string;
|
||||
expression: CalculationNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 자동 계산 설정
|
||||
*/
|
||||
export interface AutoCalculationConfig {
|
||||
/** 계산 대상 필드명 (예: calculated_price) */
|
||||
targetField: string;
|
||||
/** 계산에 사용할 입력 필드들 */
|
||||
inputFields: {
|
||||
basePrice: string; // 기본 단가 필드명
|
||||
discountType: string; // 할인 방식 필드명
|
||||
discountValue: string; // 할인값 필드명
|
||||
roundingType: string; // 반올림 방식 필드명
|
||||
roundingUnit: string; // 반올림 단위 필드명
|
||||
/** 🆕 계산 방식 */
|
||||
mode: "template" | "custom";
|
||||
|
||||
/** 템플릿 모드 (기존 방식) */
|
||||
inputFields?: {
|
||||
basePrice: string;
|
||||
discountType: string;
|
||||
discountValue: string;
|
||||
roundingType: string;
|
||||
roundingUnit: string;
|
||||
};
|
||||
/** 계산 함수 타입 */
|
||||
calculationType: "price" | "custom";
|
||||
/** 🆕 카테고리 값 → 연산 매핑 */
|
||||
calculationType?: "price" | "custom";
|
||||
valueMapping?: {
|
||||
/** 할인 방식 매핑 */
|
||||
discountType?: {
|
||||
[valueCode: string]: "none" | "rate" | "amount"; // 예: { "CATEGORY_544740": "rate" }
|
||||
[valueCode: string]: "none" | "rate" | "amount";
|
||||
};
|
||||
/** 반올림 방식 매핑 */
|
||||
roundingType?: {
|
||||
[valueCode: string]: "none" | "round" | "floor" | "ceil";
|
||||
};
|
||||
/** 반올림 단위 매핑 (숫자로 변환) */
|
||||
roundingUnit?: {
|
||||
[valueCode: string]: number; // 예: { "10": 10, "100": 100 }
|
||||
[valueCode: string]: number;
|
||||
};
|
||||
};
|
||||
|
||||
/** 커스텀 모드 (계산식 빌더) */
|
||||
calculationSteps?: CalculationStep[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 부모 화면 데이터 매핑 설정
|
||||
*/
|
||||
export interface ParentDataMapping {
|
||||
/** 소스 테이블명 (필수) */
|
||||
sourceTable: string;
|
||||
/** 소스 테이블의 필드명 */
|
||||
sourceField: string;
|
||||
/** 저장할 테이블의 필드명 */
|
||||
targetField: string;
|
||||
/** 부모 데이터가 없을 때 사용할 기본값 (선택사항) */
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -130,6 +174,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
|||
*/
|
||||
targetTable?: string;
|
||||
|
||||
/**
|
||||
* 🆕 부모 화면 데이터 매핑
|
||||
* 이전 화면(예: 거래처 테이블)에서 넘어온 데이터를 저장 테이블의 필드에 자동 매핑
|
||||
* 예: { sourceField: "id", targetField: "customer_id" }
|
||||
*/
|
||||
parentDataMapping?: ParentDataMapping[];
|
||||
|
||||
/**
|
||||
* 🆕 자동 계산 설정
|
||||
* 특정 필드가 변경되면 다른 필드를 자동으로 계산
|
||||
|
|
|
|||
|
|
@ -60,6 +60,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const resizable = componentConfig.resizable ?? true;
|
||||
const minLeftWidth = componentConfig.minLeftWidth || 200;
|
||||
const minRightWidth = componentConfig.minRightWidth || 300;
|
||||
|
||||
// 필드 표시 유틸리티 (하드코딩 제거, 동적으로 작동)
|
||||
const shouldShowField = (fieldName: string): boolean => {
|
||||
const lower = fieldName.toLowerCase();
|
||||
|
||||
// 기본 제외: id, 비밀번호, 토큰, 회사코드
|
||||
if (lower === "id" || lower === "company_code" || lower === "company_name") return false;
|
||||
if (lower.includes("password") || lower.includes("token")) return false;
|
||||
|
||||
// 나머지는 모두 표시!
|
||||
return true;
|
||||
};
|
||||
|
||||
// TableOptions Context
|
||||
const { registerTable, unregisterTable } = useTableOptions();
|
||||
|
|
@ -369,9 +381,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
setIsLoadingRight(true);
|
||||
try {
|
||||
if (relationshipType === "detail") {
|
||||
// 상세 모드: 동일 테이블의 상세 정보
|
||||
// 상세 모드: 동일 테이블의 상세 정보 (🆕 엔티티 조인 활성화)
|
||||
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);
|
||||
} else if (relationshipType === "join") {
|
||||
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
||||
|
|
@ -388,6 +409,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
rightColumn,
|
||||
leftValue,
|
||||
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달
|
||||
true, // 🆕 Entity 조인 활성화
|
||||
componentConfig.rightPanel?.columns, // 🆕 표시 컬럼 전달 (item_info.item_name 등)
|
||||
componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
|
||||
);
|
||||
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
||||
}
|
||||
|
|
@ -754,12 +778,91 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
);
|
||||
|
||||
// 수정 버튼 핸들러
|
||||
const handleEditClick = useCallback((panel: "left" | "right", item: any) => {
|
||||
setEditModalPanel(panel);
|
||||
setEditModalItem(item);
|
||||
setEditModalFormData({ ...item });
|
||||
setShowEditModal(true);
|
||||
}, []);
|
||||
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);
|
||||
setEditModalItem(item);
|
||||
setEditModalFormData({ ...item });
|
||||
setShowEditModal(true);
|
||||
},
|
||||
[componentConfig],
|
||||
);
|
||||
|
||||
// 수정 모달 저장
|
||||
const handleEditModalSave = useCallback(async () => {
|
||||
|
|
@ -888,8 +991,50 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
try {
|
||||
console.log("🗑️ 데이터 삭제:", { tableName, primaryKey });
|
||||
|
||||
// 🔍 중복 제거 설정 디버깅
|
||||
console.log("🔍 중복 제거 디버깅:", {
|
||||
panel: deleteModalPanel,
|
||||
dataFilter: componentConfig.rightPanel?.dataFilter,
|
||||
deduplication: componentConfig.rightPanel?.dataFilter?.deduplication,
|
||||
enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled,
|
||||
});
|
||||
|
||||
const result = await dataApi.deleteRecord(tableName, primaryKey);
|
||||
let result;
|
||||
|
||||
// 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제
|
||||
if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) {
|
||||
const deduplication = componentConfig.rightPanel.dataFilter.deduplication;
|
||||
const groupByColumn = deduplication.groupByColumn;
|
||||
|
||||
if (groupByColumn && deleteModalItem[groupByColumn]) {
|
||||
const groupValue = deleteModalItem[groupByColumn];
|
||||
console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`);
|
||||
|
||||
// groupByColumn 값으로 필터링하여 삭제
|
||||
const filterConditions: Record<string, any> = {
|
||||
[groupByColumn]: groupValue,
|
||||
};
|
||||
|
||||
// 좌측 패널의 선택된 항목 정보도 포함 (customer_id 등)
|
||||
if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") {
|
||||
const leftColumn = componentConfig.rightPanel.join.leftColumn;
|
||||
const rightColumn = componentConfig.rightPanel.join.rightColumn;
|
||||
filterConditions[rightColumn] = selectedLeftItem[leftColumn];
|
||||
}
|
||||
|
||||
console.log("🗑️ 그룹 삭제 조건:", filterConditions);
|
||||
|
||||
// 그룹 삭제 API 호출
|
||||
result = await dataApi.deleteGroupRecords(tableName, filterConditions);
|
||||
} else {
|
||||
// 단일 레코드 삭제
|
||||
result = await dataApi.deleteRecord(tableName, primaryKey);
|
||||
}
|
||||
} else {
|
||||
// 단일 레코드 삭제
|
||||
result = await dataApi.deleteRecord(tableName, primaryKey);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
|
|
@ -1557,13 +1702,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
value: item[leftColumn],
|
||||
});
|
||||
|
||||
// 추가로 다른 의미있는 필드 1-2개 표시 (name, title 등)
|
||||
// 추가로 다른 의미있는 필드 1-2개 표시 (동적)
|
||||
const additionalKeys = Object.keys(item).filter(
|
||||
(k) =>
|
||||
k !== "id" &&
|
||||
k !== "ID" &&
|
||||
k !== leftColumn &&
|
||||
(k.includes("name") || k.includes("title") || k.includes("desc")),
|
||||
shouldShowField(k),
|
||||
);
|
||||
|
||||
if (additionalKeys.length > 0) {
|
||||
|
|
@ -1792,7 +1937,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
label: rightColumnLabels[col.name] || col.label || col.name,
|
||||
}))
|
||||
: Object.keys(filteredData[0] || {})
|
||||
.filter((key) => !key.toLowerCase().includes("password"))
|
||||
.filter((key) => shouldShowField(key))
|
||||
.slice(0, 5)
|
||||
.map((key) => ({
|
||||
name: key,
|
||||
|
|
@ -1850,16 +1995,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
{!isDesignMode && (
|
||||
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick("right", item);
|
||||
}}
|
||||
className="rounded p-1 transition-colors hover:bg-gray-200"
|
||||
title="수정"
|
||||
>
|
||||
<Pencil className="h-4 w-4 text-gray-600" />
|
||||
</button>
|
||||
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
||||
<Button
|
||||
variant={componentConfig.rightPanel?.editButton?.buttonVariant || "outline"}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick("right", item);
|
||||
}}
|
||||
className="h-7"
|
||||
>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
|
@ -1898,45 +2047,97 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
// 우측 패널 표시 컬럼 설정 확인
|
||||
const rightColumns = componentConfig.rightPanel?.columns;
|
||||
let firstValues: [string, any][] = [];
|
||||
let allValues: [string, any][] = [];
|
||||
|
||||
if (index === 0) {
|
||||
console.log("🔍 우측 패널 표시 로직:");
|
||||
console.log(" - rightColumns:", rightColumns);
|
||||
console.log(" - item keys:", Object.keys(item));
|
||||
}
|
||||
let firstValues: [string, any, string][] = [];
|
||||
let allValues: [string, any, string][] = [];
|
||||
|
||||
if (rightColumns && rightColumns.length > 0) {
|
||||
// 설정된 컬럼만 표시
|
||||
// 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리)
|
||||
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
|
||||
firstValues = rightColumns
|
||||
.slice(0, 3)
|
||||
.map((col) => [col.name, item[col.name]] as [string, any])
|
||||
.slice(0, summaryCount)
|
||||
.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 !== "");
|
||||
|
||||
allValues = rightColumns
|
||||
.map((col) => [col.name, item[col.name]] as [string, any])
|
||||
.map((col) => {
|
||||
// 🆕 엔티티 조인 컬럼 처리
|
||||
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';
|
||||
|
||||
// 백엔드에서 반환하는 별칭 패턴:
|
||||
// 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') {
|
||||
// 기본 참조 컬럼
|
||||
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 !== "");
|
||||
|
||||
if (index === 0) {
|
||||
console.log(
|
||||
" ✅ 설정된 컬럼 사용:",
|
||||
rightColumns.map((c) => c.name),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 설정 없으면 모든 컬럼 표시 (기존 로직)
|
||||
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
|
||||
firstValues = Object.entries(item)
|
||||
.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(
|
||||
([key, value]) => value !== null && value !== undefined && value !== "",
|
||||
);
|
||||
|
||||
if (index === 0) {
|
||||
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
|
||||
}
|
||||
allValues = Object.entries(item)
|
||||
.filter(([key, value]) => value !== null && value !== undefined && value !== "")
|
||||
.map(([key, value]) => [key, value, ''] as [string, any, string]);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -1951,30 +2152,63 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => toggleRightItemExpansion(itemId)}
|
||||
>
|
||||
{firstValues.map(([key, value], idx) => (
|
||||
<div key={key} className="mb-1 last:mb-0">
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
{getColumnLabel(key)}
|
||||
</div>
|
||||
<div className="text-foreground truncate text-sm" title={String(value || "-")}>
|
||||
{String(value || "-")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
{firstValues.map(([key, value, label], idx) => {
|
||||
// 포맷 설정 및 볼드 설정 찾기
|
||||
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>
|
||||
<div className="flex flex-shrink-0 items-start gap-1 pt-1">
|
||||
{/* 수정 버튼 */}
|
||||
{!isDesignMode && (
|
||||
<button
|
||||
{!isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
||||
<Button
|
||||
variant={componentConfig.rightPanel?.editButton?.buttonVariant || "outline"}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick("right", item);
|
||||
}}
|
||||
className="rounded p-1 transition-colors hover:bg-gray-200"
|
||||
title="수정"
|
||||
className="h-7"
|
||||
>
|
||||
<Pencil className="h-4 w-4 text-gray-600" />
|
||||
</button>
|
||||
<Pencil className="h-3 w-3 mr-1" />
|
||||
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||
</Button>
|
||||
)}
|
||||
{/* 삭제 버튼 */}
|
||||
{!isDesignMode && (
|
||||
|
|
@ -2011,22 +2245,43 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
<div className="bg-card overflow-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-border divide-y">
|
||||
{allValues.map(([key, value]) => (
|
||||
<tr key={key} className="hover:bg-muted">
|
||||
<td className="text-muted-foreground px-3 py-2 font-medium whitespace-nowrap">
|
||||
{getColumnLabel(key)}
|
||||
</td>
|
||||
<td className="text-foreground px-3 py-2 break-all">{String(value)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{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">
|
||||
<td className="text-muted-foreground px-3 py-2 font-medium whitespace-nowrap">
|
||||
{label || getColumnLabel(key)}
|
||||
</td>
|
||||
<td className="text-foreground px-3 py-2 break-all">{displayValue}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
|
|
@ -2045,33 +2300,52 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 상세 모드: 단일 객체를 상세 정보로 표시
|
||||
(() => {
|
||||
const rightColumns = componentConfig.rightPanel?.columns;
|
||||
let displayEntries: [string, any][] = [];
|
||||
let displayEntries: [string, any, string][] = [];
|
||||
|
||||
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
|
||||
.map((col) => [col.name, rightData[col.name]] as [string, any])
|
||||
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
|
||||
.map((col) => {
|
||||
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name)
|
||||
let value = rightData[col.name];
|
||||
console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`);
|
||||
|
||||
if (value === undefined && col.name.includes('.')) {
|
||||
const columnName = col.name.split('.').pop();
|
||||
value = rightData[columnName || ''];
|
||||
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("🔍 상세 모드 표시 로직:");
|
||||
console.log(
|
||||
" ✅ 설정된 컬럼 사용:",
|
||||
rightColumns.map((c) => c.name),
|
||||
);
|
||||
console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개");
|
||||
} else {
|
||||
// 설정 없으면 모든 컬럼 표시
|
||||
displayEntries = Object.entries(rightData).filter(
|
||||
([_, value]) => value !== null && value !== undefined && value !== "",
|
||||
);
|
||||
displayEntries = Object.entries(rightData)
|
||||
.filter(([_, value]) => value !== null && value !== undefined && value !== "")
|
||||
.map(([key, value]) => [key, value, ""] as [string, any, string]);
|
||||
console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시");
|
||||
}
|
||||
|
||||
return (
|
||||
<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 className="text-muted-foreground mb-1 text-xs font-semibold tracking-wide uppercase">
|
||||
{getColumnLabel(key)}
|
||||
{label || getColumnLabel(key)}
|
||||
</div>
|
||||
<div className="text-sm">{String(value)}</div>
|
||||
</div>
|
||||
|
|
@ -2275,9 +2549,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</div>
|
||||
));
|
||||
} else {
|
||||
// 설정이 없으면 모든 컬럼 표시 (company_code, company_name 제외)
|
||||
// 설정이 없으면 모든 컬럼 표시 (민감한 필드 제외)
|
||||
return Object.entries(editModalFormData)
|
||||
.filter(([key]) => key !== "company_code" && key !== "company_name")
|
||||
.filter(([key]) => shouldShowField(key))
|
||||
.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<Label htmlFor={`edit-${key}`} className="text-xs sm:text-sm">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -21,6 +21,14 @@ export interface SplitPanelLayoutConfig {
|
|||
width?: number;
|
||||
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
||||
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<{
|
||||
|
|
@ -69,12 +77,23 @@ export interface SplitPanelLayoutConfig {
|
|||
showAdd?: boolean;
|
||||
showEdit?: boolean; // 수정 버튼
|
||||
showDelete?: boolean; // 삭제 버튼
|
||||
summaryColumnCount?: number; // 요약에서 표시할 컬럼 개수 (기본: 3)
|
||||
summaryShowLabel?: boolean; // 요약에서 라벨 표시 여부 (기본: true)
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
width?: number;
|
||||
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
||||
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<{
|
||||
|
|
@ -113,6 +132,24 @@ export interface SplitPanelLayoutConfig {
|
|||
|
||||
// 🆕 컬럼 값 기반 데이터 필터링
|
||||
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"])
|
||||
};
|
||||
};
|
||||
|
||||
// 레이아웃 설정
|
||||
|
|
|
|||
|
|
@ -135,6 +135,9 @@ export interface ButtonActionContext {
|
|||
currentPage?: number; // 현재 페이지
|
||||
pageSize?: number; // 페이지 크기
|
||||
totalItems?: number; // 전체 항목 수
|
||||
|
||||
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
|
||||
componentConfigs?: Record<string, any>; // 컴포넌트 ID → 컴포넌트 설정
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -208,10 +211,17 @@ export class ButtonActionExecutor {
|
|||
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId });
|
||||
|
||||
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
|
||||
window.dispatchEvent(new CustomEvent("beforeFormSave"));
|
||||
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
|
||||
window.dispatchEvent(new CustomEvent("beforeFormSave", {
|
||||
detail: {
|
||||
formData: context.formData
|
||||
}
|
||||
}));
|
||||
|
||||
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData);
|
||||
|
||||
// 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조)
|
||||
console.log("🔍 [handleSave] formData 구조 확인:", {
|
||||
|
|
@ -230,6 +240,16 @@ export class ButtonActionExecutor {
|
|||
|
||||
const selectedItemsKeys = Object.keys(context.formData).filter(key => {
|
||||
const value = context.formData[key];
|
||||
console.log(`🔍 [handleSave] 필터링 체크 - ${key}:`, {
|
||||
isArray: Array.isArray(value),
|
||||
length: Array.isArray(value) ? value.length : 0,
|
||||
firstItem: Array.isArray(value) && value.length > 0 ? {
|
||||
keys: Object.keys(value[0] || {}),
|
||||
hasOriginalData: !!value[0]?.originalData,
|
||||
hasFieldGroups: !!value[0]?.fieldGroups,
|
||||
actualValue: value[0],
|
||||
} : null
|
||||
});
|
||||
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups;
|
||||
});
|
||||
|
||||
|
|
@ -238,6 +258,7 @@ export class ButtonActionExecutor {
|
|||
return await this.handleBatchSave(config, context, selectedItemsKeys);
|
||||
} else {
|
||||
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
|
||||
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
|
||||
}
|
||||
|
||||
// 폼 유효성 검사
|
||||
|
|
@ -497,7 +518,14 @@ export class ButtonActionExecutor {
|
|||
context: ButtonActionContext,
|
||||
selectedItemsKeys: string[]
|
||||
): Promise<boolean> {
|
||||
const { formData, tableName, screenId } = context;
|
||||
const { formData, tableName, screenId, selectedRowsData, originalData } = context;
|
||||
|
||||
console.log(`🔍 [handleBatchSave] context 확인:`, {
|
||||
hasSelectedRowsData: !!selectedRowsData,
|
||||
selectedRowsCount: selectedRowsData?.length || 0,
|
||||
hasOriginalData: !!originalData,
|
||||
originalDataKeys: originalData ? Object.keys(originalData) : [],
|
||||
});
|
||||
|
||||
if (!tableName || !screenId) {
|
||||
toast.error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
||||
|
|
@ -509,6 +537,33 @@ export class ButtonActionExecutor {
|
|||
let failCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
// 🆕 부모 화면 데이터 준비 (parentDataMapping용)
|
||||
// selectedRowsData 또는 originalData를 parentData로 사용
|
||||
const parentData = selectedRowsData?.[0] || originalData || {};
|
||||
|
||||
// 🆕 modalDataStore에서 누적된 모든 테이블 데이터 가져오기
|
||||
// (여러 단계 모달에서 전달된 데이터 접근용)
|
||||
let modalDataStoreRegistry: Record<string, any[]> = {};
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
// Zustand store에서 데이터 가져오기
|
||||
const { useModalDataStore } = await import('@/stores/modalDataStore');
|
||||
modalDataStoreRegistry = useModalDataStore.getState().dataRegistry;
|
||||
} catch (error) {
|
||||
console.warn("⚠️ modalDataStore 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 각 테이블의 첫 번째 항목을 modalDataStore로 변환
|
||||
const modalDataStore: Record<string, any> = {};
|
||||
Object.entries(modalDataStoreRegistry).forEach(([key, items]) => {
|
||||
if (Array.isArray(items) && items.length > 0) {
|
||||
// ModalDataItem[] → originalData 추출
|
||||
modalDataStore[key] = items.map(item => item.originalData || item);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리
|
||||
for (const key of selectedItemsKeys) {
|
||||
// 🆕 새로운 데이터 구조: ItemData[] with fieldGroups
|
||||
|
|
@ -518,24 +573,101 @@ export class ButtonActionExecutor {
|
|||
fieldGroups: Record<string, Array<{ id: string; [key: string]: any }>>;
|
||||
}>;
|
||||
|
||||
console.log(`📦 [handleBatchSave] ${key} 처리 중 (${items.length}개 품목)`);
|
||||
// 🆕 이 컴포넌트의 parentDataMapping 설정 가져오기
|
||||
const componentConfig = context.componentConfigs?.[key];
|
||||
const parentDataMapping = componentConfig?.parentDataMapping || [];
|
||||
|
||||
// 각 품목의 모든 그룹의 모든 항목을 개별 저장
|
||||
// 🆕 각 품목의 그룹 간 조합(카티션 곱) 생성
|
||||
for (const item of items) {
|
||||
const allGroupEntries = Object.values(item.fieldGroups).flat();
|
||||
console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${allGroupEntries.length}개 입력 항목)`);
|
||||
const groupKeys = Object.keys(item.fieldGroups);
|
||||
|
||||
// 모든 그룹의 모든 항목을 개별 레코드로 저장
|
||||
for (const entry of allGroupEntries) {
|
||||
// 각 그룹의 항목 배열 가져오기
|
||||
const groupArrays = groupKeys.map(groupKey => ({
|
||||
groupKey,
|
||||
entries: item.fieldGroups[groupKey] || []
|
||||
}));
|
||||
|
||||
// 카티션 곱 계산 함수
|
||||
const cartesianProduct = (arrays: any[][]): any[][] => {
|
||||
if (arrays.length === 0) return [[]];
|
||||
if (arrays.length === 1) return arrays[0].map(item => [item]);
|
||||
|
||||
const [first, ...rest] = arrays;
|
||||
const restProduct = cartesianProduct(rest);
|
||||
|
||||
return first.flatMap(item =>
|
||||
restProduct.map(combination => [item, ...combination])
|
||||
);
|
||||
};
|
||||
|
||||
// 모든 그룹의 카티션 곱 생성
|
||||
const entryArrays = groupArrays.map(g => g.entries);
|
||||
const combinations = cartesianProduct(entryArrays);
|
||||
|
||||
// 각 조합을 개별 레코드로 저장
|
||||
for (let i = 0; i < combinations.length; i++) {
|
||||
const combination = combinations[i];
|
||||
try {
|
||||
// 원본 데이터 + 입력 데이터 병합
|
||||
const mergedData = {
|
||||
...item.originalData,
|
||||
...entry,
|
||||
};
|
||||
// 🆕 부모 데이터 매핑 적용
|
||||
const mappedData: any = {};
|
||||
|
||||
// id 필드 제거 (entry.id는 임시 ID이므로)
|
||||
delete mergedData.id;
|
||||
// 1. parentDataMapping 설정이 있으면 적용
|
||||
if (parentDataMapping.length > 0) {
|
||||
for (const mapping of parentDataMapping) {
|
||||
let sourceData: any;
|
||||
const sourceTableName = mapping.sourceTable;
|
||||
const selectedItemTable = componentConfig?.sourceTable;
|
||||
|
||||
if (sourceTableName === selectedItemTable) {
|
||||
sourceData = item.originalData;
|
||||
} else {
|
||||
const tableData = modalDataStore[sourceTableName];
|
||||
if (tableData && Array.isArray(tableData) && tableData.length > 0) {
|
||||
sourceData = tableData[0];
|
||||
} else {
|
||||
sourceData = parentData;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceValue = sourceData[mapping.sourceField];
|
||||
|
||||
if (sourceValue !== undefined && sourceValue !== null) {
|
||||
mappedData[mapping.targetField] = sourceValue;
|
||||
} else if (mapping.defaultValue !== undefined) {
|
||||
mappedData[mapping.targetField] = mapping.defaultValue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 🔧 parentDataMapping 설정이 없는 경우 기본 매핑 (하위 호환성)
|
||||
if (item.originalData.id) {
|
||||
mappedData.item_id = item.originalData.id;
|
||||
}
|
||||
|
||||
if (parentData.id || parentData.customer_id) {
|
||||
mappedData.customer_id = parentData.customer_id || parentData.id;
|
||||
}
|
||||
}
|
||||
|
||||
// 공통 필드 복사 (company_code, currency_code 등)
|
||||
if (item.originalData.company_code && !mappedData.company_code) {
|
||||
mappedData.company_code = item.originalData.company_code;
|
||||
}
|
||||
if (item.originalData.currency_code && !mappedData.currency_code) {
|
||||
mappedData.currency_code = item.originalData.currency_code;
|
||||
}
|
||||
|
||||
// 원본 데이터로 시작 (매핑된 데이터 사용)
|
||||
let mergedData = { ...mappedData };
|
||||
|
||||
// 각 그룹의 항목 데이터를 순차적으로 병합
|
||||
for (let j = 0; j < combination.length; j++) {
|
||||
const entry = combination[j];
|
||||
const { id, ...entryData } = entry; // id 제외
|
||||
mergedData = { ...mergedData, ...entryData };
|
||||
}
|
||||
|
||||
// 🆕 조합 저장 시 id 필드 제거 (각 조합이 독립된 새 레코드가 되도록)
|
||||
const { id: _removedId, ...dataWithoutId } = mergedData;
|
||||
|
||||
// 사용자 정보 추가
|
||||
if (!context.userId) {
|
||||
|
|
@ -546,19 +678,13 @@ export class ButtonActionExecutor {
|
|||
const companyCodeValue = context.companyCode || "";
|
||||
|
||||
const dataWithUserInfo = {
|
||||
...mergedData,
|
||||
writer: mergedData.writer || writerValue,
|
||||
...dataWithoutId,
|
||||
writer: dataWithoutId.writer || writerValue,
|
||||
created_by: writerValue,
|
||||
updated_by: writerValue,
|
||||
company_code: mergedData.company_code || companyCodeValue,
|
||||
company_code: dataWithoutId.company_code || companyCodeValue,
|
||||
};
|
||||
|
||||
console.log(`💾 [handleBatchSave] 입력 항목 저장:`, {
|
||||
itemId: item.id,
|
||||
entryId: entry.id,
|
||||
data: dataWithUserInfo
|
||||
});
|
||||
|
||||
// INSERT 실행
|
||||
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||
const saveResult = await DynamicFormApi.saveFormData({
|
||||
|
|
@ -569,16 +695,13 @@ export class ButtonActionExecutor {
|
|||
|
||||
if (saveResult.success) {
|
||||
successCount++;
|
||||
console.log(`✅ [handleBatchSave] 입력 항목 저장 성공: ${item.id} > ${entry.id}`);
|
||||
} else {
|
||||
failCount++;
|
||||
errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${saveResult.message}`);
|
||||
console.error(`❌ [handleBatchSave] 입력 항목 저장 실패: ${item.id} > ${entry.id}`, saveResult.message);
|
||||
errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${saveResult.message}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
failCount++;
|
||||
errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${error.message}`);
|
||||
console.error(`❌ [handleBatchSave] 입력 항목 저장 오류: ${item.id} > ${entry.id}`, error);
|
||||
errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -446,9 +446,29 @@ export interface DataTableFilter {
|
|||
export interface ColumnFilter {
|
||||
id: string;
|
||||
columnName: string; // 필터링할 컬럼명
|
||||
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null";
|
||||
value: string | string[]; // 필터 값 (in/not_in은 배열)
|
||||
valueType: "static" | "category" | "code"; // 값 타입
|
||||
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"; // 날짜 범위 포함 (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