우측 패널 일괄삭제 기능

This commit is contained in:
kjs 2025-11-20 11:58:43 +09:00
parent c3f58feef7
commit e3b78309fa
7 changed files with 587 additions and 105 deletions

View File

@ -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,
});
}
}

View File

@ -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++;

View File

@ -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를 반환

View File

@ -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,

View File

@ -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 || "데이터 저장 실패",

View File

@ -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>
);
}

View File

@ -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({