우측 패널 일괄삭제 기능
This commit is contained in:
parent
c3f58feef7
commit
e3b78309fa
|
|
@ -393,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}
|
||||
|
|
@ -579,76 +659,40 @@ router.post(
|
|||
);
|
||||
|
||||
/**
|
||||
* 그룹화된 데이터 UPSERT API
|
||||
* POST /api/data/upsert-grouped
|
||||
*
|
||||
* 요청 본문:
|
||||
* {
|
||||
* tableName: string,
|
||||
* parentKeys: { customer_id: "CUST-0002", item_id: "SLI-2025-0002" },
|
||||
* records: [ { customer_item_code: "84-44", start_date: "2025-11-18", ... }, ... ]
|
||||
* }
|
||||
* 그룹 삭제 API
|
||||
* POST /api/data/:tableName/delete-group
|
||||
*/
|
||||
router.post(
|
||||
"/upsert-grouped",
|
||||
"/:tableName/delete-group",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName, parentKeys, records } = req.body;
|
||||
const { tableName } = req.params;
|
||||
const filterConditions = 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,
|
||||
});
|
||||
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions });
|
||||
|
||||
// UPSERT 수행
|
||||
const result = await dataService.upsertGroupedRecords(
|
||||
tableName,
|
||||
parentKeys,
|
||||
records
|
||||
);
|
||||
const result = await dataService.deleteGroupRecords(tableName, filterConditions);
|
||||
|
||||
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);
|
||||
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 instanceof Error ? error.message : "Unknown error",
|
||||
message: "그룹 삭제 실패",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
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;
|
||||
|
|
@ -530,7 +531,27 @@ class DataService {
|
|||
};
|
||||
}
|
||||
|
||||
console.log(`✅ Entity Join 데이터 조회 성공:`, result.rows[0]);
|
||||
// 🔧 날짜 타입 타임존 문제 해결: 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) {
|
||||
|
|
@ -542,7 +563,7 @@ class DataService {
|
|||
let paramIndex = 1;
|
||||
|
||||
for (const col of groupByColumns) {
|
||||
const value = baseRecord[col];
|
||||
const value = normalizedRows[0][col];
|
||||
if (value !== undefined && value !== null) {
|
||||
groupConditions.push(`main."${col}" = $${paramIndex}`);
|
||||
groupValues.push(value);
|
||||
|
|
@ -565,18 +586,19 @@ class DataService {
|
|||
|
||||
const groupResult = await pool.query(groupQuery, groupValues);
|
||||
|
||||
console.log(`✅ 그룹 레코드 조회 성공: ${groupResult.rows.length}개`);
|
||||
const normalizedGroupRows = normalizeDates(groupResult.rows);
|
||||
console.log(`✅ 그룹 레코드 조회 성공: ${normalizedGroupRows.length}개`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: groupResult.rows, // 🔧 배열로 반환!
|
||||
data: normalizedGroupRows, // 🔧 배열로 반환!
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.rows[0], // 그룹핑 없으면 단일 레코드
|
||||
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -755,14 +777,33 @@ class DataService {
|
|||
|
||||
const result = await pool.query(finalQuery, values);
|
||||
|
||||
console.log(`✅ Entity 조인 성공! 반환된 데이터 개수: ${result.rows.length}개`);
|
||||
// 🔧 날짜 타입 타임존 문제 해결
|
||||
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 = result.rows;
|
||||
let finalData = normalizedRows;
|
||||
if (deduplication?.enabled && deduplication.groupByColumn) {
|
||||
console.log(`🔄 중복 제거 시작: 기준 컬럼 = ${deduplication.groupByColumn}, 전략 = ${deduplication.keepStrategy}`);
|
||||
finalData = this.deduplicateData(result.rows, deduplication);
|
||||
console.log(`✅ 중복 제거 완료: ${result.rows.length}개 → ${finalData.length}개`);
|
||||
finalData = this.deduplicateData(normalizedRows, deduplication);
|
||||
console.log(`✅ 중복 제거 완료: ${normalizedRows.length}개 → ${finalData.length}개`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -1063,6 +1104,53 @@ 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)와 레코드 배열을 받아
|
||||
|
|
@ -1072,27 +1160,27 @@ class DataService {
|
|||
async upsertGroupedRecords(
|
||||
tableName: string,
|
||||
parentKeys: Record<string, any>,
|
||||
records: Array<Record<string, any>>
|
||||
records: Array<Record<string, any>>,
|
||||
userCompany?: string,
|
||||
userId?: string
|
||||
): Promise<ServiceResponse<{ inserted: number; updated: number; deleted: number }>> {
|
||||
try {
|
||||
// 테이블 접근 권한 검증
|
||||
if (!this.canAccessTable(tableName)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `테이블 '${tableName}'에 접근할 수 없습니다.`,
|
||||
error: "ACCESS_DENIED",
|
||||
};
|
||||
const validation = await this.validateTableAccess(tableName);
|
||||
if (!validation.valid) {
|
||||
return validation.error!;
|
||||
}
|
||||
|
||||
// Primary Key 감지
|
||||
const pkColumn = await this.detectPrimaryKey(tableName);
|
||||
if (!pkColumn) {
|
||||
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,
|
||||
|
|
@ -1125,19 +1213,37 @@ class DataService {
|
|||
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) {
|
||||
// 전체 레코드 데이터 (parentKeys + newRecord)
|
||||
const fullRecord = { ...parentKeys, ...newRecord };
|
||||
// 날짜 필드 정규화
|
||||
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(newRecord);
|
||||
const uniqueFields = Object.keys(normalizedRecord);
|
||||
|
||||
// 기존 레코드에서 일치하는 것 찾기
|
||||
const existingRecord = existingRecords.rows.find((existing) => {
|
||||
return uniqueFields.every((field) => {
|
||||
const existingValue = existing[field];
|
||||
const newValue = newRecord[field];
|
||||
const newValue = normalizedRecord[field];
|
||||
|
||||
// null/undefined 처리
|
||||
if (existingValue == null && newValue == null) return true;
|
||||
|
|
@ -1180,15 +1286,49 @@ class DataService {
|
|||
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||
} else {
|
||||
// INSERT: 기존 레코드가 없으면 삽입
|
||||
const insertFields = Object.keys(fullRecord);
|
||||
const insertPlaceholders = insertFields.map((_, idx) => `$${idx + 1}`);
|
||||
const insertValues = Object.values(fullRecord);
|
||||
|
||||
// 🆕 자동 필드 추가 (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}" (${insertFields.map(f => `"${f}"`).join(", ")})
|
||||
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++;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,19 +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 타입 오류 방지)
|
||||
// "*"는 특별 처리: AS 없이 그냥 main.*만
|
||||
const baseColumns = selectColumns
|
||||
.map((col) => {
|
||||
if (col === "*") {
|
||||
return "main.*";
|
||||
}
|
||||
return `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 절과 동일한 로직)
|
||||
|
|
@ -303,6 +333,13 @@ export class EntityJoinService {
|
|||
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}`
|
||||
|
|
@ -328,6 +365,18 @@ 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를 반환
|
||||
|
|
|
|||
|
|
@ -297,10 +297,38 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
console.log("📦 전체 데이터 (JSON):", JSON.stringify(response.data, null, 2));
|
||||
}
|
||||
|
||||
setFormData(response.data);
|
||||
// 🔧 날짜 필드 정규화 (타임존 제거)
|
||||
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 호출 완료");
|
||||
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
|
||||
} else {
|
||||
console.error("❌ 수정 데이터 로드 실패:", response.error);
|
||||
toast.error("데이터를 불러올 수 없습니다.");
|
||||
|
|
@ -359,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,
|
||||
|
|
|
|||
|
|
@ -169,6 +169,31 @@ export const dataApi = {
|
|||
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 테이블명
|
||||
|
|
@ -207,13 +232,30 @@ export const dataApi = {
|
|||
records: Array<Record<string, any>>
|
||||
): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => {
|
||||
try {
|
||||
const response = await apiClient.post('/data/upsert-grouped', {
|
||||
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 || "데이터 저장 실패",
|
||||
|
|
|
|||
|
|
@ -257,18 +257,31 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
|
||||
groupFields.forEach((field: any) => {
|
||||
let fieldValue = record[field.name];
|
||||
if (fieldValue !== undefined && fieldValue !== null) {
|
||||
// 🔧 날짜 타입이면 YYYY-MM-DD 형식으로 변환 (타임존 제거)
|
||||
if (field.type === "date" || field.type === "datetime") {
|
||||
const dateStr = String(fieldValue);
|
||||
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (match) {
|
||||
const [, year, month, day] = match;
|
||||
fieldValue = `${year}-${month}-${day}`; // ISO 형식 유지 (시간 제거)
|
||||
}
|
||||
|
||||
// 🔧 값이 없으면 기본값 사용 (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;
|
||||
}
|
||||
entryData[field.name] = fieldValue;
|
||||
}
|
||||
|
||||
// 🔧 날짜 타입이면 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;
|
||||
});
|
||||
|
||||
// 🔑 모든 필드 값을 합쳐서 고유 키 생성 (중복 제거 기준)
|
||||
|
|
@ -347,6 +360,59 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 = async (event: Event) => {
|
||||
|
|
@ -377,17 +443,40 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
// 🔄 수정 모드: 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에서 부모 데이터 가져오기
|
||||
const sourceData = 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}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -402,10 +491,28 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
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 || "",
|
||||
componentConfig.targetTable,
|
||||
parentKeys,
|
||||
records
|
||||
);
|
||||
|
|
@ -469,7 +576,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
|||
return () => {
|
||||
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||
};
|
||||
}, [items, component.id, onFormDataChange, componentConfig, formData]);
|
||||
}, [items, component.id, onFormDataChange, componentConfig, formData, generateCartesianProduct]);
|
||||
|
||||
// 스타일 계산
|
||||
const componentStyle: React.CSSProperties = {
|
||||
|
|
@ -1027,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":
|
||||
// 천 단위 구분
|
||||
|
|
@ -1075,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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -979,8 +979,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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue