Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into lhj
; Please enter a commit message to explain why this merge is necessary, ; especially if it merges an updated upstream into a topic branch. ; ; Lines starting with ';' will be ignored, and an empty message aborts ; the commit.
This commit is contained in:
commit
551e893f15
|
|
@ -282,3 +282,175 @@ export async function previewCodeMerge(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값 기반 코드 병합 - 모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경
|
||||||
|
* 컬럼명에 상관없이 oldValue를 가진 모든 곳을 newValue로 변경
|
||||||
|
*/
|
||||||
|
export async function mergeCodeByValue(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const { oldValue, newValue } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 입력값 검증
|
||||||
|
if (!oldValue || !newValue) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (oldValue, newValue)",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증 정보가 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 값으로 병합 시도 방지
|
||||||
|
if (oldValue === newValue) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "기존 값과 새 값이 동일합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("값 기반 코드 병합 시작", {
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
companyCode,
|
||||||
|
userId: req.user?.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// PostgreSQL 함수 호출
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT * FROM merge_code_by_value($1, $2, $3)",
|
||||||
|
[oldValue, newValue, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 결과 처리
|
||||||
|
const affectedData = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||||
|
const totalRows = affectedData.reduce(
|
||||||
|
(sum: number, row: any) => sum + parseInt(row.out_rows_updated || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("값 기반 코드 병합 완료", {
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
affectedTablesCount: affectedData.length,
|
||||||
|
totalRowsUpdated: totalRows,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `코드 병합 완료: ${oldValue} → ${newValue}`,
|
||||||
|
data: {
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
affectedData: affectedData.map((row: any) => ({
|
||||||
|
tableName: row.out_table_name,
|
||||||
|
columnName: row.out_column_name,
|
||||||
|
rowsUpdated: parseInt(row.out_rows_updated),
|
||||||
|
})),
|
||||||
|
totalRowsUpdated: totalRows,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("값 기반 코드 병합 실패:", {
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "코드 병합 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "CODE_MERGE_BY_VALUE_ERROR",
|
||||||
|
details: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값 기반 코드 병합 미리보기
|
||||||
|
* 컬럼명에 상관없이 해당 값을 가진 모든 테이블/컬럼 조회
|
||||||
|
*/
|
||||||
|
export async function previewMergeCodeByValue(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
const { oldValue } = req.body;
|
||||||
|
const companyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!oldValue) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "필수 필드가 누락되었습니다. (oldValue)",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!companyCode) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: "인증 정보가 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("값 기반 코드 병합 미리보기", { oldValue, companyCode });
|
||||||
|
|
||||||
|
// PostgreSQL 함수 호출
|
||||||
|
const result = await pool.query(
|
||||||
|
"SELECT * FROM preview_merge_code_by_value($1, $2)",
|
||||||
|
[oldValue, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const preview = Array.isArray(result) ? result : ((result as any).rows || []);
|
||||||
|
const totalRows = preview.reduce(
|
||||||
|
(sum: number, row: any) => sum + parseInt(row.out_affected_rows || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("값 기반 코드 병합 미리보기 완료", {
|
||||||
|
tablesCount: preview.length,
|
||||||
|
totalRows,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "코드 병합 미리보기 완료",
|
||||||
|
data: {
|
||||||
|
oldValue,
|
||||||
|
preview: preview.map((row: any) => ({
|
||||||
|
tableName: row.out_table_name,
|
||||||
|
columnName: row.out_column_name,
|
||||||
|
affectedRows: parseInt(row.out_affected_rows),
|
||||||
|
})),
|
||||||
|
totalAffectedRows: totalRows,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("값 기반 코드 병합 미리보기 실패:", error);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "코드 병합 미리보기 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "PREVIEW_BY_VALUE_ERROR",
|
||||||
|
details: error.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2185,3 +2185,67 @@ export async function multiTableSave(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 테이블 간의 엔티티 관계 자동 감지
|
||||||
|
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
|
||||||
|
*
|
||||||
|
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
|
||||||
|
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
|
||||||
|
*/
|
||||||
|
export async function getTableEntityRelations(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { leftTable, rightTable } = req.query;
|
||||||
|
|
||||||
|
logger.info(`=== 테이블 엔티티 관계 조회 시작: ${leftTable} <-> ${rightTable} ===`);
|
||||||
|
|
||||||
|
if (!leftTable || !rightTable) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "leftTable과 rightTable 파라미터가 필요합니다.",
|
||||||
|
error: {
|
||||||
|
code: "MISSING_PARAMETERS",
|
||||||
|
details: "leftTable과 rightTable 쿼리 파라미터가 필요합니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
res.status(400).json(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableManagementService = new TableManagementService();
|
||||||
|
const relations = await tableManagementService.detectTableEntityRelations(
|
||||||
|
String(leftTable),
|
||||||
|
String(rightTable)
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`테이블 엔티티 관계 조회 완료: ${relations.length}개 발견`);
|
||||||
|
|
||||||
|
const response: ApiResponse<any> = {
|
||||||
|
success: true,
|
||||||
|
message: `${relations.length}개의 엔티티 관계를 발견했습니다.`,
|
||||||
|
data: {
|
||||||
|
leftTable: String(leftTable),
|
||||||
|
rightTable: String(rightTable),
|
||||||
|
relations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("테이블 엔티티 관계 조회 중 오류 발생:", error);
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
message: "테이블 엔티티 관계 조회 중 오류가 발생했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "ENTITY_RELATIONS_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(500).json(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import {
|
||||||
mergeCodeAllTables,
|
mergeCodeAllTables,
|
||||||
getTablesWithColumn,
|
getTablesWithColumn,
|
||||||
previewCodeMerge,
|
previewCodeMerge,
|
||||||
|
mergeCodeByValue,
|
||||||
|
previewMergeCodeByValue,
|
||||||
} from "../controllers/codeMergeController";
|
} from "../controllers/codeMergeController";
|
||||||
import { authenticateToken } from "../middleware/authMiddleware";
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
|
@ -13,7 +15,7 @@ router.use(authenticateToken);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/code-merge/merge-all-tables
|
* POST /api/code-merge/merge-all-tables
|
||||||
* 코드 병합 실행 (모든 관련 테이블에 적용)
|
* 코드 병합 실행 (모든 관련 테이블에 적용 - 같은 컬럼명만)
|
||||||
* Body: { columnName, oldValue, newValue }
|
* Body: { columnName, oldValue, newValue }
|
||||||
*/
|
*/
|
||||||
router.post("/merge-all-tables", mergeCodeAllTables);
|
router.post("/merge-all-tables", mergeCodeAllTables);
|
||||||
|
|
@ -26,10 +28,24 @@ router.get("/tables-with-column/:columnName", getTablesWithColumn);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/code-merge/preview
|
* POST /api/code-merge/preview
|
||||||
* 코드 병합 미리보기 (실제 실행 없이 영향받을 데이터 확인)
|
* 코드 병합 미리보기 (같은 컬럼명 기준)
|
||||||
* Body: { columnName, oldValue }
|
* Body: { columnName, oldValue }
|
||||||
*/
|
*/
|
||||||
router.post("/preview", previewCodeMerge);
|
router.post("/preview", previewCodeMerge);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/code-merge/merge-by-value
|
||||||
|
* 값 기반 코드 병합 (모든 테이블의 모든 컬럼에서 해당 값을 찾아 변경)
|
||||||
|
* Body: { oldValue, newValue }
|
||||||
|
*/
|
||||||
|
router.post("/merge-by-value", mergeCodeByValue);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/code-merge/preview-by-value
|
||||||
|
* 값 기반 코드 병합 미리보기 (컬럼명 상관없이 값으로 검색)
|
||||||
|
* Body: { oldValue }
|
||||||
|
*/
|
||||||
|
router.post("/preview-by-value", previewMergeCodeByValue);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -698,6 +698,7 @@ router.post(
|
||||||
try {
|
try {
|
||||||
const { tableName } = req.params;
|
const { tableName } = req.params;
|
||||||
const filterConditions = req.body;
|
const filterConditions = req.body;
|
||||||
|
const userCompany = req.user?.companyCode;
|
||||||
|
|
||||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName)) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|
@ -706,11 +707,12 @@ router.post(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions });
|
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany });
|
||||||
|
|
||||||
const result = await dataService.deleteGroupRecords(
|
const result = await dataService.deleteGroupRecords(
|
||||||
tableName,
|
tableName,
|
||||||
filterConditions
|
filterConditions,
|
||||||
|
userCompany // 회사 코드 전달
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
toggleLogTable,
|
toggleLogTable,
|
||||||
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
|
||||||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||||
|
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||||
} from "../controllers/tableManagementController";
|
} from "../controllers/tableManagementController";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
@ -38,6 +39,15 @@ router.use(authenticateToken);
|
||||||
*/
|
*/
|
||||||
router.get("/tables", getTableList);
|
router.get("/tables", getTableList);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 테이블 간 엔티티 관계 조회
|
||||||
|
* GET /api/table-management/tables/entity-relations?leftTable=xxx&rightTable=yyy
|
||||||
|
*
|
||||||
|
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
|
||||||
|
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
|
||||||
|
*/
|
||||||
|
router.get("/tables/entity-relations", getTableEntityRelations);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 컬럼 정보 조회
|
* 테이블 컬럼 정보 조회
|
||||||
* GET /api/table-management/tables/:tableName/columns
|
* GET /api/table-management/tables/:tableName/columns
|
||||||
|
|
|
||||||
|
|
@ -1189,6 +1189,13 @@ class DataService {
|
||||||
[tableName]
|
[tableName]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(`🔍 테이블 ${tableName}의 Primary Key 조회 결과:`, {
|
||||||
|
pkColumns: pkResult.map((r) => r.attname),
|
||||||
|
pkCount: pkResult.length,
|
||||||
|
inputId: typeof id === "object" ? JSON.stringify(id).substring(0, 200) + "..." : id,
|
||||||
|
inputIdType: typeof id,
|
||||||
|
});
|
||||||
|
|
||||||
let whereClauses: string[] = [];
|
let whereClauses: string[] = [];
|
||||||
let params: any[] = [];
|
let params: any[] = [];
|
||||||
|
|
||||||
|
|
@ -1216,17 +1223,31 @@ class DataService {
|
||||||
params.push(typeof id === "object" ? id[pkColumn] : id);
|
params.push(typeof id === "object" ? id[pkColumn] : id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")}`;
|
const queryText = `DELETE FROM "${tableName}" WHERE ${whereClauses.join(" AND ")} RETURNING *`;
|
||||||
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
console.log(`🗑️ 삭제 쿼리:`, queryText, params);
|
||||||
|
|
||||||
const result = await query<any>(queryText, params);
|
const result = await query<any>(queryText, params);
|
||||||
|
|
||||||
|
// 삭제된 행이 없으면 실패 처리
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.warn(
|
||||||
|
`⚠️ 레코드 삭제 실패: ${tableName}, 해당 조건에 맞는 레코드가 없습니다.`,
|
||||||
|
{ whereClauses, params }
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "삭제할 레코드를 찾을 수 없습니다. 이미 삭제되었거나 권한이 없습니다.",
|
||||||
|
error: "RECORD_NOT_FOUND",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
|
`✅ 레코드 삭제 완료: ${tableName}, 영향받은 행: ${result.length}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
data: result[0], // 삭제된 레코드 정보 반환
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`레코드 삭제 오류 (${tableName}):`, error);
|
console.error(`레코드 삭제 오류 (${tableName}):`, error);
|
||||||
|
|
@ -1240,10 +1261,14 @@ class DataService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
* 조건에 맞는 모든 레코드 삭제 (그룹 삭제)
|
||||||
|
* @param tableName 테이블명
|
||||||
|
* @param filterConditions 삭제 조건
|
||||||
|
* @param userCompany 사용자 회사 코드 (멀티테넌시 필터링)
|
||||||
*/
|
*/
|
||||||
async deleteGroupRecords(
|
async deleteGroupRecords(
|
||||||
tableName: string,
|
tableName: string,
|
||||||
filterConditions: Record<string, any>
|
filterConditions: Record<string, any>,
|
||||||
|
userCompany?: string
|
||||||
): Promise<ServiceResponse<{ deleted: number }>> {
|
): Promise<ServiceResponse<{ deleted: number }>> {
|
||||||
try {
|
try {
|
||||||
const validation = await this.validateTableAccess(tableName);
|
const validation = await this.validateTableAccess(tableName);
|
||||||
|
|
@ -1255,6 +1280,7 @@ class DataService {
|
||||||
const whereValues: any[] = [];
|
const whereValues: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 사용자 필터 조건 추가
|
||||||
for (const [key, value] of Object.entries(filterConditions)) {
|
for (const [key, value] of Object.entries(filterConditions)) {
|
||||||
whereConditions.push(`"${key}" = $${paramIndex}`);
|
whereConditions.push(`"${key}" = $${paramIndex}`);
|
||||||
whereValues.push(value);
|
whereValues.push(value);
|
||||||
|
|
@ -1269,10 +1295,24 @@ class DataService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 멀티테넌시: company_code 필터링 (최고 관리자 제외)
|
||||||
|
const hasCompanyCode = await this.checkColumnExists(tableName, "company_code");
|
||||||
|
if (hasCompanyCode && userCompany && userCompany !== "*") {
|
||||||
|
whereConditions.push(`"company_code" = $${paramIndex}`);
|
||||||
|
whereValues.push(userCompany);
|
||||||
|
paramIndex++;
|
||||||
|
console.log(`🔒 멀티테넌시 필터 적용: company_code = ${userCompany}`);
|
||||||
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.join(" AND ");
|
const whereClause = whereConditions.join(" AND ");
|
||||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
|
const deleteQuery = `DELETE FROM "${tableName}" WHERE ${whereClause} RETURNING *`;
|
||||||
|
|
||||||
console.log(`🗑️ 그룹 삭제:`, { tableName, conditions: filterConditions });
|
console.log(`🗑️ 그룹 삭제:`, {
|
||||||
|
tableName,
|
||||||
|
conditions: filterConditions,
|
||||||
|
userCompany,
|
||||||
|
whereClause,
|
||||||
|
});
|
||||||
|
|
||||||
const result = await pool.query(deleteQuery, whereValues);
|
const result = await pool.query(deleteQuery, whereValues);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1306,6 +1306,41 @@ export class TableManagementService {
|
||||||
paramCount: number;
|
paramCount: number;
|
||||||
} | null> {
|
} | null> {
|
||||||
try {
|
try {
|
||||||
|
// 🆕 배열 값 처리 (다중 값 검색 - 분할패널 엔티티 타입에서 "2,3" 형태 지원)
|
||||||
|
// 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록 함
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
// 배열의 각 값에 대해 OR 조건으로 검색
|
||||||
|
// 우측 컬럼에 "2,3" 같은 다중 값이 있을 수 있으므로
|
||||||
|
// 각 값을 LIKE 또는 = 조건으로 처리
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
value.forEach((v: any, idx: number) => {
|
||||||
|
const safeValue = String(v).trim();
|
||||||
|
// 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함
|
||||||
|
// 예: "2,3" 컬럼에서 "2"를 찾으려면:
|
||||||
|
// - 정확히 "2"
|
||||||
|
// - "2," 로 시작
|
||||||
|
// - ",2" 로 끝남
|
||||||
|
// - ",2," 중간에 포함
|
||||||
|
const paramBase = paramIndex + (idx * 4);
|
||||||
|
conditions.push(`(
|
||||||
|
${columnName}::text = $${paramBase} OR
|
||||||
|
${columnName}::text LIKE $${paramBase + 1} OR
|
||||||
|
${columnName}::text LIKE $${paramBase + 2} OR
|
||||||
|
${columnName}::text LIKE $${paramBase + 3}
|
||||||
|
)`);
|
||||||
|
values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`);
|
||||||
|
return {
|
||||||
|
whereClause: `(${conditions.join(" OR ")})`,
|
||||||
|
values,
|
||||||
|
paramCount: values.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
|
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
|
||||||
if (typeof value === "string" && value.includes("|")) {
|
if (typeof value === "string" && value.includes("|")) {
|
||||||
const columnInfo = await this.getColumnWebTypeInfo(
|
const columnInfo = await this.getColumnWebTypeInfo(
|
||||||
|
|
@ -4630,4 +4665,101 @@ export class TableManagementService {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 테이블 간의 엔티티 관계 자동 감지
|
||||||
|
* column_labels에서 엔티티 타입 설정을 기반으로 테이블 간 관계를 찾습니다.
|
||||||
|
*
|
||||||
|
* @param leftTable 좌측 테이블명
|
||||||
|
* @param rightTable 우측 테이블명
|
||||||
|
* @returns 감지된 엔티티 관계 배열
|
||||||
|
*/
|
||||||
|
async detectTableEntityRelations(
|
||||||
|
leftTable: string,
|
||||||
|
rightTable: string
|
||||||
|
): Promise<Array<{
|
||||||
|
leftColumn: string;
|
||||||
|
rightColumn: string;
|
||||||
|
direction: "left_to_right" | "right_to_left";
|
||||||
|
inputType: string;
|
||||||
|
displayColumn?: string;
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`);
|
||||||
|
|
||||||
|
const relations: Array<{
|
||||||
|
leftColumn: string;
|
||||||
|
rightColumn: string;
|
||||||
|
direction: "left_to_right" | "right_to_left";
|
||||||
|
inputType: string;
|
||||||
|
displayColumn?: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// 1. 우측 테이블에서 좌측 테이블을 참조하는 엔티티 컬럼 찾기
|
||||||
|
// 예: right_table의 customer_id -> left_table(customer_mng)의 customer_code
|
||||||
|
const rightToLeftRels = await query<{
|
||||||
|
column_name: string;
|
||||||
|
reference_column: string;
|
||||||
|
input_type: string;
|
||||||
|
display_column: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT column_name, reference_column, input_type, display_column
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND input_type IN ('entity', 'category')
|
||||||
|
AND reference_table = $2
|
||||||
|
AND reference_column IS NOT NULL
|
||||||
|
AND reference_column != ''`,
|
||||||
|
[rightTable, leftTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const rel of rightToLeftRels) {
|
||||||
|
relations.push({
|
||||||
|
leftColumn: rel.reference_column,
|
||||||
|
rightColumn: rel.column_name,
|
||||||
|
direction: "right_to_left",
|
||||||
|
inputType: rel.input_type,
|
||||||
|
displayColumn: rel.display_column || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 좌측 테이블에서 우측 테이블을 참조하는 엔티티 컬럼 찾기
|
||||||
|
// 예: left_table의 item_id -> right_table(item_info)의 item_number
|
||||||
|
const leftToRightRels = await query<{
|
||||||
|
column_name: string;
|
||||||
|
reference_column: string;
|
||||||
|
input_type: string;
|
||||||
|
display_column: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT column_name, reference_column, input_type, display_column
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = $1
|
||||||
|
AND input_type IN ('entity', 'category')
|
||||||
|
AND reference_table = $2
|
||||||
|
AND reference_column IS NOT NULL
|
||||||
|
AND reference_column != ''`,
|
||||||
|
[leftTable, rightTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const rel of leftToRightRels) {
|
||||||
|
relations.push({
|
||||||
|
leftColumn: rel.column_name,
|
||||||
|
rightColumn: rel.reference_column,
|
||||||
|
direction: "left_to_right",
|
||||||
|
inputType: rel.input_type,
|
||||||
|
displayColumn: rel.display_column || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`);
|
||||||
|
relations.forEach((rel, idx) => {
|
||||||
|
logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return relations;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1369,58 +1369,25 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
}
|
}
|
||||||
|
|
||||||
case "entity": {
|
case "entity": {
|
||||||
|
// DynamicWebTypeRenderer로 위임하여 EntitySearchInputWrapper 사용
|
||||||
const widget = comp as WidgetComponent;
|
const widget = comp as WidgetComponent;
|
||||||
const config = widget.webTypeConfig as EntityTypeConfig | undefined;
|
return applyStyles(
|
||||||
|
<DynamicWebTypeRenderer
|
||||||
console.log("🏢 InteractiveScreenViewer - Entity 위젯:", {
|
webType="entity"
|
||||||
componentId: widget.id,
|
config={widget.webTypeConfig}
|
||||||
widgetType: widget.widgetType,
|
props={{
|
||||||
config,
|
component: widget,
|
||||||
appliedSettings: {
|
value: currentValue,
|
||||||
entityName: config?.entityName,
|
onChange: (value: any) => updateFormData(fieldName, value),
|
||||||
displayField: config?.displayField,
|
onFormDataChange: updateFormData,
|
||||||
valueField: config?.valueField,
|
formData: formData,
|
||||||
multiple: config?.multiple,
|
readonly: readonly,
|
||||||
defaultValue: config?.defaultValue,
|
required: required,
|
||||||
},
|
placeholder: widget.placeholder || "엔티티를 선택하세요",
|
||||||
});
|
isInteractive: true,
|
||||||
|
className: "w-full h-full",
|
||||||
const finalPlaceholder = config?.placeholder || "엔티티를 선택하세요...";
|
}}
|
||||||
const defaultOptions = [
|
/>,
|
||||||
{ label: "사용자", value: "user" },
|
|
||||||
{ label: "제품", value: "product" },
|
|
||||||
{ label: "주문", value: "order" },
|
|
||||||
{ label: "카테고리", value: "category" },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
value={currentValue || config?.defaultValue || ""}
|
|
||||||
onValueChange={(value) => updateFormData(fieldName, value)}
|
|
||||||
disabled={readonly}
|
|
||||||
required={required}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className="w-full"
|
|
||||||
style={{ height: "100%" }}
|
|
||||||
style={{
|
|
||||||
...comp.style,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectValue placeholder={finalPlaceholder} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{defaultOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{config?.displayFormat
|
|
||||||
? config.displayFormat.replace("{label}", option.label).replace("{value}", option.value)
|
|
||||||
: option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Search, Database, Link, X, Plus } from "lucide-react";
|
import { Search, Database, Link, X, Plus } from "lucide-react";
|
||||||
import { EntityTypeConfig } from "@/types/screen";
|
import { EntityTypeConfig } from "@/types/screen";
|
||||||
|
|
@ -26,6 +27,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
||||||
placeholder: "",
|
placeholder: "",
|
||||||
displayFormat: "simple",
|
displayFormat: "simple",
|
||||||
separator: " - ",
|
separator: " - ",
|
||||||
|
multiple: false, // 다중 선택
|
||||||
|
uiMode: "select", // UI 모드: select, combo, modal, autocomplete
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -38,6 +41,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
||||||
placeholder: safeConfig.placeholder,
|
placeholder: safeConfig.placeholder,
|
||||||
displayFormat: safeConfig.displayFormat,
|
displayFormat: safeConfig.displayFormat,
|
||||||
separator: safeConfig.separator,
|
separator: safeConfig.separator,
|
||||||
|
multiple: safeConfig.multiple,
|
||||||
|
uiMode: safeConfig.uiMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [newFilter, setNewFilter] = useState({ field: "", operator: "=", value: "" });
|
const [newFilter, setNewFilter] = useState({ field: "", operator: "=", value: "" });
|
||||||
|
|
@ -74,6 +79,8 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
||||||
placeholder: safeConfig.placeholder,
|
placeholder: safeConfig.placeholder,
|
||||||
displayFormat: safeConfig.displayFormat,
|
displayFormat: safeConfig.displayFormat,
|
||||||
separator: safeConfig.separator,
|
separator: safeConfig.separator,
|
||||||
|
multiple: safeConfig.multiple,
|
||||||
|
uiMode: safeConfig.uiMode,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
safeConfig.referenceTable,
|
safeConfig.referenceTable,
|
||||||
|
|
@ -83,8 +90,18 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
||||||
safeConfig.placeholder,
|
safeConfig.placeholder,
|
||||||
safeConfig.displayFormat,
|
safeConfig.displayFormat,
|
||||||
safeConfig.separator,
|
safeConfig.separator,
|
||||||
|
safeConfig.multiple,
|
||||||
|
safeConfig.uiMode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// UI 모드 옵션
|
||||||
|
const uiModes = [
|
||||||
|
{ value: "select", label: "드롭다운 선택" },
|
||||||
|
{ value: "combo", label: "입력 + 모달 버튼" },
|
||||||
|
{ value: "modal", label: "모달 팝업" },
|
||||||
|
{ value: "autocomplete", label: "자동완성" },
|
||||||
|
];
|
||||||
|
|
||||||
const updateConfig = (key: keyof EntityTypeConfig, value: any) => {
|
const updateConfig = (key: keyof EntityTypeConfig, value: any) => {
|
||||||
// 로컬 상태 즉시 업데이트
|
// 로컬 상태 즉시 업데이트
|
||||||
setLocalValues((prev) => ({ ...prev, [key]: value }));
|
setLocalValues((prev) => ({ ...prev, [key]: value }));
|
||||||
|
|
@ -260,6 +277,46 @@ export const EntityTypeConfigPanel: React.FC<EntityTypeConfigPanelProps> = ({ co
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* UI 모드 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="uiMode" className="text-sm font-medium">
|
||||||
|
UI 모드
|
||||||
|
</Label>
|
||||||
|
<Select value={localValues.uiMode || "select"} onValueChange={(value) => updateConfig("uiMode", value)}>
|
||||||
|
<SelectTrigger className="mt-1 h-8 w-full text-xs">
|
||||||
|
<SelectValue placeholder="모드 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{uiModes.map((mode) => (
|
||||||
|
<SelectItem key={mode.value} value={mode.value}>
|
||||||
|
{mode.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
{localValues.uiMode === "select" && "검색 가능한 드롭다운 형태로 표시됩니다."}
|
||||||
|
{localValues.uiMode === "combo" && "입력 필드와 검색 버튼이 함께 표시됩니다."}
|
||||||
|
{localValues.uiMode === "modal" && "모달 팝업에서 데이터를 검색하고 선택합니다."}
|
||||||
|
{localValues.uiMode === "autocomplete" && "입력하면서 자동완성 목록이 표시됩니다."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다중 선택 */}
|
||||||
|
<div className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="multiple" className="text-sm font-medium">
|
||||||
|
다중 선택
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">여러 항목을 선택할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="multiple"
|
||||||
|
checked={localValues.multiple || false}
|
||||||
|
onCheckedChange={(checked) => updateConfig("multiple", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 필터 관리 */}
|
{/* 필터 관리 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-sm font-medium">데이터 필터</Label>
|
<Label className="text-sm font-medium">데이터 필터</Label>
|
||||||
|
|
|
||||||
|
|
@ -93,10 +93,15 @@ export class DynamicFormApi {
|
||||||
): Promise<ApiResponse<SaveFormDataResponse>> {
|
): Promise<ApiResponse<SaveFormDataResponse>> {
|
||||||
try {
|
try {
|
||||||
console.log("🔄 폼 데이터 업데이트 요청:", { id, formData });
|
console.log("🔄 폼 데이터 업데이트 요청:", { id, formData });
|
||||||
|
console.log("🌐 API URL:", `/dynamic-form/${id}`);
|
||||||
|
console.log("📦 요청 본문:", JSON.stringify(formData, null, 2));
|
||||||
|
|
||||||
const response = await apiClient.put(`/dynamic-form/${id}`, formData);
|
const response = await apiClient.put(`/dynamic-form/${id}`, formData);
|
||||||
|
|
||||||
console.log("✅ 폼 데이터 업데이트 성공:", response.data);
|
console.log("✅ 폼 데이터 업데이트 성공:", response.data);
|
||||||
|
console.log("📊 응답 상태:", response.status);
|
||||||
|
console.log("📋 응답 헤더:", response.headers);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: response.data,
|
data: response.data,
|
||||||
|
|
@ -104,6 +109,8 @@ export class DynamicFormApi {
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("❌ 폼 데이터 업데이트 실패:", error);
|
console.error("❌ 폼 데이터 업데이트 실패:", error);
|
||||||
|
console.error("📊 에러 응답:", error.response?.data);
|
||||||
|
console.error("📊 에러 상태:", error.response?.status);
|
||||||
|
|
||||||
const errorMessage = error.response?.data?.message || error.message || "데이터 업데이트 중 오류가 발생했습니다.";
|
const errorMessage = error.response?.data?.message || error.message || "데이터 업데이트 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,40 @@ class TableManagementApi {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 두 테이블 간의 엔티티 관계 자동 감지
|
||||||
|
* column_labels에서 정의된 엔티티/카테고리 타입 설정을 기반으로
|
||||||
|
* 두 테이블 간의 외래키 관계를 자동으로 감지합니다.
|
||||||
|
*/
|
||||||
|
async getTableEntityRelations(
|
||||||
|
leftTable: string,
|
||||||
|
rightTable: string
|
||||||
|
): Promise<ApiResponse<{
|
||||||
|
leftTable: string;
|
||||||
|
rightTable: string;
|
||||||
|
relations: Array<{
|
||||||
|
leftColumn: string;
|
||||||
|
rightColumn: string;
|
||||||
|
direction: "left_to_right" | "right_to_left";
|
||||||
|
inputType: string;
|
||||||
|
displayColumn?: string;
|
||||||
|
}>;
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`${this.basePath}/tables/entity-relations?leftTable=${encodeURIComponent(leftTable)}&rightTable=${encodeURIComponent(rightTable)}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`❌ 테이블 엔티티 관계 조회 실패: ${leftTable} <-> ${rightTable}`, error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.message || error.message || "테이블 엔티티 관계를 조회할 수 없습니다.",
|
||||||
|
errorCode: error.response?.data?.errorCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 싱글톤 인스턴스 생성
|
// 싱글톤 인스턴스 생성
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,9 @@ export function EntitySearchInputComponent({
|
||||||
parentValue: parentValueProp,
|
parentValue: parentValueProp,
|
||||||
parentFieldId,
|
parentFieldId,
|
||||||
formData,
|
formData,
|
||||||
// 🆕 추가 props
|
// 다중선택 props
|
||||||
|
multiple: multipleProp,
|
||||||
|
// 추가 props
|
||||||
component,
|
component,
|
||||||
isInteractive,
|
isInteractive,
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
|
|
@ -49,8 +51,11 @@ export function EntitySearchInputComponent({
|
||||||
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
|
// uiMode가 있으면 우선 사용, 없으면 modeProp 사용, 기본값 "combo"
|
||||||
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
|
const mode = (uiMode || modeProp || "combo") as "select" | "modal" | "combo" | "autocomplete";
|
||||||
|
|
||||||
// 연쇄관계 설정 추출 (webTypeConfig 또는 component.componentConfig에서)
|
// 다중선택 및 연쇄관계 설정 (props > webTypeConfig > componentConfig 순서)
|
||||||
const config = component?.componentConfig || {};
|
const config = component?.componentConfig || component?.webTypeConfig || {};
|
||||||
|
const isMultiple = multipleProp ?? config.multiple ?? false;
|
||||||
|
|
||||||
|
// 연쇄관계 설정 추출
|
||||||
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
|
const effectiveCascadingRelationCode = cascadingRelationCode || config.cascadingRelationCode;
|
||||||
// cascadingParentField: ConfigPanel에서 저장되는 필드명
|
// cascadingParentField: ConfigPanel에서 저장되는 필드명
|
||||||
const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId;
|
const effectiveParentFieldId = parentFieldId || config.cascadingParentField || config.parentFieldId;
|
||||||
|
|
@ -68,11 +73,27 @@ export function EntitySearchInputComponent({
|
||||||
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||||
const [optionsLoaded, setOptionsLoaded] = useState(false);
|
const [optionsLoaded, setOptionsLoaded] = useState(false);
|
||||||
|
|
||||||
|
// 다중선택 상태 (콤마로 구분된 값들)
|
||||||
|
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||||
|
const [selectedDataList, setSelectedDataList] = useState<EntitySearchResult[]>([]);
|
||||||
|
|
||||||
// 연쇄관계 상태
|
// 연쇄관계 상태
|
||||||
const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]);
|
const [cascadingOptions, setCascadingOptions] = useState<EntitySearchResult[]>([]);
|
||||||
const [isCascadingLoading, setIsCascadingLoading] = useState(false);
|
const [isCascadingLoading, setIsCascadingLoading] = useState(false);
|
||||||
const previousParentValue = useRef<any>(null);
|
const previousParentValue = useRef<any>(null);
|
||||||
|
|
||||||
|
// 다중선택 초기값 설정
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMultiple && value) {
|
||||||
|
const vals =
|
||||||
|
typeof value === "string" ? value.split(",").filter(Boolean) : Array.isArray(value) ? value : [value];
|
||||||
|
setSelectedValues(vals.map(String));
|
||||||
|
} else if (isMultiple && !value) {
|
||||||
|
setSelectedValues([]);
|
||||||
|
setSelectedDataList([]);
|
||||||
|
}
|
||||||
|
}, [isMultiple, value]);
|
||||||
|
|
||||||
// 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요
|
// 부모 필드 값 결정 (직접 전달 또는 formData에서 추출) - 자식 역할일 때만 필요
|
||||||
const parentValue = isChildRole
|
const parentValue = isChildRole
|
||||||
? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined))
|
? (parentValueProp ?? (effectiveParentFieldId && formData ? formData[effectiveParentFieldId] : undefined))
|
||||||
|
|
@ -249,23 +270,75 @@ export function EntitySearchInputComponent({
|
||||||
}, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]);
|
}, [value, displayField, effectiveOptions, mode, valueField, tableName, selectedData]);
|
||||||
|
|
||||||
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
|
const handleSelect = (newValue: any, fullData: EntitySearchResult) => {
|
||||||
setSelectedData(fullData);
|
if (isMultiple) {
|
||||||
setDisplayValue(fullData[displayField] || "");
|
// 다중선택 모드
|
||||||
onChange?.(newValue, fullData);
|
const valueStr = String(newValue);
|
||||||
|
const isAlreadySelected = selectedValues.includes(valueStr);
|
||||||
|
|
||||||
|
let newSelectedValues: string[];
|
||||||
|
let newSelectedDataList: EntitySearchResult[];
|
||||||
|
|
||||||
|
if (isAlreadySelected) {
|
||||||
|
// 이미 선택된 항목이면 제거
|
||||||
|
newSelectedValues = selectedValues.filter((v) => v !== valueStr);
|
||||||
|
newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueStr);
|
||||||
|
} else {
|
||||||
|
// 선택되지 않은 항목이면 추가
|
||||||
|
newSelectedValues = [...selectedValues, valueStr];
|
||||||
|
newSelectedDataList = [...selectedDataList, fullData];
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
setSelectedDataList(newSelectedDataList);
|
||||||
|
|
||||||
|
const joinedValue = newSelectedValues.join(",");
|
||||||
|
onChange?.(joinedValue, newSelectedDataList);
|
||||||
|
|
||||||
|
if (isInteractive && onFormDataChange && component?.columnName) {
|
||||||
|
onFormDataChange(component.columnName, joinedValue);
|
||||||
|
console.log("📤 EntitySearchInput (multiple) -> onFormDataChange:", component.columnName, joinedValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 단일선택 모드
|
||||||
|
setSelectedData(fullData);
|
||||||
|
setDisplayValue(fullData[displayField] || "");
|
||||||
|
onChange?.(newValue, fullData);
|
||||||
|
|
||||||
|
if (isInteractive && onFormDataChange && component?.columnName) {
|
||||||
|
onFormDataChange(component.columnName, newValue);
|
||||||
|
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다중선택 모드에서 개별 항목 제거
|
||||||
|
const handleRemoveValue = (valueToRemove: string) => {
|
||||||
|
const newSelectedValues = selectedValues.filter((v) => v !== valueToRemove);
|
||||||
|
const newSelectedDataList = selectedDataList.filter((d) => String(d[valueField]) !== valueToRemove);
|
||||||
|
|
||||||
|
setSelectedValues(newSelectedValues);
|
||||||
|
setSelectedDataList(newSelectedDataList);
|
||||||
|
|
||||||
|
const joinedValue = newSelectedValues.join(",");
|
||||||
|
onChange?.(joinedValue || null, newSelectedDataList);
|
||||||
|
|
||||||
// 🆕 onFormDataChange 호출 (formData에 값 저장)
|
|
||||||
if (isInteractive && onFormDataChange && component?.columnName) {
|
if (isInteractive && onFormDataChange && component?.columnName) {
|
||||||
onFormDataChange(component.columnName, newValue);
|
onFormDataChange(component.columnName, joinedValue || null);
|
||||||
console.log("📤 EntitySearchInput -> onFormDataChange:", component.columnName, newValue);
|
console.log("📤 EntitySearchInput (remove) -> onFormDataChange:", component.columnName, joinedValue);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setDisplayValue("");
|
if (isMultiple) {
|
||||||
setSelectedData(null);
|
setSelectedValues([]);
|
||||||
onChange?.(null, null);
|
setSelectedDataList([]);
|
||||||
|
onChange?.(null, []);
|
||||||
|
} else {
|
||||||
|
setDisplayValue("");
|
||||||
|
setSelectedData(null);
|
||||||
|
onChange?.(null, null);
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 onFormDataChange 호출 (formData에서 값 제거)
|
|
||||||
if (isInteractive && onFormDataChange && component?.columnName) {
|
if (isInteractive && onFormDataChange && component?.columnName) {
|
||||||
onFormDataChange(component.columnName, null);
|
onFormDataChange(component.columnName, null);
|
||||||
console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null);
|
console.log("📤 EntitySearchInput -> onFormDataChange (clear):", component.columnName, null);
|
||||||
|
|
@ -280,7 +353,10 @@ export function EntitySearchInputComponent({
|
||||||
|
|
||||||
const handleSelectOption = (option: EntitySearchResult) => {
|
const handleSelectOption = (option: EntitySearchResult) => {
|
||||||
handleSelect(option[valueField], option);
|
handleSelect(option[valueField], option);
|
||||||
setSelectOpen(false);
|
// 다중선택이 아닌 경우에만 드롭다운 닫기
|
||||||
|
if (!isMultiple) {
|
||||||
|
setSelectOpen(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값)
|
// 높이 계산 (style에서 height가 있으면 사용, 없으면 기본값)
|
||||||
|
|
@ -289,6 +365,111 @@ export function EntitySearchInputComponent({
|
||||||
|
|
||||||
// select 모드: 검색 가능한 드롭다운
|
// select 모드: 검색 가능한 드롭다운
|
||||||
if (mode === "select") {
|
if (mode === "select") {
|
||||||
|
// 다중선택 모드
|
||||||
|
if (isMultiple) {
|
||||||
|
return (
|
||||||
|
<div className={cn("relative flex flex-col", className)} style={style}>
|
||||||
|
{/* 라벨 렌더링 */}
|
||||||
|
{component?.label && component?.style?.labelDisplay !== false && (
|
||||||
|
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||||
|
{component.label}
|
||||||
|
{component.required && <span className="text-destructive">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 항목들 표시 (태그 형식) */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"box-border flex min-h-[40px] w-full flex-wrap items-center gap-2 rounded-md border bg-white px-3 py-2",
|
||||||
|
!disabled && "hover:border-primary/50",
|
||||||
|
disabled && "cursor-not-allowed bg-gray-100 opacity-60",
|
||||||
|
)}
|
||||||
|
onClick={() => !disabled && !isLoading && setSelectOpen(true)}
|
||||||
|
style={{ cursor: disabled ? "not-allowed" : "pointer" }}
|
||||||
|
>
|
||||||
|
{selectedValues.length > 0 ? (
|
||||||
|
selectedValues.map((val) => {
|
||||||
|
const opt = effectiveOptions.find((o) => String(o[valueField]) === val);
|
||||||
|
const label = opt?.[displayField] || val;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={val}
|
||||||
|
className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveValue(val);
|
||||||
|
}}
|
||||||
|
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{isLoading
|
||||||
|
? "로딩 중..."
|
||||||
|
: shouldApplyCascading && !parentValue
|
||||||
|
? "상위 항목을 먼저 선택하세요"
|
||||||
|
: placeholder}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronsUpDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 옵션 드롭다운 */}
|
||||||
|
{selectOpen && !disabled && (
|
||||||
|
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border bg-white shadow-md">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={`${displayField} 검색...`} className="text-xs sm:text-sm" />
|
||||||
|
<CommandList className="max-h-60">
|
||||||
|
<CommandEmpty className="py-4 text-center text-xs sm:text-sm">항목을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{effectiveOptions.map((option, index) => {
|
||||||
|
const isSelected = selectedValues.includes(String(option[valueField]));
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option[valueField] || index}
|
||||||
|
value={`${option[displayField] || ""}-${option[valueField] || ""}`}
|
||||||
|
onSelect={() => handleSelectOption(option)}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Check className={cn("mr-2 h-4 w-4", isSelected ? "opacity-100" : "opacity-0")} />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{option[displayField]}</span>
|
||||||
|
{valueField !== displayField && (
|
||||||
|
<span className="text-muted-foreground text-[10px]">{option[valueField]}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
{/* 닫기 버튼 */}
|
||||||
|
<div className="border-t p-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setSelectOpen(false)} className="w-full text-xs">
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 외부 클릭 시 닫기 */}
|
||||||
|
{selectOpen && <div className="fixed inset-0 z-40" onClick={() => setSelectOpen(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 단일선택 모드 (기존 로직)
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative flex flex-col", className)} style={style}>
|
<div className={cn("relative flex flex-col", className)} style={style}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
|
|
@ -366,6 +547,95 @@ export function EntitySearchInputComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
// modal, combo, autocomplete 모드
|
// modal, combo, autocomplete 모드
|
||||||
|
// 다중선택 모드
|
||||||
|
if (isMultiple) {
|
||||||
|
return (
|
||||||
|
<div className={cn("relative flex flex-col", className)} style={style}>
|
||||||
|
{/* 라벨 렌더링 */}
|
||||||
|
{component?.label && component?.style?.labelDisplay !== false && (
|
||||||
|
<label className="text-muted-foreground absolute -top-6 left-0 text-sm font-medium">
|
||||||
|
{component.label}
|
||||||
|
{component.required && <span className="text-destructive">*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 항목들 표시 (태그 형식) + 검색 버튼 */}
|
||||||
|
<div className="flex h-full gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"box-border flex min-h-[40px] flex-1 flex-wrap items-center gap-2 rounded-md border bg-white px-3 py-2",
|
||||||
|
!disabled && "hover:border-primary/50",
|
||||||
|
disabled && "cursor-not-allowed bg-gray-100 opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedValues.length > 0 ? (
|
||||||
|
selectedValues.map((val) => {
|
||||||
|
// selectedDataList에서 먼저 찾고, 없으면 effectiveOptions에서 찾기
|
||||||
|
const dataFromList = selectedDataList.find((d) => String(d[valueField]) === val);
|
||||||
|
const opt = dataFromList || effectiveOptions.find((o) => String(o[valueField]) === val);
|
||||||
|
const label = opt?.[displayField] || val;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={val}
|
||||||
|
className="flex items-center gap-1 rounded bg-blue-100 px-2 py-1 text-sm text-blue-800"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveValue(val);
|
||||||
|
}}
|
||||||
|
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">{placeholder}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모달 버튼: modal 또는 combo 모드일 때만 표시 */}
|
||||||
|
{(mode === "modal" || mode === "combo") && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(!componentHeight && "h-8 text-xs sm:h-10 sm:text-sm")}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 모달: modal 또는 combo 모드일 때만 렌더링 */}
|
||||||
|
{(mode === "modal" || mode === "combo") && (
|
||||||
|
<EntitySearchModal
|
||||||
|
open={modalOpen}
|
||||||
|
onOpenChange={setModalOpen}
|
||||||
|
tableName={tableName}
|
||||||
|
displayField={displayField}
|
||||||
|
valueField={valueField}
|
||||||
|
searchFields={searchFields}
|
||||||
|
filterCondition={filterCondition}
|
||||||
|
modalTitle={modalTitle}
|
||||||
|
modalColumns={modalColumns}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
multiple={isMultiple}
|
||||||
|
selectedValues={selectedValues}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 단일선택 모드 (기존 로직)
|
||||||
return (
|
return (
|
||||||
<div className={cn("relative flex flex-col", className)} style={style}>
|
<div className={cn("relative flex flex-col", className)} style={style}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
|
|
|
||||||
|
|
@ -747,6 +747,23 @@ export function EntitySearchInputConfigPanel({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs sm:text-sm">다중 선택</Label>
|
||||||
|
<Switch
|
||||||
|
checked={localConfig.multiple || false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
updateConfig({ multiple: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{localConfig.multiple
|
||||||
|
? "여러 항목을 선택할 수 있습니다. 값은 콤마로 구분됩니다."
|
||||||
|
: "하나의 항목만 선택할 수 있습니다."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
<Label className="text-xs sm:text-sm">플레이스홀더</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { EntitySearchInputComponent } from "./EntitySearchInputComponent";
|
||||||
|
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EntitySearchInput 래퍼 컴포넌트
|
||||||
|
* WebTypeRegistry에서 사용하기 위한 래퍼로,
|
||||||
|
* 레지스트리 props를 EntitySearchInputComponent에 맞게 변환합니다.
|
||||||
|
*/
|
||||||
|
export const EntitySearchInputWrapper: React.FC<WebTypeComponentProps> = ({
|
||||||
|
component,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
readonly = false,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
// component에서 필요한 설정 추출
|
||||||
|
const widget = component as any;
|
||||||
|
const webTypeConfig = widget?.webTypeConfig || {};
|
||||||
|
const componentConfig = widget?.componentConfig || {};
|
||||||
|
|
||||||
|
// 설정 우선순위: webTypeConfig > componentConfig > component 직접 속성
|
||||||
|
const config = { ...componentConfig, ...webTypeConfig };
|
||||||
|
|
||||||
|
// 테이블 타입 관리에서 설정된 참조 테이블 정보 사용
|
||||||
|
const tableName = config.referenceTable || widget?.referenceTable || "";
|
||||||
|
const displayField = config.labelField || config.displayColumn || config.displayField || "name";
|
||||||
|
const valueField = config.valueField || config.referenceColumn || "id";
|
||||||
|
|
||||||
|
// UI 모드: uiMode > mode 순서
|
||||||
|
const uiMode = config.uiMode || config.mode || "select";
|
||||||
|
|
||||||
|
// 다중선택 설정
|
||||||
|
const multiple = config.multiple ?? false;
|
||||||
|
|
||||||
|
// placeholder
|
||||||
|
const placeholder = config.placeholder || widget?.placeholder || "항목을 선택하세요";
|
||||||
|
|
||||||
|
console.log("🏢 EntitySearchInputWrapper 렌더링:", {
|
||||||
|
tableName,
|
||||||
|
displayField,
|
||||||
|
valueField,
|
||||||
|
uiMode,
|
||||||
|
multiple,
|
||||||
|
value,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 테이블 정보가 없으면 안내 메시지 표시
|
||||||
|
if (!tableName) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground flex h-full w-full items-center rounded-md border border-dashed px-3 py-2 text-sm">
|
||||||
|
테이블 타입 관리에서 참조 테이블을 설정해주세요
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EntitySearchInputComponent
|
||||||
|
tableName={tableName}
|
||||||
|
displayField={displayField}
|
||||||
|
valueField={valueField}
|
||||||
|
uiMode={uiMode}
|
||||||
|
placeholder={placeholder}
|
||||||
|
disabled={readonly}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
multiple={multiple}
|
||||||
|
component={component}
|
||||||
|
isInteractive={props.isInteractive}
|
||||||
|
onFormDataChange={props.onFormDataChange}
|
||||||
|
formData={props.formData}
|
||||||
|
className="h-full w-full"
|
||||||
|
style={widget?.style}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
EntitySearchInputWrapper.displayName = "EntitySearchInputWrapper";
|
||||||
|
|
||||||
|
|
@ -11,7 +11,9 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Search, Loader2 } from "lucide-react";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Search, Loader2, Check } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { useEntitySearch } from "./useEntitySearch";
|
import { useEntitySearch } from "./useEntitySearch";
|
||||||
import { EntitySearchResult } from "./types";
|
import { EntitySearchResult } from "./types";
|
||||||
|
|
||||||
|
|
@ -26,6 +28,9 @@ interface EntitySearchModalProps {
|
||||||
modalTitle?: string;
|
modalTitle?: string;
|
||||||
modalColumns?: string[];
|
modalColumns?: string[];
|
||||||
onSelect: (value: any, fullData: EntitySearchResult) => void;
|
onSelect: (value: any, fullData: EntitySearchResult) => void;
|
||||||
|
// 다중선택 관련
|
||||||
|
multiple?: boolean;
|
||||||
|
selectedValues?: string[]; // 이미 선택된 값들
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EntitySearchModal({
|
export function EntitySearchModal({
|
||||||
|
|
@ -39,6 +44,8 @@ export function EntitySearchModal({
|
||||||
modalTitle = "검색",
|
modalTitle = "검색",
|
||||||
modalColumns = [],
|
modalColumns = [],
|
||||||
onSelect,
|
onSelect,
|
||||||
|
multiple = false,
|
||||||
|
selectedValues = [],
|
||||||
}: EntitySearchModalProps) {
|
}: EntitySearchModalProps) {
|
||||||
const [localSearchText, setLocalSearchText] = useState("");
|
const [localSearchText, setLocalSearchText] = useState("");
|
||||||
const {
|
const {
|
||||||
|
|
@ -71,7 +78,15 @@ export function EntitySearchModal({
|
||||||
|
|
||||||
const handleSelect = (item: EntitySearchResult) => {
|
const handleSelect = (item: EntitySearchResult) => {
|
||||||
onSelect(item[valueField], item);
|
onSelect(item[valueField], item);
|
||||||
onOpenChange(false);
|
// 다중선택이 아닌 경우에만 모달 닫기
|
||||||
|
if (!multiple) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 항목이 선택되어 있는지 확인
|
||||||
|
const isItemSelected = (item: EntitySearchResult): boolean => {
|
||||||
|
return selectedValues.includes(String(item[valueField]));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 표시할 컬럼 결정
|
// 표시할 컬럼 결정
|
||||||
|
|
@ -123,10 +138,16 @@ export function EntitySearchModal({
|
||||||
|
|
||||||
{/* 검색 결과 테이블 */}
|
{/* 검색 결과 테이블 */}
|
||||||
<div className="border rounded-md overflow-hidden">
|
<div className="border rounded-md overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||||
<table className="w-full text-xs sm:text-sm">
|
<table className="w-full text-xs sm:text-sm">
|
||||||
<thead className="bg-muted">
|
<thead className="bg-muted sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* 다중선택 시 체크박스 컬럼 */}
|
||||||
|
{multiple && (
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||||
|
선택
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
{displayColumns.map((col) => (
|
{displayColumns.map((col) => (
|
||||||
<th
|
<th
|
||||||
key={col}
|
key={col}
|
||||||
|
|
@ -135,54 +156,72 @@ export function EntitySearchModal({
|
||||||
{col}
|
{col}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
|
{!multiple && (
|
||||||
선택
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
|
||||||
</th>
|
선택
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{loading && results.length === 0 ? (
|
{loading && results.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center">
|
<td colSpan={displayColumns.length + (multiple ? 1 : 2)} className="px-4 py-8 text-center">
|
||||||
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||||||
<p className="mt-2 text-muted-foreground">검색 중...</p>
|
<p className="mt-2 text-muted-foreground">검색 중...</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : results.length === 0 ? (
|
) : results.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center text-muted-foreground">
|
<td colSpan={displayColumns.length + (multiple ? 1 : 2)} className="px-4 py-8 text-center text-muted-foreground">
|
||||||
검색 결과가 없습니다
|
검색 결과가 없습니다
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
results.map((item, index) => {
|
results.map((item, index) => {
|
||||||
const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`;
|
const uniqueKey = item[valueField] !== undefined ? `${item[valueField]}` : `row-${index}`;
|
||||||
|
const isSelected = isItemSelected(item);
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={uniqueKey}
|
key={uniqueKey}
|
||||||
className="border-t hover:bg-accent cursor-pointer transition-colors"
|
className={cn(
|
||||||
onClick={() => handleSelect(item)}
|
"border-t cursor-pointer transition-colors",
|
||||||
>
|
isSelected ? "bg-blue-50 hover:bg-blue-100" : "hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelect(item)}
|
||||||
|
>
|
||||||
|
{/* 다중선택 시 체크박스 */}
|
||||||
|
{multiple && (
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => handleSelect(item)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
{displayColumns.map((col) => (
|
{displayColumns.map((col) => (
|
||||||
<td key={`${uniqueKey}-${col}`} className="px-4 py-2">
|
<td key={`${uniqueKey}-${col}`} className="px-4 py-2">
|
||||||
{item[col] || "-"}
|
{item[col] || "-"}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
<td className="px-4 py-2">
|
{!multiple && (
|
||||||
<Button
|
<td className="px-4 py-2">
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
size="sm"
|
||||||
onClick={(e) => {
|
variant="outline"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
handleSelect(item);
|
e.stopPropagation();
|
||||||
}}
|
handleSelect(item);
|
||||||
className="h-7 text-xs"
|
}}
|
||||||
>
|
className="h-7 text-xs"
|
||||||
선택
|
>
|
||||||
</Button>
|
선택
|
||||||
</td>
|
</Button>
|
||||||
</tr>
|
</td>
|
||||||
);
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -211,12 +250,18 @@ export function EntitySearchModal({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
{/* 다중선택 시 선택된 항목 수 표시 */}
|
||||||
|
{multiple && selectedValues.length > 0 && (
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
{selectedValues.length}개 항목 선택됨
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
취소
|
{multiple ? "완료" : "취소"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ export interface EntitySearchInputConfig {
|
||||||
showAdditionalInfo?: boolean;
|
showAdditionalInfo?: boolean;
|
||||||
additionalFields?: string[];
|
additionalFields?: string[];
|
||||||
|
|
||||||
|
// 다중 선택 설정
|
||||||
|
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
|
||||||
|
|
||||||
// 연쇄관계 설정 (cascading_relation 테이블과 연동)
|
// 연쇄관계 설정 (cascading_relation 테이블과 연동)
|
||||||
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
|
cascadingRelationCode?: string; // 연쇄관계 코드 (WAREHOUSE_LOCATION 등)
|
||||||
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
|
cascadingRole?: "parent" | "child"; // 역할 (부모/자식)
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ export type { EntitySearchInputConfig } from "./config";
|
||||||
|
|
||||||
// 컴포넌트 내보내기
|
// 컴포넌트 내보내기
|
||||||
export { EntitySearchInputComponent } from "./EntitySearchInputComponent";
|
export { EntitySearchInputComponent } from "./EntitySearchInputComponent";
|
||||||
|
export { EntitySearchInputWrapper } from "./EntitySearchInputWrapper";
|
||||||
export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer";
|
export { EntitySearchInputRenderer } from "./EntitySearchInputRenderer";
|
||||||
export { EntitySearchModal } from "./EntitySearchModal";
|
export { EntitySearchModal } from "./EntitySearchModal";
|
||||||
export { useEntitySearch } from "./useEntitySearch";
|
export { useEntitySearch } from "./useEntitySearch";
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ export interface EntitySearchInputProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
||||||
|
// 다중선택
|
||||||
|
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
|
||||||
|
|
||||||
// 필터링
|
// 필터링
|
||||||
filterCondition?: Record<string, any>; // 추가 WHERE 조건
|
filterCondition?: Record<string, any>; // 추가 WHERE 조건
|
||||||
companyCode?: string; // 멀티테넌시
|
companyCode?: string; // 멀티테넌시
|
||||||
|
|
|
||||||
|
|
@ -830,7 +830,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
size: 1,
|
size: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
const detail = result.items && result.items.length > 0 ? result.items[0] : null;
|
// result.data가 EntityJoinResponse의 실제 배열 필드
|
||||||
|
const detail = result.data && result.data.length > 0 ? result.data[0] : null;
|
||||||
setRightData(detail);
|
setRightData(detail);
|
||||||
} else if (relationshipType === "join") {
|
} else if (relationshipType === "join") {
|
||||||
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
// 조인 모드: 다른 테이블의 관련 데이터 (여러 개)
|
||||||
|
|
@ -899,16 +900,54 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🆕 복합키 지원
|
// 🆕 엔티티 관계 자동 감지 로직 개선
|
||||||
if (keys && keys.length > 0 && leftTable) {
|
// 1. 설정된 keys가 있으면 사용
|
||||||
|
// 2. 없으면 테이블 타입관리에서 정의된 엔티티 관계를 자동으로 조회
|
||||||
|
let effectiveKeys = keys || [];
|
||||||
|
|
||||||
|
if (effectiveKeys.length === 0 && leftTable && rightTableName) {
|
||||||
|
// 엔티티 관계 자동 감지
|
||||||
|
console.log("🔍 [분할패널] 엔티티 관계 자동 감지 시작:", leftTable, "->", rightTableName);
|
||||||
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
|
const relResponse = await tableManagementApi.getTableEntityRelations(leftTable, rightTableName);
|
||||||
|
|
||||||
|
if (relResponse.success && relResponse.data?.relations && relResponse.data.relations.length > 0) {
|
||||||
|
effectiveKeys = relResponse.data.relations.map((rel) => ({
|
||||||
|
leftColumn: rel.leftColumn,
|
||||||
|
rightColumn: rel.rightColumn,
|
||||||
|
}));
|
||||||
|
console.log("✅ [분할패널] 자동 감지된 관계:", effectiveKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (effectiveKeys.length > 0 && leftTable) {
|
||||||
// 복합키: 여러 조건으로 필터링
|
// 복합키: 여러 조건으로 필터링
|
||||||
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
const { entityJoinApi } = await import("@/lib/api/entityJoin");
|
||||||
|
|
||||||
// 복합키 조건 생성
|
// 복합키 조건 생성 (다중 값 지원)
|
||||||
|
// 🆕 항상 배열로 전달하여 백엔드에서 다중 값 컬럼 검색을 지원하도록 함
|
||||||
|
// 예: 좌측에서 "2"를 선택해도, 우측에서 "2,3"을 가진 행이 표시되도록
|
||||||
const searchConditions: Record<string, any> = {};
|
const searchConditions: Record<string, any> = {};
|
||||||
keys.forEach((key) => {
|
effectiveKeys.forEach((key) => {
|
||||||
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
if (key.leftColumn && key.rightColumn && leftItem[key.leftColumn] !== undefined) {
|
||||||
searchConditions[key.rightColumn] = leftItem[key.leftColumn];
|
const leftValue = leftItem[key.leftColumn];
|
||||||
|
// 다중 값 지원: 모든 값을 배열로 변환하여 다중 값 컬럼 검색 활성화
|
||||||
|
if (typeof leftValue === "string") {
|
||||||
|
if (leftValue.includes(",")) {
|
||||||
|
// "2,3" 형태면 분리해서 배열로
|
||||||
|
const values = leftValue.split(",").map((v: string) => v.trim()).filter((v: string) => v);
|
||||||
|
searchConditions[key.rightColumn] = values;
|
||||||
|
console.log("🔗 [분할패널] 다중 값 검색 (분리):", key.rightColumn, "=", values);
|
||||||
|
} else {
|
||||||
|
// 단일 값도 배열로 변환 (우측에 "2,3" 같은 다중 값이 있을 수 있으므로)
|
||||||
|
searchConditions[key.rightColumn] = [leftValue.trim()];
|
||||||
|
console.log("🔗 [분할패널] 다중 값 검색 (단일):", key.rightColumn, "=", [leftValue.trim()]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 숫자나 다른 타입은 배열로 감싸기
|
||||||
|
searchConditions[key.rightColumn] = [leftValue];
|
||||||
|
console.log("🔗 [분할패널] 다중 값 검색 (기타):", key.rightColumn, "=", [leftValue]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -947,7 +986,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
|
|
||||||
setRightData(filteredData);
|
setRightData(filteredData);
|
||||||
} else {
|
} else {
|
||||||
// 단일키 (하위 호환성)
|
// 단일키 (하위 호환성) 또는 관계를 찾지 못한 경우
|
||||||
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
|
||||||
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
|
const rightColumn = componentConfig.rightPanel?.relation?.foreignKey;
|
||||||
|
|
||||||
|
|
@ -965,6 +1004,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
|
componentConfig.rightPanel?.deduplication, // 🆕 중복 제거 설정 전달
|
||||||
);
|
);
|
||||||
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
setRightData(joinedData || []); // 모든 관련 레코드 (배열)
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ [분할패널] 테이블 관계를 찾을 수 없습니다:", leftTable, "->", rightTableName);
|
||||||
|
setRightData([]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1613,47 +1655,89 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 데이터 삭제:", { tableName, primaryKey });
|
console.log("🗑️ 데이터 삭제:", { tableName, primaryKey });
|
||||||
|
|
||||||
// 🔍 중복 제거 설정 디버깅
|
// 🔍 그룹 삭제 설정 확인 (editButton.groupByColumns 또는 deduplication)
|
||||||
console.log("🔍 중복 제거 디버깅:", {
|
const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || [];
|
||||||
|
const deduplication = componentConfig.rightPanel?.dataFilter?.deduplication;
|
||||||
|
|
||||||
|
console.log("🔍 삭제 설정 디버깅:", {
|
||||||
panel: deleteModalPanel,
|
panel: deleteModalPanel,
|
||||||
dataFilter: componentConfig.rightPanel?.dataFilter,
|
groupByColumns,
|
||||||
deduplication: componentConfig.rightPanel?.dataFilter?.deduplication,
|
deduplication,
|
||||||
enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled,
|
deduplicationEnabled: deduplication?.enabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
|
|
||||||
// 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제
|
// 🔧 우측 패널 삭제 시 그룹 삭제 조건 확인
|
||||||
if (deleteModalPanel === "right" && componentConfig.rightPanel?.dataFilter?.deduplication?.enabled) {
|
if (deleteModalPanel === "right") {
|
||||||
const deduplication = componentConfig.rightPanel.dataFilter.deduplication;
|
// 1. groupByColumns가 설정된 경우 (패널 설정에서 선택된 컬럼들)
|
||||||
const groupByColumn = deduplication.groupByColumn;
|
if (groupByColumns.length > 0) {
|
||||||
|
const filterConditions: Record<string, any> = {};
|
||||||
if (groupByColumn && deleteModalItem[groupByColumn]) {
|
|
||||||
const groupValue = deleteModalItem[groupByColumn];
|
// 선택된 컬럼들의 값을 필터 조건으로 추가
|
||||||
console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`);
|
for (const col of groupByColumns) {
|
||||||
|
if (deleteModalItem[col] !== undefined && deleteModalItem[col] !== null) {
|
||||||
// groupByColumn 값으로 필터링하여 삭제
|
filterConditions[col] = deleteModalItem[col];
|
||||||
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);
|
// 🔒 안전장치: 조인 모드에서 좌측 패널의 키 값도 필터 조건에 포함
|
||||||
|
// (다른 거래처의 같은 품목이 삭제되는 것을 방지)
|
||||||
|
if (selectedLeftItem && componentConfig.rightPanel?.mode === "join") {
|
||||||
|
const leftColumn = componentConfig.rightPanel.join?.leftColumn;
|
||||||
|
const rightColumn = componentConfig.rightPanel.join?.rightColumn;
|
||||||
|
if (leftColumn && rightColumn && selectedLeftItem[leftColumn]) {
|
||||||
|
// rightColumn이 filterConditions에 없으면 추가
|
||||||
|
if (!filterConditions[rightColumn]) {
|
||||||
|
filterConditions[rightColumn] = selectedLeftItem[leftColumn];
|
||||||
|
console.log(`🔒 안전장치: ${rightColumn} = ${selectedLeftItem[leftColumn]} 추가`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 그룹 삭제 API 호출
|
// 필터 조건이 있으면 그룹 삭제
|
||||||
result = await dataApi.deleteGroupRecords(tableName, filterConditions);
|
if (Object.keys(filterConditions).length > 0) {
|
||||||
} else {
|
console.log(`🔗 그룹 삭제 (groupByColumns): ${groupByColumns.join(", ")} 기준`);
|
||||||
// 단일 레코드 삭제
|
console.log("🗑️ 그룹 삭제 조건:", filterConditions);
|
||||||
|
|
||||||
|
result = await dataApi.deleteGroupRecords(tableName, filterConditions);
|
||||||
|
} else {
|
||||||
|
// 필터 조건이 없으면 단일 삭제
|
||||||
|
console.log("⚠️ groupByColumns 값이 없어 단일 삭제로 전환");
|
||||||
|
result = await dataApi.deleteRecord(tableName, primaryKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. 중복 제거(deduplication)가 활성화된 경우
|
||||||
|
else if (deduplication?.enabled && deduplication?.groupByColumn) {
|
||||||
|
const groupByColumn = deduplication.groupByColumn;
|
||||||
|
const groupValue = deleteModalItem[groupByColumn];
|
||||||
|
|
||||||
|
if (groupValue) {
|
||||||
|
console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`);
|
||||||
|
|
||||||
|
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);
|
||||||
|
result = await dataApi.deleteGroupRecords(tableName, filterConditions);
|
||||||
|
} else {
|
||||||
|
result = await dataApi.deleteRecord(tableName, primaryKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. 그 외: 단일 레코드 삭제
|
||||||
|
else {
|
||||||
result = await dataApi.deleteRecord(tableName, primaryKey);
|
result = await dataApi.deleteRecord(tableName, primaryKey);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 단일 레코드 삭제
|
// 좌측 패널: 단일 레코드 삭제
|
||||||
result = await dataApi.deleteRecord(tableName, primaryKey);
|
result = await dataApi.deleteRecord(tableName, primaryKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,71 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
}
|
}
|
||||||
}, [config.rightPanel?.tableName]);
|
}, [config.rightPanel?.tableName]);
|
||||||
|
|
||||||
|
// 🆕 좌측/우측 테이블이 모두 선택되면 엔티티 관계 자동 감지
|
||||||
|
const [autoDetectedRelations, setAutoDetectedRelations] = useState<
|
||||||
|
Array<{
|
||||||
|
leftColumn: string;
|
||||||
|
rightColumn: string;
|
||||||
|
direction: "left_to_right" | "right_to_left";
|
||||||
|
inputType: string;
|
||||||
|
displayColumn?: string;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
const [isDetectingRelations, setIsDetectingRelations] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const detectRelations = async () => {
|
||||||
|
const leftTable = config.leftPanel?.tableName || screenTableName;
|
||||||
|
const rightTable = config.rightPanel?.tableName;
|
||||||
|
|
||||||
|
// 조인 모드이고 양쪽 테이블이 모두 있을 때만 감지
|
||||||
|
if (relationshipType !== "join" || !leftTable || !rightTable) {
|
||||||
|
setAutoDetectedRelations([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDetectingRelations(true);
|
||||||
|
try {
|
||||||
|
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||||
|
const response = await tableManagementApi.getTableEntityRelations(leftTable, rightTable);
|
||||||
|
|
||||||
|
if (response.success && response.data?.relations) {
|
||||||
|
console.log("🔍 엔티티 관계 자동 감지:", response.data.relations);
|
||||||
|
setAutoDetectedRelations(response.data.relations);
|
||||||
|
|
||||||
|
// 감지된 관계가 있고, 현재 설정된 키가 없으면 자동으로 첫 번째 관계를 설정
|
||||||
|
const currentKeys = config.rightPanel?.relation?.keys || [];
|
||||||
|
if (response.data.relations.length > 0 && currentKeys.length === 0) {
|
||||||
|
// 첫 번째 관계만 자동 설정 (사용자가 추가로 설정 가능)
|
||||||
|
const firstRel = response.data.relations[0];
|
||||||
|
console.log("✅ 첫 번째 엔티티 관계 자동 설정:", firstRel);
|
||||||
|
updateRightPanel({
|
||||||
|
relation: {
|
||||||
|
...config.rightPanel?.relation,
|
||||||
|
type: "join",
|
||||||
|
useMultipleKeys: true,
|
||||||
|
keys: [
|
||||||
|
{
|
||||||
|
leftColumn: firstRel.leftColumn,
|
||||||
|
rightColumn: firstRel.rightColumn,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 엔티티 관계 감지 실패:", error);
|
||||||
|
setAutoDetectedRelations([]);
|
||||||
|
} finally {
|
||||||
|
setIsDetectingRelations(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
detectRelations();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [config.leftPanel?.tableName, config.rightPanel?.tableName, screenTableName, relationshipType]);
|
||||||
|
|
||||||
console.log("🔧 SplitPanelLayoutConfigPanel 렌더링");
|
console.log("🔧 SplitPanelLayoutConfigPanel 렌더링");
|
||||||
console.log(" - config:", config);
|
console.log(" - config:", config);
|
||||||
console.log(" - tables:", tables);
|
console.log(" - tables:", tables);
|
||||||
|
|
@ -1633,234 +1698,50 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 컬럼 매핑 - 조인 모드에서만 표시 */}
|
{/* 엔티티 관계 자동 감지 (읽기 전용) - 조인 모드에서만 표시 */}
|
||||||
{relationshipType !== "detail" && (
|
{relationshipType !== "detail" && (
|
||||||
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
<div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div>
|
||||||
<div>
|
<Label className="text-sm font-semibold">테이블 관계 (자동 감지)</Label>
|
||||||
<Label className="text-sm font-semibold">컬럼 매핑 (외래키 관계)</Label>
|
<p className="text-xs text-gray-600">테이블 타입관리에서 정의된 엔티티 관계입니다</p>
|
||||||
<p className="text-xs text-gray-600">좌측 테이블의 컬럼을 우측 테이블의 컬럼과 연결합니다</p>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
const currentKeys = config.rightPanel?.relation?.keys || [];
|
|
||||||
// 단일키에서 복합키로 전환 시 기존 값 유지
|
|
||||||
if (
|
|
||||||
currentKeys.length === 0 &&
|
|
||||||
config.rightPanel?.relation?.leftColumn &&
|
|
||||||
config.rightPanel?.relation?.foreignKey
|
|
||||||
) {
|
|
||||||
updateRightPanel({
|
|
||||||
relation: {
|
|
||||||
...config.rightPanel?.relation,
|
|
||||||
keys: [
|
|
||||||
{
|
|
||||||
leftColumn: config.rightPanel.relation.leftColumn,
|
|
||||||
rightColumn: config.rightPanel.relation.foreignKey,
|
|
||||||
},
|
|
||||||
{ leftColumn: "", rightColumn: "" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updateRightPanel({
|
|
||||||
relation: {
|
|
||||||
...config.rightPanel?.relation,
|
|
||||||
keys: [...currentKeys, { leftColumn: "", rightColumn: "" }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
조인 키 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-[10px] text-blue-600">복합키: 여러 컬럼으로 조인 (예: item_code + lot_number)</p>
|
{isDetectingRelations ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||||
{/* 복합키가 설정된 경우 */}
|
<div className="h-3 w-3 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||||
{(config.rightPanel?.relation?.keys || []).length > 0 ? (
|
관계 감지 중...
|
||||||
<>
|
</div>
|
||||||
{(config.rightPanel?.relation?.keys || []).map((key, index) => (
|
) : autoDetectedRelations.length > 0 ? (
|
||||||
<div key={index} className="space-y-2 rounded-md border bg-white p-3">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
{autoDetectedRelations.map((rel, index) => (
|
||||||
<span className="text-xs font-medium">조인 키 {index + 1}</span>
|
<div key={index} className="flex items-center gap-2 rounded-md border border-blue-300 bg-white p-2">
|
||||||
<Button
|
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||||
size="sm"
|
{leftTableName}.{rel.leftColumn}
|
||||||
variant="ghost"
|
</span>
|
||||||
className="text-destructive h-6 w-6 p-0"
|
<ArrowRight className="h-3 w-3 text-blue-400" />
|
||||||
onClick={() => {
|
<span className="rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">
|
||||||
const newKeys = (config.rightPanel?.relation?.keys || []).filter((_, i) => i !== index);
|
{rightTableName}.{rel.rightColumn}
|
||||||
updateRightPanel({
|
</span>
|
||||||
relation: { ...config.rightPanel?.relation, keys: newKeys },
|
<span className="ml-auto text-[10px] text-gray-500">
|
||||||
});
|
{rel.inputType === "entity" ? "엔티티" : "카테고리"}
|
||||||
}}
|
</span>
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">좌측 컬럼</Label>
|
|
||||||
<Select
|
|
||||||
value={key.leftColumn || ""}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const newKeys = [...(config.rightPanel?.relation?.keys || [])];
|
|
||||||
newKeys[index] = { ...newKeys[index], leftColumn: value };
|
|
||||||
updateRightPanel({
|
|
||||||
relation: { ...config.rightPanel?.relation, keys: newKeys },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue placeholder="좌측 컬럼" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{leftTableColumns.map((column) => (
|
|
||||||
<SelectItem key={column.columnName} value={column.columnName}>
|
|
||||||
{column.columnName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs">우측 컬럼</Label>
|
|
||||||
<Select
|
|
||||||
value={key.rightColumn || ""}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
const newKeys = [...(config.rightPanel?.relation?.keys || [])];
|
|
||||||
newKeys[index] = { ...newKeys[index], rightColumn: value };
|
|
||||||
updateRightPanel({
|
|
||||||
relation: { ...config.rightPanel?.relation, keys: newKeys },
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
|
||||||
<SelectValue placeholder="우측 컬럼" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{rightTableColumns.map((column) => (
|
|
||||||
<SelectItem key={column.columnName} value={column.columnName}>
|
|
||||||
{column.columnName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
<p className="text-[10px] text-blue-600">
|
||||||
|
테이블 타입관리에서 엔티티/카테고리 설정을 변경하면 자동으로 적용됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : config.rightPanel?.tableName ? (
|
||||||
|
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||||
|
<p className="text-xs text-gray-500">감지된 엔티티 관계가 없습니다</p>
|
||||||
|
<p className="mt-1 text-[10px] text-gray-400">
|
||||||
|
테이블 타입관리에서 엔티티 타입과 참조 테이블을 설정하세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* 단일키 (하위 호환성) */
|
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
|
||||||
<>
|
<p className="text-xs text-gray-500">우측 테이블을 선택하면 관계를 자동 감지합니다</p>
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
<Label className="text-xs">좌측 컬럼</Label>
|
|
||||||
<Popover open={leftColumnOpen} onOpenChange={setLeftColumnOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={leftColumnOpen}
|
|
||||||
className="w-full justify-between"
|
|
||||||
disabled={!config.leftPanel?.tableName}
|
|
||||||
>
|
|
||||||
{config.rightPanel?.relation?.leftColumn || "좌측 컬럼 선택"}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-full p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="컬럼 검색..." />
|
|
||||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
|
||||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
|
||||||
{leftTableColumns.map((column) => (
|
|
||||||
<CommandItem
|
|
||||||
key={column.columnName}
|
|
||||||
value={column.columnName}
|
|
||||||
onSelect={(value) => {
|
|
||||||
updateRightPanel({
|
|
||||||
relation: { ...config.rightPanel?.relation, leftColumn: value },
|
|
||||||
});
|
|
||||||
setLeftColumnOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
config.rightPanel?.relation?.leftColumn === column.columnName
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{column.columnName}
|
|
||||||
<span className="ml-2 text-xs text-gray-500">({column.columnLabel || ""})</span>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label className="text-xs">우측 컬럼 (외래키)</Label>
|
|
||||||
<Popover open={rightColumnOpen} onOpenChange={setRightColumnOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={rightColumnOpen}
|
|
||||||
className="w-full justify-between"
|
|
||||||
disabled={!config.rightPanel?.tableName}
|
|
||||||
>
|
|
||||||
{config.rightPanel?.relation?.foreignKey || "우측 컬럼 선택"}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-full p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="컬럼 검색..." />
|
|
||||||
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
|
||||||
<CommandGroup className="max-h-[200px] overflow-auto">
|
|
||||||
{rightTableColumns.map((column) => (
|
|
||||||
<CommandItem
|
|
||||||
key={column.columnName}
|
|
||||||
value={column.columnName}
|
|
||||||
onSelect={(value) => {
|
|
||||||
updateRightPanel({
|
|
||||||
relation: { ...config.rightPanel?.relation, foreignKey: value },
|
|
||||||
});
|
|
||||||
setRightColumnOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
config.rightPanel?.relation?.foreignKey === column.columnName
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{column.columnName}
|
|
||||||
<span className="ml-2 text-xs text-gray-500">({column.columnLabel || ""})</span>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -333,6 +333,14 @@ export function UniversalFormModalComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 테이블 섹션 데이터 병합 (품목 리스트 등)
|
||||||
|
for (const [key, value] of Object.entries(formData)) {
|
||||||
|
if (key.startsWith("_tableSection_") && Array.isArray(value)) {
|
||||||
|
event.detail.formData[key] = value;
|
||||||
|
console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용)
|
// 🆕 수정 모드: 원본 그룹 데이터 전달 (UPDATE/DELETE 추적용)
|
||||||
if (originalGroupedData.length > 0) {
|
if (originalGroupedData.length > 0) {
|
||||||
event.detail.formData._originalGroupedData = originalGroupedData;
|
event.detail.formData._originalGroupedData = originalGroupedData;
|
||||||
|
|
@ -355,15 +363,9 @@ export function UniversalFormModalComponent({
|
||||||
// 테이블 타입 섹션 찾기
|
// 테이블 타입 섹션 찾기
|
||||||
const tableSection = config.sections.find((s) => s.type === "table");
|
const tableSection = config.sections.find((s) => s.type === "table");
|
||||||
if (!tableSection) {
|
if (!tableSection) {
|
||||||
// console.log("[UniversalFormModal] 테이블 섹션 없음 - _groupedData 무시");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log("[UniversalFormModal] 수정 모드 - 테이블 섹션 초기화:", {
|
|
||||||
// sectionId: tableSection.id,
|
|
||||||
// itemCount: _groupedData.length,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// 원본 데이터 저장 (수정/삭제 추적용)
|
// 원본 데이터 저장 (수정/삭제 추적용)
|
||||||
setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData)));
|
setOriginalGroupedData(JSON.parse(JSON.stringify(_groupedData)));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { CheckboxWidget } from "@/components/screen/widgets/types/CheckboxWidget
|
||||||
import { RadioWidget } from "@/components/screen/widgets/types/RadioWidget";
|
import { RadioWidget } from "@/components/screen/widgets/types/RadioWidget";
|
||||||
import { FileWidget } from "@/components/screen/widgets/types/FileWidget";
|
import { FileWidget } from "@/components/screen/widgets/types/FileWidget";
|
||||||
import { CodeWidget } from "@/components/screen/widgets/types/CodeWidget";
|
import { CodeWidget } from "@/components/screen/widgets/types/CodeWidget";
|
||||||
import { EntityWidget } from "@/components/screen/widgets/types/EntityWidget";
|
import { EntitySearchInputWrapper } from "@/lib/registry/components/entity-search-input/EntitySearchInputWrapper";
|
||||||
import { ButtonWidget } from "@/components/screen/widgets/types/ButtonWidget";
|
import { ButtonWidget } from "@/components/screen/widgets/types/ButtonWidget";
|
||||||
|
|
||||||
// 개별적으로 설정 패널들을 import
|
// 개별적으로 설정 패널들을 import
|
||||||
|
|
@ -352,7 +352,7 @@ export function initializeWebTypeRegistry() {
|
||||||
name: "엔티티 선택",
|
name: "엔티티 선택",
|
||||||
category: "input",
|
category: "input",
|
||||||
description: "데이터베이스 엔티티 선택 필드",
|
description: "데이터베이스 엔티티 선택 필드",
|
||||||
component: EntityWidget,
|
component: EntitySearchInputWrapper,
|
||||||
configPanel: EntityConfigPanel,
|
configPanel: EntityConfigPanel,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
entityType: "",
|
entityType: "",
|
||||||
|
|
|
||||||
|
|
@ -724,11 +724,16 @@ export class ButtonActionExecutor {
|
||||||
// originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨
|
// originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨
|
||||||
// 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인
|
// 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인
|
||||||
const hasRealOriginalData = originalData && Object.keys(originalData).length > 0;
|
const hasRealOriginalData = originalData && Object.keys(originalData).length > 0;
|
||||||
const isUpdate = hasRealOriginalData && !!primaryKeyValue;
|
|
||||||
|
// 🆕 폴백 로직: originalData가 없어도 formData에 id가 있으면 UPDATE로 판단
|
||||||
|
// 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리
|
||||||
|
const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== "";
|
||||||
|
const isUpdate = (hasRealOriginalData || hasIdInFormData) && !!primaryKeyValue;
|
||||||
|
|
||||||
console.log("🔍 [handleSave] INSERT/UPDATE 판단:", {
|
console.log("🔍 [handleSave] INSERT/UPDATE 판단:", {
|
||||||
hasOriginalData: !!originalData,
|
hasOriginalData: !!originalData,
|
||||||
hasRealOriginalData,
|
hasRealOriginalData,
|
||||||
|
hasIdInFormData,
|
||||||
originalDataKeys: originalData ? Object.keys(originalData) : [],
|
originalDataKeys: originalData ? Object.keys(originalData) : [],
|
||||||
primaryKeyValue,
|
primaryKeyValue,
|
||||||
isUpdate,
|
isUpdate,
|
||||||
|
|
@ -741,18 +746,18 @@ export class ButtonActionExecutor {
|
||||||
// UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우)
|
// UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우)
|
||||||
console.log("🔄 UPDATE 모드로 저장:", {
|
console.log("🔄 UPDATE 모드로 저장:", {
|
||||||
primaryKeyValue,
|
primaryKeyValue,
|
||||||
formData,
|
|
||||||
originalData,
|
|
||||||
hasOriginalData: !!originalData,
|
hasOriginalData: !!originalData,
|
||||||
|
hasIdInFormData,
|
||||||
|
updateReason: hasRealOriginalData ? "originalData 존재" : "formData.id 존재 (폴백)",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (originalData) {
|
if (hasRealOriginalData) {
|
||||||
// 부분 업데이트: 변경된 필드만 업데이트
|
// 부분 업데이트: 변경된 필드만 업데이트
|
||||||
console.log("📝 부분 업데이트 실행 (변경된 필드만)");
|
console.log("📝 부분 업데이트 실행 (변경된 필드만)");
|
||||||
saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName);
|
saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName);
|
||||||
} else {
|
} else {
|
||||||
// 전체 업데이트 (기존 방식)
|
// 전체 업데이트 (originalData 없이 id로 UPDATE 판단된 경우)
|
||||||
console.log("📝 전체 업데이트 실행 (모든 필드)");
|
console.log("📝 전체 업데이트 실행 (originalData 없음 - 폴백 모드)");
|
||||||
saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, {
|
saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, {
|
||||||
tableName,
|
tableName,
|
||||||
data: formData,
|
data: formData,
|
||||||
|
|
@ -1883,37 +1888,45 @@ export class ButtonActionExecutor {
|
||||||
const originalItem = originalGroupedData.find((orig) => orig.id === item.id);
|
const originalItem = originalGroupedData.find((orig) => orig.id === item.id);
|
||||||
|
|
||||||
if (!originalItem) {
|
if (!originalItem) {
|
||||||
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - INSERT로 처리: id=${item.id}`);
|
// 🆕 폴백 로직: 원본 데이터가 없어도 id가 있으면 UPDATE 시도
|
||||||
// 원본이 없으면 신규로 처리
|
// originalGroupedData 전달이 누락된 경우를 처리
|
||||||
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
|
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`);
|
||||||
Object.keys(rowToSave).forEach((key) => {
|
|
||||||
|
// ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함
|
||||||
|
// item에 있는 기존 값(예: manager_id=123)이 commonFieldsData의 새 값(manager_id=234)을 덮어쓰지 않도록
|
||||||
|
// 순서: item(기존) → commonFieldsData(새로 입력) → userInfo(메타데이터)
|
||||||
|
const rowToUpdate = { ...item, ...commonFieldsData, ...userInfo };
|
||||||
|
Object.keys(rowToUpdate).forEach((key) => {
|
||||||
if (key.startsWith("_")) {
|
if (key.startsWith("_")) {
|
||||||
delete rowToSave[key];
|
delete rowToUpdate[key];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
delete rowToSave.id; // id 제거하여 INSERT
|
|
||||||
|
|
||||||
// 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우)
|
console.log("📝 [UPDATE 폴백] 저장할 데이터:", {
|
||||||
if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) {
|
id: item.id,
|
||||||
rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveResult = await DynamicFormApi.saveFormData({
|
|
||||||
screenId: screenId!,
|
|
||||||
tableName: saveTableName,
|
tableName: saveTableName,
|
||||||
data: rowToSave,
|
commonFieldsData,
|
||||||
|
itemFields: Object.keys(item).filter(k => !k.startsWith("_")),
|
||||||
|
rowToUpdate,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!saveResult.success) {
|
// id를 유지하고 UPDATE 실행
|
||||||
throw new Error(saveResult.message || "품목 저장 실패");
|
const updateResult = await DynamicFormApi.updateFormData(item.id, {
|
||||||
|
tableName: saveTableName,
|
||||||
|
data: rowToUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateResult.success) {
|
||||||
|
throw new Error(updateResult.message || "품목 수정 실패");
|
||||||
}
|
}
|
||||||
|
|
||||||
insertedCount++;
|
updatedCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 변경 사항 확인 (공통 필드 포함)
|
// 변경 사항 확인 (공통 필드 포함)
|
||||||
const currentDataWithCommon = { ...commonFieldsData, ...item };
|
// ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 (새로 입력한 값이 기존 값을 덮어씀)
|
||||||
|
const currentDataWithCommon = { ...item, ...commonFieldsData };
|
||||||
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
|
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
|
||||||
|
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
|
|
@ -1938,13 +1951,14 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3️⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
|
// 3️⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
|
||||||
const currentIds = new Set(currentItems.map((item) => item.id).filter(Boolean));
|
// ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지)
|
||||||
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(orig.id));
|
const currentIds = new Set(currentItems.map((item) => String(item.id)).filter(Boolean));
|
||||||
|
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(String(orig.id)));
|
||||||
|
|
||||||
for (const deletedItem of deletedItems) {
|
for (const deletedItem of deletedItems) {
|
||||||
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`);
|
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`);
|
||||||
|
|
||||||
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(saveTableName, deletedItem.id);
|
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName);
|
||||||
|
|
||||||
if (!deleteResult.success) {
|
if (!deleteResult.success) {
|
||||||
throw new Error(deleteResult.message || "품목 삭제 실패");
|
throw new Error(deleteResult.message || "품목 삭제 실패");
|
||||||
|
|
@ -4981,26 +4995,35 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
const { oldValue, newValue } = confirmed;
|
const { oldValue, newValue } = confirmed;
|
||||||
|
|
||||||
// 미리보기 표시 (옵션)
|
// 미리보기 표시 (값 기반 검색 - 모든 테이블의 모든 컬럼에서 검색)
|
||||||
if (config.mergeShowPreview !== false) {
|
if (config.mergeShowPreview !== false) {
|
||||||
const { apiClient } = await import("@/lib/api/client");
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
const previewResponse = await apiClient.post("/code-merge/preview", {
|
toast.loading("영향받는 데이터 검색 중...", { duration: Infinity });
|
||||||
columnName,
|
|
||||||
|
const previewResponse = await apiClient.post("/code-merge/preview-by-value", {
|
||||||
oldValue,
|
oldValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
toast.dismiss();
|
||||||
|
|
||||||
if (previewResponse.data.success) {
|
if (previewResponse.data.success) {
|
||||||
const preview = previewResponse.data.data;
|
const preview = previewResponse.data.data;
|
||||||
const totalRows = preview.totalAffectedRows;
|
const totalRows = preview.totalAffectedRows;
|
||||||
|
|
||||||
|
// 상세 정보 생성
|
||||||
|
const detailList = preview.preview
|
||||||
|
.map((p: any) => ` - ${p.tableName}.${p.columnName}: ${p.affectedRows}건`)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
const confirmMerge = confirm(
|
const confirmMerge = confirm(
|
||||||
"⚠️ 코드 병합 확인\n\n" +
|
"코드 병합 확인\n\n" +
|
||||||
`${oldValue} → ${newValue}\n\n` +
|
`${oldValue} → ${newValue}\n\n` +
|
||||||
"영향받는 데이터:\n" +
|
"영향받는 데이터:\n" +
|
||||||
`- 테이블 수: ${preview.preview.length}개\n` +
|
`- 테이블/컬럼 수: ${preview.preview.length}개\n` +
|
||||||
`- 총 행 수: ${totalRows}개\n\n` +
|
`- 총 행 수: ${totalRows}개\n\n` +
|
||||||
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` +
|
(preview.preview.length <= 10 ? `상세:\n${detailList}\n\n` : "") +
|
||||||
|
"모든 테이블에서 해당 값이 변경됩니다.\n\n" +
|
||||||
"계속하시겠습니까?",
|
"계속하시겠습니까?",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -5010,13 +5033,12 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 병합 실행
|
// 병합 실행 (값 기반 - 모든 테이블의 모든 컬럼)
|
||||||
toast.loading("코드 병합 중...", { duration: Infinity });
|
toast.loading("코드 병합 중...", { duration: Infinity });
|
||||||
|
|
||||||
const { apiClient } = await import("@/lib/api/client");
|
const { apiClient } = await import("@/lib/api/client");
|
||||||
|
|
||||||
const response = await apiClient.post("/code-merge/merge-all-tables", {
|
const response = await apiClient.post("/code-merge/merge-by-value", {
|
||||||
columnName,
|
|
||||||
oldValue,
|
oldValue,
|
||||||
newValue,
|
newValue,
|
||||||
});
|
});
|
||||||
|
|
@ -5025,9 +5047,17 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
const data = response.data.data;
|
const data = response.data.data;
|
||||||
|
|
||||||
|
// 변경된 테이블/컬럼 목록 생성
|
||||||
|
const changedList = data.affectedData
|
||||||
|
.map((d: any) => `${d.tableName}.${d.columnName}: ${d.rowsUpdated}건`)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
"코드 병합 완료!\n" + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`,
|
`코드 병합 완료! ${data.affectedData.length}개 테이블/컬럼, ${data.totalRowsUpdated}개 행 업데이트`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("코드 병합 결과:", data.affectedData);
|
||||||
|
|
||||||
// 화면 새로고침
|
// 화면 새로고침
|
||||||
context.onRefresh?.();
|
context.onRefresh?.();
|
||||||
|
|
|
||||||
|
|
@ -365,6 +365,8 @@ export interface EntityTypeConfig {
|
||||||
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
|
separator?: string; // 여러 컬럼 표시 시 구분자 (기본: ' - ')
|
||||||
// UI 모드
|
// UI 모드
|
||||||
uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo"
|
uiMode?: "select" | "modal" | "combo" | "autocomplete"; // 기본: "combo"
|
||||||
|
// 다중 선택
|
||||||
|
multiple?: boolean; // 여러 항목 선택 가능 여부 (기본: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue