Merge pull request 'jskim-node' (#388) from jskim-node into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/388
This commit is contained in:
commit
0d1a19e852
|
|
@ -18,6 +18,7 @@
|
|||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
|
|
@ -1044,7 +1045,6 @@
|
|||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
|
|
@ -2372,7 +2372,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
|
|
@ -3476,7 +3475,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
|
|
@ -3713,7 +3711,6 @@
|
|||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
|
|
@ -3931,7 +3928,6 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -4458,7 +4454,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
|
|
@ -5669,7 +5664,6 @@
|
|||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
|
|
@ -5989,6 +5983,15 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-async-errors": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz",
|
||||
"integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"express": "^4.16.2"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "7.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||
|
|
@ -7432,7 +7435,6 @@
|
|||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
|
|
@ -8402,6 +8404,7 @@
|
|||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
|
|
@ -9290,7 +9293,6 @@
|
|||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
|
|
@ -10141,6 +10143,7 @@
|
|||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
|
|
@ -10949,7 +10952,6 @@
|
|||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
|
|
@ -11055,7 +11057,6 @@
|
|||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import "dotenv/config";
|
||||
import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ export async function getAdminMenus(
|
|||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 관리자 메뉴 목록 조회 시작 ===");
|
||||
|
||||
// 현재 로그인한 사용자의 정보 가져오기
|
||||
const userId = req.user?.userId;
|
||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||
|
|
@ -29,13 +27,6 @@ export async function getAdminMenus(
|
|||
const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가
|
||||
const includeInactive = req.query.includeInactive === "true"; // includeInactive 파라미터 추가
|
||||
|
||||
logger.info(`사용자 ID: ${userId}`);
|
||||
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
|
||||
logger.info(`사용자 유형: ${userType}`);
|
||||
logger.info(`사용자 로케일: ${userLang}`);
|
||||
logger.info(`메뉴 타입: ${menuType || "전체"}`);
|
||||
logger.info(`비활성 메뉴 포함: ${includeInactive}`);
|
||||
|
||||
const paramMap = {
|
||||
userId,
|
||||
userCompanyCode,
|
||||
|
|
@ -47,13 +38,6 @@ export async function getAdminMenus(
|
|||
|
||||
const menuList = await AdminService.getAdminMenuList(paramMap);
|
||||
|
||||
logger.info(
|
||||
`관리자 메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"}, 회사: ${userCompanyCode})`
|
||||
);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", menuList[0]);
|
||||
}
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "관리자 메뉴 목록 조회 성공",
|
||||
|
|
@ -85,19 +69,12 @@ export async function getUserMenus(
|
|||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 사용자 메뉴 목록 조회 시작 ===");
|
||||
|
||||
// 현재 로그인한 사용자의 정보 가져오기
|
||||
const userId = req.user?.userId;
|
||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||
const userType = req.user?.userType;
|
||||
const userLang = (req.query.userLang as string) || "ko";
|
||||
|
||||
logger.info(`사용자 ID: ${userId}`);
|
||||
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
|
||||
logger.info(`사용자 유형: ${userType}`);
|
||||
logger.info(`사용자 로케일: ${userLang}`);
|
||||
|
||||
const paramMap = {
|
||||
userId,
|
||||
userCompanyCode,
|
||||
|
|
@ -107,13 +84,6 @@ export async function getUserMenus(
|
|||
|
||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||
|
||||
logger.info(
|
||||
`사용자 메뉴 조회 결과: ${menuList.length}개 (회사: ${userCompanyCode})`
|
||||
);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", menuList[0]);
|
||||
}
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "사용자 메뉴 목록 조회 성공",
|
||||
|
|
@ -473,7 +443,7 @@ export const getUserLocale = async (
|
|||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
logger.info("사용자 로케일 조회 요청", {
|
||||
logger.debug("사용자 로케일 조회 요청", {
|
||||
query: req.query,
|
||||
user: req.user,
|
||||
});
|
||||
|
|
@ -496,7 +466,7 @@ export const getUserLocale = async (
|
|||
|
||||
if (userInfo?.locale) {
|
||||
userLocale = userInfo.locale;
|
||||
logger.info("데이터베이스에서 사용자 로케일 조회 성공", {
|
||||
logger.debug("데이터베이스에서 사용자 로케일 조회 성공", {
|
||||
userId: req.user.userId,
|
||||
locale: userLocale,
|
||||
});
|
||||
|
|
@ -513,7 +483,7 @@ export const getUserLocale = async (
|
|||
message: "사용자 로케일 조회 성공",
|
||||
};
|
||||
|
||||
logger.info("사용자 로케일 조회 성공", {
|
||||
logger.debug("사용자 로케일 조회 성공", {
|
||||
userLocale,
|
||||
userId: req.user.userId,
|
||||
fromDatabase: !!userInfo?.locale,
|
||||
|
|
@ -618,7 +588,7 @@ export const getCompanyList = async (
|
|||
res: Response
|
||||
) => {
|
||||
try {
|
||||
logger.info("회사 목록 조회 요청", {
|
||||
logger.debug("회사 목록 조회 요청", {
|
||||
query: req.query,
|
||||
user: req.user,
|
||||
});
|
||||
|
|
@ -658,12 +628,8 @@ export const getCompanyList = async (
|
|||
message: "회사 목록 조회 성공",
|
||||
};
|
||||
|
||||
logger.info("회사 목록 조회 성공", {
|
||||
logger.debug("회사 목록 조회 성공", {
|
||||
totalCount: companies.length,
|
||||
companies: companies.map((c) => ({
|
||||
code: c.company_code,
|
||||
name: c.company_name,
|
||||
})),
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
|
|
@ -1443,13 +1409,7 @@ async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
|
|||
* 메뉴 및 관련 데이터 정리 헬퍼 함수
|
||||
*/
|
||||
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
|
||||
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 2. code_category에서 menu_objid를 NULL로 설정
|
||||
// 1. code_category에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
|
|
@ -1870,7 +1830,7 @@ export async function getCompanyListFromDB(
|
|||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("회사 목록 조회 요청 (Raw Query)", { user: req.user });
|
||||
logger.debug("회사 목록 조회 요청 (Raw Query)", { user: req.user });
|
||||
|
||||
// Raw Query로 회사 목록 조회
|
||||
const companies = await query<any>(
|
||||
|
|
@ -1890,7 +1850,7 @@ export async function getCompanyListFromDB(
|
|||
ORDER BY regdate DESC`
|
||||
);
|
||||
|
||||
logger.info("회사 목록 조회 성공 (Raw Query)", { count: companies.length });
|
||||
logger.debug("회사 목록 조회 성공 (Raw Query)", { count: companies.length });
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -17,9 +17,7 @@ export class AuthController {
|
|||
const { userId, password }: LoginRequest = req.body;
|
||||
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
|
||||
|
||||
logger.info(`=== API 로그인 호출됨 ===`);
|
||||
logger.info(`userId: ${userId}`);
|
||||
logger.info(`password: ${password ? "***" : "null"}`);
|
||||
logger.debug(`로그인 요청: ${userId}`);
|
||||
|
||||
// 입력값 검증
|
||||
if (!userId || !password) {
|
||||
|
|
@ -50,14 +48,7 @@ export class AuthController {
|
|||
companyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
||||
};
|
||||
|
||||
logger.info(`=== API 로그인 사용자 정보 디버그 ===`);
|
||||
logger.info(
|
||||
`PersonBean companyCode: ${loginResult.userInfo.companyCode}`
|
||||
);
|
||||
logger.info(`반환할 사용자 정보:`);
|
||||
logger.info(`- userId: ${userInfo.userId}`);
|
||||
logger.info(`- userName: ${userInfo.userName}`);
|
||||
logger.info(`- companyCode: ${userInfo.companyCode}`);
|
||||
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
|
||||
|
||||
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
||||
let firstMenuPath: string | null = null;
|
||||
|
|
@ -71,7 +62,7 @@ export class AuthController {
|
|||
};
|
||||
|
||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||
logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||
|
||||
// 접근 가능한 첫 번째 메뉴 찾기
|
||||
// 조건:
|
||||
|
|
@ -87,16 +78,9 @@ export class AuthController {
|
|||
|
||||
if (firstMenu) {
|
||||
firstMenuPath = firstMenu.menu_url || firstMenu.url;
|
||||
logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, {
|
||||
name: firstMenu.menu_name_kor || firstMenu.translated_name,
|
||||
url: firstMenuPath,
|
||||
level: firstMenu.lev || firstMenu.level,
|
||||
seq: firstMenu.seq,
|
||||
});
|
||||
logger.debug(`첫 번째 메뉴: ${firstMenuPath}`);
|
||||
} else {
|
||||
logger.info(
|
||||
"⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다."
|
||||
);
|
||||
logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동");
|
||||
}
|
||||
} catch (menuError) {
|
||||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||
|
|
|
|||
|
|
@ -193,10 +193,11 @@ export class EntityJoinController {
|
|||
async getEntityJoinConfigs(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`Entity 조인 설정 조회: ${tableName}`);
|
||||
logger.info(`Entity 조인 설정 조회: ${tableName} (companyCode: ${companyCode})`);
|
||||
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
|
|
@ -224,11 +225,12 @@ export class EntityJoinController {
|
|||
async getReferenceTableColumns(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`참조 테이블 컬럼 조회: ${tableName}`);
|
||||
logger.info(`참조 테이블 컬럼 조회: ${tableName} (companyCode: ${companyCode})`);
|
||||
|
||||
const columns =
|
||||
await tableManagementService.getReferenceTableColumns(tableName);
|
||||
await tableManagementService.getReferenceTableColumns(tableName, companyCode);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
|
|
@ -408,11 +410,12 @@ export class EntityJoinController {
|
|||
async getEntityJoinColumns(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`Entity 조인 컬럼 조회: ${tableName}`);
|
||||
logger.info(`Entity 조인 컬럼 조회: ${tableName} (companyCode: ${companyCode})`);
|
||||
|
||||
// 1. 현재 테이블의 Entity 조인 설정 조회
|
||||
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
|
||||
|
||||
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
|
||||
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
|
||||
|
|
@ -439,7 +442,7 @@ export class EntityJoinController {
|
|||
try {
|
||||
const columns =
|
||||
await tableManagementService.getReferenceTableColumns(
|
||||
config.referenceTable
|
||||
config.referenceTable, companyCode
|
||||
);
|
||||
|
||||
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
|
||||
|
|
|
|||
|
|
@ -395,11 +395,35 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
|||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 정렬 컬럼 결정: id가 있으면 id, 없으면 첫 번째 컬럼 사용
|
||||
let orderByColumn = "1"; // 기본: 첫 번째 컬럼
|
||||
if (existingColumns.has("id")) {
|
||||
orderByColumn = '"id"';
|
||||
} else {
|
||||
// PK 컬럼 조회 시도
|
||||
try {
|
||||
const pkResult = await pool.query(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary
|
||||
ORDER BY array_position(i.indkey, a.attnum)
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
if (pkResult.rows.length > 0) {
|
||||
orderByColumn = `"${pkResult.rows[0].attname}"`;
|
||||
}
|
||||
} catch {
|
||||
// PK 조회 실패 시 기본값 유지
|
||||
}
|
||||
}
|
||||
|
||||
// 쿼리 실행 (pool은 위에서 이미 선언됨)
|
||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||
const dataQuery = `
|
||||
SELECT * FROM ${tableName} ${whereClause}
|
||||
ORDER BY id DESC
|
||||
ORDER BY ${orderByColumn} DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -46,17 +46,7 @@ export class FlowController {
|
|||
const userId = (req as any).user?.userId || "system";
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
console.log("🔍 createFlowDefinition called with:", {
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
userCompanyCode,
|
||||
});
|
||||
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
|
|
@ -121,13 +111,7 @@ export class FlowController {
|
|||
const user = (req as any).user;
|
||||
const userCompanyCode = user?.companyCode;
|
||||
|
||||
console.log("🎯 getFlowDefinitions called:", {
|
||||
userId: user?.userId,
|
||||
userCompanyCode: userCompanyCode,
|
||||
userType: user?.userType,
|
||||
tableName,
|
||||
isActive,
|
||||
});
|
||||
|
||||
|
||||
const flows = await this.flowDefinitionService.findAll(
|
||||
tableName as string | undefined,
|
||||
|
|
@ -135,7 +119,7 @@ export class FlowController {
|
|||
userCompanyCode
|
||||
);
|
||||
|
||||
console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
@ -583,14 +567,11 @@ export class FlowController {
|
|||
getStepColumnLabels = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId } = req.params;
|
||||
console.log("🏷️ [FlowController] 컬럼 라벨 조회 요청:", {
|
||||
flowId,
|
||||
stepId,
|
||||
});
|
||||
|
||||
|
||||
const step = await this.flowStepService.findById(parseInt(stepId));
|
||||
if (!step) {
|
||||
console.warn("⚠️ [FlowController] 스텝을 찾을 수 없음:", stepId);
|
||||
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Step not found",
|
||||
|
|
@ -602,7 +583,7 @@ export class FlowController {
|
|||
parseInt(flowId)
|
||||
);
|
||||
if (!flowDef) {
|
||||
console.warn("⚠️ [FlowController] 플로우를 찾을 수 없음:", flowId);
|
||||
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found",
|
||||
|
|
@ -612,14 +593,10 @@ export class FlowController {
|
|||
|
||||
// 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블)
|
||||
const tableName = step.tableName || flowDef.tableName;
|
||||
console.log("📋 [FlowController] 테이블명 결정:", {
|
||||
stepTableName: step.tableName,
|
||||
flowTableName: flowDef.tableName,
|
||||
selectedTableName: tableName,
|
||||
});
|
||||
|
||||
|
||||
if (!tableName) {
|
||||
console.warn("⚠️ [FlowController] 테이블명이 지정되지 않음");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {},
|
||||
|
|
@ -639,14 +616,7 @@ export class FlowController {
|
|||
[tableName]
|
||||
);
|
||||
|
||||
console.log(`✅ [FlowController] table_type_columns 조회 완료:`, {
|
||||
tableName,
|
||||
rowCount: labelRows.length,
|
||||
labels: labelRows.map((r) => ({
|
||||
col: r.column_name,
|
||||
label: r.column_label,
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
// { columnName: label } 형태의 객체로 변환
|
||||
const labels: Record<string, string> = {};
|
||||
|
|
@ -656,7 +626,7 @@ export class FlowController {
|
|||
}
|
||||
});
|
||||
|
||||
console.log("📦 [FlowController] 반환할 라벨 객체:", labels);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -1488,13 +1488,13 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
|||
SELECT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
jsonb_array_elements_text(
|
||||
sd.table_name::text as main_table,
|
||||
jsonb_array_elements(
|
||||
COALESCE(
|
||||
sl.properties->'componentConfig'->'columns',
|
||||
'[]'::jsonb
|
||||
)
|
||||
)::jsonb->>'columnName' as column_name
|
||||
)->>'columnName' as column_name
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
WHERE sd.screen_id = ANY($1)
|
||||
|
|
@ -1507,7 +1507,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
|||
SELECT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
sd.table_name::text as main_table,
|
||||
COALESCE(
|
||||
sl.properties->'componentConfig'->>'bindField',
|
||||
sl.properties->>'bindField',
|
||||
|
|
@ -1530,7 +1530,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
|||
SELECT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
sd.table_name::text as main_table,
|
||||
sl.properties->'componentConfig'->>'valueField' as column_name
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
|
|
@ -1543,7 +1543,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
|||
SELECT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
sd.table_name::text as main_table,
|
||||
sl.properties->'componentConfig'->>'parentFieldId' as column_name
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
|
|
@ -1556,7 +1556,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
|||
SELECT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
sd.table_name::text as main_table,
|
||||
sl.properties->'componentConfig'->>'cascadingParentField' as column_name
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
|
|
@ -1569,7 +1569,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
|||
SELECT
|
||||
sd.screen_id,
|
||||
sd.screen_name,
|
||||
sd.table_name as main_table,
|
||||
sd.table_name::text as main_table,
|
||||
sl.properties->'componentConfig'->>'controlField' as column_name
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
|
|
@ -1750,7 +1750,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
|
|||
sd.table_name as main_table,
|
||||
sl.properties->>'componentType' as component_type,
|
||||
sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation,
|
||||
sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table,
|
||||
sl.properties->'componentConfig'->'rightPanel'->>'tableName' as right_panel_table,
|
||||
sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns
|
||||
FROM screen_definitions sd
|
||||
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
|
||||
|
|
|
|||
|
|
@ -787,6 +787,78 @@ export const updateLayerCondition = async (req: AuthenticatedRequest, res: Respo
|
|||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 조건부 영역(Zone) 관리
|
||||
// ========================================
|
||||
|
||||
// Zone 목록 조회
|
||||
export const getScreenZones = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const zones = await screenManagementService.getScreenZones(parseInt(screenId), companyCode);
|
||||
res.json({ success: true, data: zones });
|
||||
} catch (error) {
|
||||
console.error("Zone 목록 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone 목록 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// Zone 생성
|
||||
export const createZone = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const zone = await screenManagementService.createZone(parseInt(screenId), companyCode, req.body);
|
||||
res.json({ success: true, data: zone });
|
||||
} catch (error) {
|
||||
console.error("Zone 생성 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone 생성에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// Zone 업데이트 (위치/크기/트리거)
|
||||
export const updateZone = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { zoneId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
await screenManagementService.updateZone(parseInt(zoneId), companyCode, req.body);
|
||||
res.json({ success: true, message: "Zone이 업데이트되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("Zone 업데이트 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone 업데이트에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// Zone 삭제
|
||||
export const deleteZone = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { zoneId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
await screenManagementService.deleteZone(parseInt(zoneId), companyCode);
|
||||
res.json({ success: true, message: "Zone이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("Zone 삭제 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone 삭제에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// Zone에 레이어 추가
|
||||
export const addLayerToZone = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, zoneId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { conditionValue, layerName } = req.body;
|
||||
const result = await screenManagementService.addLayerToZone(
|
||||
parseInt(screenId), companyCode, parseInt(zoneId), conditionValue, layerName
|
||||
);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error("Zone 레이어 추가 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone에 레이어를 추가하지 못했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// POP 레이아웃 관리 (모바일/태블릿)
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -2447,3 +2447,260 @@ export async function getReferencedByTables(
|
|||
res.status(500).json(response);
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// PK / 인덱스 관리 API
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* PK/인덱스 상태 조회
|
||||
* GET /api/table-management/tables/:tableName/constraints
|
||||
*/
|
||||
export async function getTableConstraints(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
|
||||
if (!tableName) {
|
||||
res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
// PK 조회
|
||||
const pkResult = await query<any>(
|
||||
`SELECT tc.conname AS constraint_name,
|
||||
array_agg(a.attname ORDER BY x.n) AS columns
|
||||
FROM pg_constraint tc
|
||||
JOIN pg_class c ON tc.conrelid = c.oid
|
||||
JOIN pg_namespace ns ON c.relnamespace = ns.oid
|
||||
CROSS JOIN LATERAL unnest(tc.conkey) WITH ORDINALITY AS x(attnum, n)
|
||||
JOIN pg_attribute a ON a.attrelid = tc.conrelid AND a.attnum = x.attnum
|
||||
WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'
|
||||
GROUP BY tc.conname`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
// array_agg 결과가 문자열로 올 수 있으므로 안전하게 배열로 변환
|
||||
const parseColumns = (cols: any): string[] => {
|
||||
if (Array.isArray(cols)) return cols;
|
||||
if (typeof cols === "string") {
|
||||
// PostgreSQL 배열 형식: {col1,col2}
|
||||
return cols.replace(/[{}]/g, "").split(",").filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const primaryKey = pkResult.length > 0
|
||||
? { name: pkResult[0].constraint_name, columns: parseColumns(pkResult[0].columns) }
|
||||
: { name: "", columns: [] };
|
||||
|
||||
// 인덱스 조회 (PK 인덱스 제외)
|
||||
const indexResult = await query<any>(
|
||||
`SELECT i.relname AS index_name,
|
||||
ix.indisunique AS is_unique,
|
||||
array_agg(a.attname ORDER BY x.n) AS columns
|
||||
FROM pg_index ix
|
||||
JOIN pg_class t ON ix.indrelid = t.oid
|
||||
JOIN pg_class i ON ix.indexrelid = i.oid
|
||||
JOIN pg_namespace ns ON t.relnamespace = ns.oid
|
||||
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n)
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
|
||||
WHERE ns.nspname = 'public' AND t.relname = $1
|
||||
AND ix.indisprimary = false
|
||||
GROUP BY i.relname, ix.indisunique
|
||||
ORDER BY i.relname`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
const indexes = indexResult.map((row: any) => ({
|
||||
name: row.index_name,
|
||||
columns: parseColumns(row.columns),
|
||||
isUnique: row.is_unique,
|
||||
}));
|
||||
|
||||
logger.info(`제약조건 조회: ${tableName} - PK: ${primaryKey.columns.join(",")}, 인덱스: ${indexes.length}개`);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { primaryKey, indexes },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("제약조건 조회 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "제약조건 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PK 설정
|
||||
* PUT /api/table-management/tables/:tableName/primary-key
|
||||
*/
|
||||
export async function setTablePrimaryKey(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { columns } = req.body;
|
||||
|
||||
if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) {
|
||||
res.status(400).json({ success: false, message: "테이블명과 PK 컬럼 배열이 필요합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`PK 설정: ${tableName} → [${columns.join(", ")}]`);
|
||||
|
||||
// 기존 PK 제약조건 이름 조회
|
||||
const existingPk = await query<any>(
|
||||
`SELECT conname FROM pg_constraint tc
|
||||
JOIN pg_class c ON tc.conrelid = c.oid
|
||||
JOIN pg_namespace ns ON c.relnamespace = ns.oid
|
||||
WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
// 기존 PK 삭제
|
||||
if (existingPk.length > 0) {
|
||||
const dropSql = `ALTER TABLE "public"."${tableName}" DROP CONSTRAINT "${existingPk[0].conname}"`;
|
||||
logger.info(`기존 PK 삭제: ${dropSql}`);
|
||||
await query(dropSql);
|
||||
}
|
||||
|
||||
// 새 PK 추가
|
||||
const colList = columns.map((c: string) => `"${c}"`).join(", ");
|
||||
const addSql = `ALTER TABLE "public"."${tableName}" ADD PRIMARY KEY (${colList})`;
|
||||
logger.info(`새 PK 추가: ${addSql}`);
|
||||
await query(addSql);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: `PK가 설정되었습니다: ${columns.join(", ")}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("PK 설정 오류:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "PK 설정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스 토글 (생성/삭제)
|
||||
* POST /api/table-management/tables/:tableName/indexes
|
||||
*/
|
||||
export async function toggleTableIndex(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { columnName, indexType, action } = req.body;
|
||||
|
||||
if (!tableName || !columnName || !indexType || !action) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, columnName, indexType(index|unique), action(create|drop)이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const indexName = `idx_${tableName}_${columnName}${indexType === "unique" ? "_uq" : ""}`;
|
||||
|
||||
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
|
||||
|
||||
if (action === "create") {
|
||||
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
|
||||
const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`;
|
||||
logger.info(`인덱스 생성: ${sql}`);
|
||||
await query(sql);
|
||||
} else if (action === "drop") {
|
||||
const sql = `DROP INDEX IF EXISTS "public"."${indexName}"`;
|
||||
logger.info(`인덱스 삭제: ${sql}`);
|
||||
await query(sql);
|
||||
} else {
|
||||
res.status(400).json({ success: false, message: "action은 create 또는 drop이어야 합니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: action === "create"
|
||||
? `인덱스가 생성되었습니다: ${indexName}`
|
||||
: `인덱스가 삭제되었습니다: ${indexName}`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("인덱스 토글 오류:", error);
|
||||
|
||||
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내
|
||||
const errorMsg = error.message?.includes("duplicate key")
|
||||
? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요."
|
||||
: "인덱스 설정 중 오류가 발생했습니다.";
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMsg,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT NULL 토글
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
|
||||
*/
|
||||
export async function toggleColumnNullable(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { nullable } = req.body;
|
||||
|
||||
if (!tableName || !columnName || typeof nullable !== "boolean") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, columnName, nullable(boolean)이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (nullable) {
|
||||
// NOT NULL 해제
|
||||
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`;
|
||||
logger.info(`NOT NULL 해제: ${sql}`);
|
||||
await query(sql);
|
||||
} else {
|
||||
// NOT NULL 설정
|
||||
const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`;
|
||||
logger.info(`NOT NULL 설정: ${sql}`);
|
||||
await query(sql);
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: nullable
|
||||
? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.`
|
||||
: `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("NOT NULL 토글 오류:", error);
|
||||
|
||||
// NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내
|
||||
const errorMsg = error.message?.includes("contains null values")
|
||||
? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요."
|
||||
: "NOT NULL 설정 중 오류가 발생했습니다.";
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: errorMsg,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,9 +86,9 @@ export const optionalAuth = (
|
|||
if (token) {
|
||||
const userInfo: PersonBean = JwtUtils.verifyToken(token);
|
||||
req.user = userInfo;
|
||||
logger.info(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`);
|
||||
logger.debug(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`);
|
||||
} else {
|
||||
logger.info(`선택적 인증: 토큰 없음 (${req.ip})`);
|
||||
logger.debug(`선택적 인증: 토큰 없음 (${req.ip})`);
|
||||
}
|
||||
|
||||
next();
|
||||
|
|
|
|||
|
|
@ -166,14 +166,20 @@ router.post(
|
|||
masterInserted: result.masterInserted,
|
||||
masterUpdated: result.masterUpdated,
|
||||
detailInserted: result.detailInserted,
|
||||
detailUpdated: result.detailUpdated,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
const detailTotal = result.detailInserted + (result.detailUpdated || 0);
|
||||
const detailMsg = result.detailUpdated
|
||||
? `디테일 신규 ${result.detailInserted}건, 수정 ${result.detailUpdated}건`
|
||||
: `디테일 ${result.detailInserted}건`;
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
data: result,
|
||||
message: result.success
|
||||
? `마스터 ${result.masterInserted + result.masterUpdated}건, 디테일 ${result.detailInserted}건 처리되었습니다.`
|
||||
? `마스터 ${result.masterInserted + result.masterUpdated}건, ${detailMsg} 처리되었습니다.`
|
||||
: "업로드 중 오류가 발생했습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
|
@ -688,7 +694,7 @@ router.post(
|
|||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const { tableName, parentKeys, records } = req.body;
|
||||
const { tableName, parentKeys, records, deleteOrphans = true } = req.body;
|
||||
|
||||
// 입력값 검증
|
||||
if (!tableName || !parentKeys || !records || !Array.isArray(records)) {
|
||||
|
|
@ -722,7 +728,8 @@ router.post(
|
|||
parentKeys,
|
||||
records,
|
||||
req.user?.companyCode,
|
||||
req.user?.userId
|
||||
req.user?.userId,
|
||||
deleteOrphans
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -741,6 +748,7 @@ router.post(
|
|||
inserted: result.data?.inserted || 0,
|
||||
updated: result.data?.updated || 0,
|
||||
deleted: result.data?.deleted || 0,
|
||||
savedIds: result.data?.savedIds || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("그룹화된 데이터 UPSERT 오류:", error);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ import {
|
|||
getLayerLayout,
|
||||
deleteLayer,
|
||||
updateLayerCondition,
|
||||
getScreenZones,
|
||||
createZone,
|
||||
updateZone,
|
||||
deleteZone,
|
||||
addLayerToZone,
|
||||
} from "../controllers/screenManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -98,6 +103,13 @@ router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특
|
|||
router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제
|
||||
router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정
|
||||
|
||||
// 조건부 영역(Zone) 관리
|
||||
router.get("/screens/:screenId/zones", getScreenZones); // Zone 목록
|
||||
router.post("/screens/:screenId/zones", createZone); // Zone 생성
|
||||
router.put("/zones/:zoneId", updateZone); // Zone 업데이트
|
||||
router.delete("/zones/:zoneId", deleteZone); // Zone 삭제
|
||||
router.post("/screens/:screenId/zones/:zoneId/layers", addLayerToZone); // Zone에 레이어 추가
|
||||
|
||||
// POP 레이아웃 관리 (모바일/태블릿)
|
||||
router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회
|
||||
router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ import {
|
|||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
|
||||
getTableConstraints, // 🆕 PK/인덱스 상태 조회
|
||||
setTablePrimaryKey, // 🆕 PK 설정
|
||||
toggleTableIndex, // 🆕 인덱스 토글
|
||||
toggleColumnNullable, // 🆕 NOT NULL 토글
|
||||
} from "../controllers/tableManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -133,6 +137,30 @@ router.put("/tables/:tableName/columns/batch", updateAllColumnSettings);
|
|||
*/
|
||||
router.get("/tables/:tableName/schema", getTableSchema);
|
||||
|
||||
/**
|
||||
* PK/인덱스 제약조건 상태 조회
|
||||
* GET /api/table-management/tables/:tableName/constraints
|
||||
*/
|
||||
router.get("/tables/:tableName/constraints", getTableConstraints);
|
||||
|
||||
/**
|
||||
* PK 설정 (기존 PK DROP 후 재생성)
|
||||
* PUT /api/table-management/tables/:tableName/primary-key
|
||||
*/
|
||||
router.put("/tables/:tableName/primary-key", setTablePrimaryKey);
|
||||
|
||||
/**
|
||||
* 인덱스 토글 (생성/삭제)
|
||||
* POST /api/table-management/tables/:tableName/indexes
|
||||
*/
|
||||
router.post("/tables/:tableName/indexes", toggleTableIndex);
|
||||
|
||||
/**
|
||||
* NOT NULL 토글
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
|
||||
*/
|
||||
router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable);
|
||||
|
||||
/**
|
||||
* 테이블 존재 여부 확인
|
||||
* GET /api/table-management/tables/:tableName/exists
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export class AdminService {
|
|||
*/
|
||||
static async getAdminMenuList(paramMap: any): Promise<any[]> {
|
||||
try {
|
||||
logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap);
|
||||
logger.debug("AdminService.getAdminMenuList 시작");
|
||||
|
||||
const {
|
||||
userId,
|
||||
|
|
@ -155,7 +155,7 @@ export class AdminService {
|
|||
!isManagementScreen
|
||||
) {
|
||||
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
||||
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
||||
logger.debug(`최고 관리자: 공통 메뉴 표시`);
|
||||
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`;
|
||||
}
|
||||
|
|
@ -168,18 +168,18 @@ export class AdminService {
|
|||
// SUPER_ADMIN
|
||||
if (isManagementScreen) {
|
||||
// 메뉴 관리 화면: 모든 메뉴
|
||||
logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||
logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||
companyFilter = "";
|
||||
} else {
|
||||
// 좌측 사이드바: 공통 메뉴만 (company_code = '*')
|
||||
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
||||
logger.debug("좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
||||
companyFilter = `AND MENU.COMPANY_CODE = '*'`;
|
||||
}
|
||||
} else if (isManagementScreen) {
|
||||
// 메뉴 관리 화면: 회사별 필터링
|
||||
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
||||
// 최고 관리자: 모든 메뉴 (공통 + 모든 회사)
|
||||
logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||
logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||
companyFilter = "";
|
||||
} else {
|
||||
// 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외)
|
||||
|
|
@ -387,16 +387,7 @@ export class AdminService {
|
|||
queryParams
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`관리자 메뉴 목록 조회 결과: ${menuList.length}개 (menuType: ${menuType || "전체"}, userType: ${userType}, company: ${userCompanyCode})`
|
||||
);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", {
|
||||
objid: menuList[0].objid,
|
||||
name: menuList[0].menu_name_kor,
|
||||
companyCode: menuList[0].company_code,
|
||||
});
|
||||
}
|
||||
logger.debug(`관리자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
||||
|
||||
return menuList;
|
||||
} catch (error) {
|
||||
|
|
@ -410,7 +401,7 @@ export class AdminService {
|
|||
*/
|
||||
static async getUserMenuList(paramMap: any): Promise<any[]> {
|
||||
try {
|
||||
logger.info("AdminService.getUserMenuList 시작 - 파라미터:", paramMap);
|
||||
logger.debug("AdminService.getUserMenuList 시작");
|
||||
|
||||
const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap;
|
||||
|
||||
|
|
@ -422,9 +413,7 @@ export class AdminService {
|
|||
|
||||
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
||||
// TODO: 권한 체크 다시 활성화 필요
|
||||
logger.info(
|
||||
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
||||
);
|
||||
logger.debug(`getUserMenuList 권한 그룹 체크 스킵 - ${userId}(${userType})`);
|
||||
authFilter = "";
|
||||
unionFilter = "";
|
||||
|
||||
|
|
@ -617,16 +606,7 @@ export class AdminService {
|
|||
queryParams
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`사용자 메뉴 목록 조회 결과: ${menuList.length}개 (userType: ${userType}, company: ${userCompanyCode})`
|
||||
);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", {
|
||||
objid: menuList[0].objid,
|
||||
name: menuList[0].menu_name_kor,
|
||||
companyCode: menuList[0].company_code,
|
||||
});
|
||||
}
|
||||
logger.debug(`사용자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
||||
|
||||
return menuList;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -29,12 +29,11 @@ export class AuthService {
|
|||
if (userInfo && userInfo.user_password) {
|
||||
const dbPassword = userInfo.user_password;
|
||||
|
||||
logger.info(`로그인 시도: ${userId}`);
|
||||
logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`);
|
||||
logger.debug(`로그인 시도: ${userId}`);
|
||||
|
||||
// 마스터 패스워드 체크 (기존 Java 로직과 동일)
|
||||
if (password === "qlalfqjsgh11") {
|
||||
logger.info(`마스터 패스워드로 로그인 성공: ${userId}`);
|
||||
logger.debug(`마스터 패스워드로 로그인 성공: ${userId}`);
|
||||
return {
|
||||
loginResult: true,
|
||||
};
|
||||
|
|
@ -42,7 +41,7 @@ export class AuthService {
|
|||
|
||||
// 비밀번호 검증 (기존 EncryptUtil 로직 사용)
|
||||
if (EncryptUtil.matches(password, dbPassword)) {
|
||||
logger.info(`비밀번호 일치로 로그인 성공: ${userId}`);
|
||||
logger.debug(`비밀번호 일치로 로그인 성공: ${userId}`);
|
||||
return {
|
||||
loginResult: true,
|
||||
};
|
||||
|
|
@ -98,7 +97,7 @@ export class AuthService {
|
|||
]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
|
||||
);
|
||||
} catch (error) {
|
||||
|
|
@ -225,7 +224,7 @@ export class AuthService {
|
|||
// deptCode: personBean.deptCode,
|
||||
//});
|
||||
|
||||
logger.info(`사용자 정보 조회 완료: ${userId}`);
|
||||
logger.debug(`사용자 정보 조회 완료: ${userId}`);
|
||||
return personBean;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,45 @@ import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool impo
|
|||
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
||||
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
|
||||
|
||||
/**
|
||||
* 비밀번호(password) 타입 컬럼의 값을 빈 문자열로 마스킹
|
||||
* - table_type_columns에서 input_type = 'password'인 컬럼을 조회
|
||||
* - 데이터 응답에서 해당 컬럼 값을 비워서 해시값 노출 방지
|
||||
*/
|
||||
async function maskPasswordColumns(tableName: string, data: any): Promise<any> {
|
||||
try {
|
||||
const passwordCols = await query<{ column_name: string }>(
|
||||
`SELECT DISTINCT column_name FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'password'`,
|
||||
[tableName]
|
||||
);
|
||||
if (passwordCols.length === 0) return data;
|
||||
|
||||
const passwordColumnNames = new Set(passwordCols.map(c => c.column_name));
|
||||
|
||||
// 단일 객체 처리
|
||||
const maskRow = (row: any) => {
|
||||
if (!row || typeof row !== "object") return row;
|
||||
const masked = { ...row };
|
||||
for (const col of passwordColumnNames) {
|
||||
if (col in masked) {
|
||||
masked[col] = ""; // 해시값 대신 빈 문자열
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
};
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(maskRow);
|
||||
}
|
||||
return maskRow(data);
|
||||
} catch (error) {
|
||||
// 마스킹 실패해도 원본 데이터 반환 (서비스 중단 방지)
|
||||
console.warn("⚠️ password 컬럼 마스킹 실패:", error);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
interface GetTableDataParams {
|
||||
tableName: string;
|
||||
limit?: number;
|
||||
|
|
@ -622,14 +661,14 @@ class DataService {
|
|||
|
||||
return {
|
||||
success: true,
|
||||
data: normalizedGroupRows, // 🔧 배열로 반환!
|
||||
data: await maskPasswordColumns(tableName, normalizedGroupRows), // 🔧 배열로 반환! + password 마스킹
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
|
||||
data: await maskPasswordColumns(tableName, normalizedRows[0]), // 그룹핑 없으면 단일 레코드 + password 마스킹
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -648,7 +687,7 @@ class DataService {
|
|||
|
||||
return {
|
||||
success: true,
|
||||
data: result[0],
|
||||
data: await maskPasswordColumns(tableName, result[0]), // password 마스킹
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
|
||||
|
|
@ -1354,7 +1393,8 @@ class DataService {
|
|||
parentKeys: Record<string, any>,
|
||||
records: Array<Record<string, any>>,
|
||||
userCompany?: string,
|
||||
userId?: string
|
||||
userId?: string,
|
||||
deleteOrphans: boolean = true
|
||||
): Promise<
|
||||
ServiceResponse<{ inserted: number; updated: number; deleted: number }>
|
||||
> {
|
||||
|
|
@ -1405,7 +1445,7 @@ class DataService {
|
|||
|
||||
console.log(`✅ 기존 레코드: ${existingRecords.rows.length}개`);
|
||||
|
||||
// 2. 새 레코드와 기존 레코드 비교
|
||||
// 2. id 기반 UPSERT: 레코드에 id(PK)가 있으면 UPDATE, 없으면 INSERT
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
|
|
@ -1413,125 +1453,81 @@ class DataService {
|
|||
// 날짜 필드를 YYYY-MM-DD 형식으로 변환하는 헬퍼 함수
|
||||
const normalizeDateValue = (value: any): any => {
|
||||
if (value == null) return value;
|
||||
|
||||
// ISO 날짜 문자열 감지 (YYYY-MM-DDTHH:mm:ss.sssZ)
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
return value.split("T")[0]; // YYYY-MM-DD 만 추출
|
||||
return value.split("T")[0];
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
// 새 레코드 처리 (INSERT or UPDATE)
|
||||
for (const newRecord of records) {
|
||||
console.log(`🔍 처리할 새 레코드:`, newRecord);
|
||||
const existingIds = new Set(existingRecords.rows.map((r: any) => r[pkColumn]));
|
||||
const processedIds = new Set<string>(); // UPDATE 처리된 id 추적
|
||||
|
||||
for (const newRecord of records) {
|
||||
// 날짜 필드 정규화
|
||||
const normalizedRecord: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(newRecord)) {
|
||||
normalizedRecord[key] = normalizeDateValue(value);
|
||||
}
|
||||
|
||||
console.log(`🔄 정규화된 레코드:`, normalizedRecord);
|
||||
const recordId = normalizedRecord[pkColumn]; // 프론트에서 보낸 기존 레코드의 id
|
||||
|
||||
// 전체 레코드 데이터 (parentKeys + normalizedRecord)
|
||||
const fullRecord = { ...parentKeys, ...normalizedRecord };
|
||||
|
||||
// 고유 키: parentKeys 제외한 나머지 필드들
|
||||
const uniqueFields = Object.keys(normalizedRecord);
|
||||
|
||||
console.log(`🔑 고유 필드들:`, uniqueFields);
|
||||
|
||||
// 기존 레코드에서 일치하는 것 찾기
|
||||
const existingRecord = existingRecords.rows.find((existing) => {
|
||||
return uniqueFields.every((field) => {
|
||||
const existingValue = existing[field];
|
||||
const newValue = normalizedRecord[field];
|
||||
|
||||
// null/undefined 처리
|
||||
if (existingValue == null && newValue == null) return true;
|
||||
if (existingValue == null || newValue == null) return false;
|
||||
|
||||
// Date 타입 처리
|
||||
if (existingValue instanceof Date && typeof newValue === "string") {
|
||||
return (
|
||||
existingValue.toISOString().split("T")[0] ===
|
||||
newValue.split("T")[0]
|
||||
);
|
||||
}
|
||||
|
||||
// 문자열 비교
|
||||
return String(existingValue) === String(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
if (existingRecord) {
|
||||
// UPDATE: 기존 레코드가 있으면 업데이트
|
||||
if (recordId && existingIds.has(recordId)) {
|
||||
// ===== UPDATE: id(PK)가 DB에 존재 → 해당 레코드 업데이트 =====
|
||||
const fullRecord = { ...parentKeys, ...normalizedRecord };
|
||||
const updateFields: string[] = [];
|
||||
const updateValues: any[] = [];
|
||||
let updateParamIndex = 1;
|
||||
let paramIdx = 1;
|
||||
|
||||
for (const [key, value] of Object.entries(fullRecord)) {
|
||||
if (key !== pkColumn) {
|
||||
// Primary Key는 업데이트하지 않음
|
||||
updateFields.push(`"${key}" = $${updateParamIndex}`);
|
||||
updateFields.push(`"${key}" = $${paramIdx}`);
|
||||
updateValues.push(value);
|
||||
updateParamIndex++;
|
||||
paramIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
updateValues.push(existingRecord[pkColumn]); // WHERE 조건용
|
||||
const updateQuery = `
|
||||
UPDATE "${tableName}"
|
||||
SET ${updateFields.join(", ")}, updated_date = NOW()
|
||||
WHERE "${pkColumn}" = $${updateParamIndex}
|
||||
`;
|
||||
|
||||
await pool.query(updateQuery, updateValues);
|
||||
updated++;
|
||||
|
||||
console.log(`✏️ UPDATE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||
if (updateFields.length > 0) {
|
||||
updateValues.push(recordId);
|
||||
const updateQuery = `
|
||||
UPDATE "${tableName}"
|
||||
SET ${updateFields.join(", ")}, updated_date = NOW()
|
||||
WHERE "${pkColumn}" = $${paramIdx}
|
||||
`;
|
||||
await pool.query(updateQuery, updateValues);
|
||||
updated++;
|
||||
processedIds.add(recordId);
|
||||
console.log(`✏️ UPDATE by id: ${pkColumn} = ${recordId}`);
|
||||
}
|
||||
} else {
|
||||
// INSERT: 기존 레코드가 없으면 삽입
|
||||
|
||||
// 🆕 자동 필드 추가 (company_code, writer, created_date, updated_date, id)
|
||||
// created_date는 프론트엔드에서 전달된 값 무시하고 항상 현재 시간 설정
|
||||
const { created_date: _, ...recordWithoutCreatedDate } = fullRecord;
|
||||
// ===== INSERT: id 없음 또는 DB에 없음 → 새 레코드 삽입 =====
|
||||
const { [pkColumn]: _removedId, created_date: _cd, ...cleanRecord } = normalizedRecord;
|
||||
const fullRecord = { ...parentKeys, ...cleanRecord };
|
||||
const newId = uuidv4();
|
||||
const recordWithMeta: Record<string, any> = {
|
||||
...recordWithoutCreatedDate,
|
||||
id: uuidv4(), // 새 ID 생성
|
||||
...fullRecord,
|
||||
[pkColumn]: newId,
|
||||
created_date: "NOW()",
|
||||
updated_date: "NOW()",
|
||||
};
|
||||
|
||||
// company_code가 없으면 userCompany 사용 (단, userCompany가 "*"가 아닐 때만)
|
||||
if (
|
||||
!recordWithMeta.company_code &&
|
||||
userCompany &&
|
||||
userCompany !== "*"
|
||||
) {
|
||||
if (!recordWithMeta.company_code && userCompany && userCompany !== "*") {
|
||||
recordWithMeta.company_code = userCompany;
|
||||
}
|
||||
|
||||
// writer가 없으면 userId 사용
|
||||
if (!recordWithMeta.writer && userId) {
|
||||
recordWithMeta.writer = userId;
|
||||
}
|
||||
|
||||
const insertFields = Object.keys(recordWithMeta).filter(
|
||||
(key) => recordWithMeta[key] !== "NOW()"
|
||||
);
|
||||
const insertPlaceholders: string[] = [];
|
||||
const insertValues: any[] = [];
|
||||
let insertParamIndex = 1;
|
||||
let paramIdx = 1;
|
||||
|
||||
for (const field of Object.keys(recordWithMeta)) {
|
||||
if (recordWithMeta[field] === "NOW()") {
|
||||
insertPlaceholders.push("NOW()");
|
||||
} else {
|
||||
insertPlaceholders.push(`$${insertParamIndex}`);
|
||||
insertPlaceholders.push(`$${paramIdx}`);
|
||||
insertValues.push(recordWithMeta[field]);
|
||||
insertParamIndex++;
|
||||
paramIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1541,57 +1537,33 @@ class DataService {
|
|||
.join(", ")})
|
||||
VALUES (${insertPlaceholders.join(", ")})
|
||||
`;
|
||||
|
||||
console.log(`➕ INSERT 쿼리:`, {
|
||||
query: insertQuery,
|
||||
values: insertValues,
|
||||
});
|
||||
|
||||
await pool.query(insertQuery, insertValues);
|
||||
inserted++;
|
||||
|
||||
console.log(`➕ INSERT: 새 레코드`);
|
||||
processedIds.add(newId);
|
||||
console.log(`➕ INSERT: 새 레코드 ${pkColumn} = ${newId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 삭제할 레코드 찾기 (기존 레코드 중 새 레코드에 없는 것)
|
||||
for (const existingRecord of existingRecords.rows) {
|
||||
const uniqueFields = Object.keys(records[0] || {});
|
||||
|
||||
const stillExists = records.some((newRecord) => {
|
||||
return uniqueFields.every((field) => {
|
||||
const existingValue = existingRecord[field];
|
||||
const newValue = newRecord[field];
|
||||
|
||||
if (existingValue == null && newValue == null) return true;
|
||||
if (existingValue == null || newValue == null) return false;
|
||||
|
||||
if (existingValue instanceof Date && typeof newValue === "string") {
|
||||
return (
|
||||
existingValue.toISOString().split("T")[0] ===
|
||||
newValue.split("T")[0]
|
||||
);
|
||||
}
|
||||
|
||||
return String(existingValue) === String(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
if (!stillExists) {
|
||||
// DELETE: 새 레코드에 없으면 삭제
|
||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
await pool.query(deleteQuery, [existingRecord[pkColumn]]);
|
||||
deleted++;
|
||||
|
||||
console.log(`🗑️ DELETE: ${pkColumn} = ${existingRecord[pkColumn]}`);
|
||||
// 3. 고아 레코드 삭제: deleteOrphans=true일 때만 (EDIT 모드)
|
||||
// CREATE 모드에서는 기존 레코드를 건드리지 않음
|
||||
if (deleteOrphans) {
|
||||
for (const existingRow of existingRecords.rows) {
|
||||
const existId = existingRow[pkColumn];
|
||||
if (!processedIds.has(existId)) {
|
||||
const deleteQuery = `DELETE FROM "${tableName}" WHERE "${pkColumn}" = $1`;
|
||||
await pool.query(deleteQuery, [existId]);
|
||||
deleted++;
|
||||
console.log(`🗑️ DELETE orphan: ${pkColumn} = ${existId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted });
|
||||
const savedIds = Array.from(processedIds);
|
||||
console.log(`✅ UPSERT 완료:`, { inserted, updated, deleted, savedIds });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { inserted, updated, deleted },
|
||||
data: { inserted, updated, deleted, savedIds },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`UPSERT 오류 (${tableName}):`, error);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { query, queryOne, transaction, getPool } from "../database/db";
|
|||
import { EventTriggerService } from "./eventTriggerService";
|
||||
import { DataflowControlService } from "./dataflowControlService";
|
||||
import tableCategoryValueService from "./tableCategoryValueService";
|
||||
import { PasswordUtils } from "../utils/passwordUtils";
|
||||
|
||||
export interface FormDataResult {
|
||||
id: number;
|
||||
|
|
@ -859,6 +860,33 @@ export class DynamicFormService {
|
|||
}
|
||||
}
|
||||
|
||||
// 비밀번호(password) 타입 컬럼 처리
|
||||
// - 빈 값이면 변경 목록에서 제거 (기존 비밀번호 유지)
|
||||
// - 값이 있으면 암호화 후 저장
|
||||
try {
|
||||
const passwordCols = await query<{ column_name: string }>(
|
||||
`SELECT DISTINCT column_name FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'password'`,
|
||||
[tableName]
|
||||
);
|
||||
for (const { column_name } of passwordCols) {
|
||||
if (column_name in changedFields) {
|
||||
const pwValue = changedFields[column_name];
|
||||
if (!pwValue || pwValue === "") {
|
||||
// 빈 값 → 기존 비밀번호 유지 (변경 목록에서 제거)
|
||||
delete changedFields[column_name];
|
||||
console.log(`🔐 비밀번호 필드 ${column_name}: 빈 값이므로 업데이트 스킵 (기존 유지)`);
|
||||
} else {
|
||||
// 값 있음 → 암호화하여 저장
|
||||
changedFields[column_name] = PasswordUtils.encrypt(pwValue);
|
||||
console.log(`🔐 비밀번호 필드 ${column_name}: 새 비밀번호 암호화 완료`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (pwError) {
|
||||
console.warn("⚠️ 비밀번호 컬럼 처리 중 오류:", pwError);
|
||||
}
|
||||
|
||||
// 변경된 필드가 없으면 업데이트 건너뛰기
|
||||
if (Object.keys(changedFields).length === 0) {
|
||||
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");
|
||||
|
|
@ -1290,6 +1318,11 @@ export class DynamicFormService {
|
|||
return res.rows;
|
||||
});
|
||||
|
||||
// 삭제된 행이 없으면 레코드를 찾을 수 없는 것
|
||||
if (!result || !Array.isArray(result) || result.length === 0) {
|
||||
throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
|
||||
}
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||||
|
||||
// 🔥 조건부 연결 실행 (DELETE 트리거)
|
||||
|
|
|
|||
|
|
@ -16,16 +16,18 @@ export class EntityJoinService {
|
|||
* 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성
|
||||
* @param tableName 테이블명
|
||||
* @param screenEntityConfigs 화면별 엔티티 설정 (선택사항)
|
||||
* @param companyCode 회사코드 (회사별 설정 우선, 없으면 전체 조회)
|
||||
*/
|
||||
async detectEntityJoins(
|
||||
tableName: string,
|
||||
screenEntityConfigs?: Record<string, any>
|
||||
screenEntityConfigs?: Record<string, any>,
|
||||
companyCode?: string
|
||||
): Promise<EntityJoinConfig[]> {
|
||||
try {
|
||||
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
||||
logger.info(`Entity 컬럼 감지 시작: ${tableName} (companyCode: ${companyCode || 'all'})`);
|
||||
|
||||
// table_type_columns에서 entity 및 category 타입인 컬럼들 조회
|
||||
// company_code = '*' (공통 설정) 우선 조회
|
||||
// 회사코드가 있으면 해당 회사 + '*' 만 조회, 회사별 우선
|
||||
const entityColumns = await query<{
|
||||
column_name: string;
|
||||
input_type: string;
|
||||
|
|
@ -33,14 +35,17 @@ export class EntityJoinService {
|
|||
reference_column: string;
|
||||
display_column: string | null;
|
||||
}>(
|
||||
`SELECT column_name, input_type, reference_table, reference_column, display_column
|
||||
`SELECT DISTINCT ON (column_name)
|
||||
column_name, input_type, reference_table, reference_column, display_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type IN ('entity', 'category')
|
||||
AND company_code = '*'
|
||||
AND reference_table IS NOT NULL
|
||||
AND reference_table != ''`,
|
||||
[tableName]
|
||||
AND reference_table != ''
|
||||
${companyCode ? `AND company_code IN ($2, '*')` : ''}
|
||||
ORDER BY column_name,
|
||||
CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
companyCode ? [tableName, companyCode] : [tableName]
|
||||
);
|
||||
|
||||
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
|
||||
|
|
@ -272,7 +277,8 @@ export class EntityJoinService {
|
|||
orderBy: string = "",
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
columnTypes?: Map<string, string> // 컬럼명 → 데이터 타입 매핑
|
||||
columnTypes?: Map<string, string>, // 컬럼명 → 데이터 타입 매핑
|
||||
referenceTableColumns?: Map<string, string[]> // 🆕 참조 테이블별 전체 컬럼 목록
|
||||
): { query: string; aliasMap: Map<string, string> } {
|
||||
try {
|
||||
// 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅)
|
||||
|
|
@ -338,115 +344,100 @@ export class EntityJoinService {
|
|||
);
|
||||
});
|
||||
|
||||
// 🔧 _label 별칭 중복 방지를 위한 Set
|
||||
// 같은 sourceColumn에서 여러 조인 설정이 있을 때 _label은 첫 번째만 생성
|
||||
const generatedLabelAliases = new Set<string>();
|
||||
// 🔧 생성된 별칭 중복 방지를 위한 Set
|
||||
const generatedAliases = new Set<string>();
|
||||
|
||||
const joinColumns = joinConfigs
|
||||
const joinColumns = uniqueReferenceTableConfigs
|
||||
.map((config) => {
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
const displayColumns = config.displayColumns || [
|
||||
config.displayColumn,
|
||||
];
|
||||
const separator = config.separator || " - ";
|
||||
|
||||
// 결과 컬럼 배열 (aliasColumn + _label 필드)
|
||||
const resultColumns: string[] = [];
|
||||
|
||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
||||
// 조인 테이블의 referenceColumn을 기본값으로 사용
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
} else if (displayColumns.length === 1) {
|
||||
// 단일 컬럼인 경우
|
||||
const col = displayColumns[0];
|
||||
// 🆕 참조 테이블의 전체 컬럼 목록이 있으면 모든 컬럼을 SELECT
|
||||
const refTableCols = referenceTableColumns?.get(
|
||||
`${config.referenceTable}:${config.sourceColumn}`
|
||||
) || referenceTableColumns?.get(config.referenceTable);
|
||||
|
||||
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
|
||||
// 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
if (refTableCols && refTableCols.length > 0) {
|
||||
// 메타 컬럼은 제외 (메인 테이블과 중복되거나 불필요)
|
||||
const skipColumns = new Set(["company_code", "created_date", "updated_date", "writer"]);
|
||||
|
||||
for (const col of refTableCols) {
|
||||
if (skipColumns.has(col)) continue;
|
||||
|
||||
const colAlias = `${config.sourceColumn}_${col}`;
|
||||
if (generatedAliases.has(colAlias)) continue;
|
||||
|
||||
if (isJoinTableColumn) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
`COALESCE(${alias}."${col}"::TEXT, '') AS "${colAlias}"`
|
||||
);
|
||||
generatedAliases.add(colAlias);
|
||||
}
|
||||
|
||||
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
|
||||
// sourceColumn_label 형식으로 추가
|
||||
// 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성
|
||||
const labelAlias = `${config.sourceColumn}_label`;
|
||||
if (!generatedLabelAliases.has(labelAlias)) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
|
||||
);
|
||||
generatedLabelAliases.add(labelAlias);
|
||||
}
|
||||
|
||||
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
|
||||
// 예: customer_code, item_number 등
|
||||
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
|
||||
// 🔧 중복 방지: referenceColumn도 한 번만 추가
|
||||
const refColAlias = config.referenceColumn;
|
||||
if (!generatedLabelAliases.has(refColAlias)) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${refColAlias}`
|
||||
);
|
||||
generatedLabelAliases.add(refColAlias);
|
||||
}
|
||||
} else {
|
||||
// _label 필드도 추가 (기존 호환성)
|
||||
const labelAlias = `${config.sourceColumn}_label`;
|
||||
if (!generatedAliases.has(labelAlias)) {
|
||||
// 표시용 컬럼 자동 감지: *_name > name > label > referenceColumn
|
||||
const nameCol = refTableCols.find((c) => c.endsWith("_name") && c !== "company_name");
|
||||
const displayCol = nameCol || refTableCols.find((c) => c === "name") || config.referenceColumn;
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
`COALESCE(${alias}."${displayCol}"::TEXT, '') AS "${labelAlias}"`
|
||||
);
|
||||
generatedAliases.add(labelAlias);
|
||||
}
|
||||
} else {
|
||||
// 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음)
|
||||
// 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price)
|
||||
displayColumns.forEach((col) => {
|
||||
// 🔄 기존 로직 (참조 테이블 컬럼 목록이 없는 경우 - fallback)
|
||||
const displayColumns = config.displayColumns || [config.displayColumn];
|
||||
|
||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
} else if (displayColumns.length === 1) {
|
||||
const col = displayColumns[0];
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
|
||||
const individualAlias = `${config.sourceColumn}_${col}`;
|
||||
|
||||
// 🔧 중복 방지: 같은 alias가 이미 생성되었으면 스킵
|
||||
if (generatedLabelAliases.has(individualAlias)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJoinTableColumn) {
|
||||
// 조인 테이블 컬럼은 조인 별칭 사용
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
const labelAlias = `${config.sourceColumn}_label`;
|
||||
if (!generatedAliases.has(labelAlias)) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
|
||||
);
|
||||
generatedAliases.add(labelAlias);
|
||||
}
|
||||
} else {
|
||||
// 기본 테이블 컬럼은 main 별칭 사용
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
}
|
||||
generatedLabelAliases.add(individualAlias);
|
||||
});
|
||||
} else {
|
||||
displayColumns.forEach((col) => {
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
const individualAlias = `${config.sourceColumn}_${col}`;
|
||||
if (generatedAliases.has(individualAlias)) return;
|
||||
|
||||
// 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용)
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
if (
|
||||
isJoinTableColumn &&
|
||||
!displayColumns.includes(config.referenceColumn) &&
|
||||
!generatedLabelAliases.has(config.referenceColumn) // 🔧 중복 방지
|
||||
) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
|
||||
);
|
||||
generatedLabelAliases.add(config.referenceColumn);
|
||||
if (isJoinTableColumn) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
|
||||
);
|
||||
} else {
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
|
||||
);
|
||||
}
|
||||
generatedAliases.add(individualAlias);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 resultColumns를 반환
|
||||
return resultColumns.join(", ");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
// SELECT 절 구성
|
||||
|
|
@ -466,17 +457,18 @@ export class EntityJoinService {
|
|||
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
}
|
||||
|
||||
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
|
||||
if (config.referenceTable === "user_info") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;
|
||||
}
|
||||
|
||||
// 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시)
|
||||
// supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`;
|
||||
// ::TEXT 캐스팅으로 varchar/integer 등 타입 불일치 방지
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.company_code = main.company_code`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
|
|
@ -589,6 +581,7 @@ export class EntityJoinService {
|
|||
logger.info("🔍 조인 설정 검증 상세:", {
|
||||
sourceColumn: config.sourceColumn,
|
||||
referenceTable: config.referenceTable,
|
||||
referenceColumn: config.referenceColumn,
|
||||
displayColumns: config.displayColumns,
|
||||
displayColumn: config.displayColumn,
|
||||
aliasColumn: config.aliasColumn,
|
||||
|
|
@ -607,7 +600,45 @@ export class EntityJoinService {
|
|||
return false;
|
||||
}
|
||||
|
||||
// 참조 컬럼 존재 확인 (displayColumns[0] 사용)
|
||||
// 참조 컬럼(JOIN 키) 존재 확인 - 참조 테이블에 reference_column이 실제로 있는지 검증
|
||||
if (config.referenceColumn) {
|
||||
const refColExists = await query<{ exists: number }>(
|
||||
`SELECT 1 as exists FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
LIMIT 1`,
|
||||
[config.referenceTable, config.referenceColumn]
|
||||
);
|
||||
|
||||
if (refColExists.length === 0) {
|
||||
// reference_column이 없으면 'id' 컬럼으로 자동 대체 시도
|
||||
const idColExists = await query<{ exists: number }>(
|
||||
`SELECT 1 as exists FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND column_name = 'id'
|
||||
LIMIT 1`,
|
||||
[config.referenceTable]
|
||||
);
|
||||
|
||||
if (idColExists.length > 0) {
|
||||
logger.warn(
|
||||
`⚠️ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않음 → 'id'로 자동 대체`
|
||||
);
|
||||
config.referenceColumn = "id";
|
||||
} else {
|
||||
logger.warn(
|
||||
`❌ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않고 'id' 컬럼도 없음 → 스킵`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`✅ 참조 컬럼 확인 완료: ${config.referenceTable}.${config.referenceColumn}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 표시 컬럼 존재 확인 (displayColumns[0] 사용)
|
||||
const displayColumn = config.displayColumns?.[0] || config.displayColumn;
|
||||
logger.info(
|
||||
`🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})`
|
||||
|
|
@ -695,10 +726,10 @@ export class EntityJoinService {
|
|||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
}
|
||||
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
|
|
@ -725,7 +756,7 @@ export class EntityJoinService {
|
|||
/**
|
||||
* 참조 테이블의 컬럼 목록 조회 (UI용)
|
||||
*/
|
||||
async getReferenceTableColumns(tableName: string): Promise<
|
||||
async getReferenceTableColumns(tableName: string, companyCode?: string): Promise<
|
||||
Array<{
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
|
|
@ -750,16 +781,19 @@ export class EntityJoinService {
|
|||
);
|
||||
|
||||
// 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회
|
||||
// 회사코드가 있으면 해당 회사 + '*' 만, 회사별 우선
|
||||
const columnLabels = await query<{
|
||||
column_name: string;
|
||||
column_label: string | null;
|
||||
input_type: string | null;
|
||||
}>(
|
||||
`SELECT column_name, column_label, input_type
|
||||
`SELECT DISTINCT ON (column_name) column_name, column_label, input_type
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND company_code = '*'`,
|
||||
[tableName]
|
||||
${companyCode ? `AND company_code IN ($2, '*')` : ''}
|
||||
ORDER BY column_name,
|
||||
CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
companyCode ? [tableName, companyCode] : [tableName]
|
||||
);
|
||||
|
||||
// 3. 라벨 및 inputType 정보를 맵으로 변환
|
||||
|
|
|
|||
|
|
@ -31,13 +31,6 @@ export class FlowExecutionService {
|
|||
throw new Error(`Flow definition not found: ${flowId}`);
|
||||
}
|
||||
|
||||
console.log("🔍 [getStepDataCount] Flow Definition:", {
|
||||
flowId,
|
||||
dbSourceType: flowDef.dbSourceType,
|
||||
dbConnectionId: flowDef.dbConnectionId,
|
||||
tableName: flowDef.tableName,
|
||||
});
|
||||
|
||||
// 2. 플로우 단계 조회
|
||||
const step = await this.flowStepService.findById(stepId);
|
||||
if (!step) {
|
||||
|
|
@ -59,36 +52,21 @@ export class FlowExecutionService {
|
|||
// 5. 카운트 쿼리 실행 (내부 또는 외부 DB)
|
||||
const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
|
||||
|
||||
console.log("🔍 [getStepDataCount] Query Info:", {
|
||||
tableName,
|
||||
query,
|
||||
params,
|
||||
isExternal: flowDef.dbSourceType === "external",
|
||||
connectionId: flowDef.dbConnectionId,
|
||||
});
|
||||
|
||||
let result: any;
|
||||
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
|
||||
// 외부 DB 조회
|
||||
console.log(
|
||||
"✅ [getStepDataCount] Using EXTERNAL DB:",
|
||||
flowDef.dbConnectionId
|
||||
);
|
||||
const externalResult = await executeExternalQuery(
|
||||
flowDef.dbConnectionId,
|
||||
query,
|
||||
params
|
||||
);
|
||||
console.log("📦 [getStepDataCount] External result:", externalResult);
|
||||
result = externalResult.rows;
|
||||
} else {
|
||||
// 내부 DB 조회
|
||||
console.log("✅ [getStepDataCount] Using INTERNAL DB");
|
||||
result = await db.query(query, params);
|
||||
}
|
||||
|
||||
const count = parseInt(result[0].count || result[0].COUNT);
|
||||
console.log("✅ [getStepDataCount] Final count:", count);
|
||||
return count;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,13 +93,6 @@ export class FlowStepService {
|
|||
id: number,
|
||||
request: UpdateFlowStepRequest
|
||||
): Promise<FlowStep | null> {
|
||||
console.log("🔧 FlowStepService.update called with:", {
|
||||
id,
|
||||
statusColumn: request.statusColumn,
|
||||
statusValue: request.statusValue,
|
||||
fullRequest: JSON.stringify(request),
|
||||
});
|
||||
|
||||
// 조건 검증
|
||||
if (request.conditionJson) {
|
||||
FlowConditionParser.validateConditionGroup(request.conditionJson);
|
||||
|
|
@ -276,14 +269,6 @@ export class FlowStepService {
|
|||
// JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌
|
||||
const displayConfig = row.display_config;
|
||||
|
||||
// 디버깅 로그 (개발 환경에서만)
|
||||
if (displayConfig && process.env.NODE_ENV === "development") {
|
||||
console.log(`🔍 [FlowStep ${row.id}] displayConfig:`, {
|
||||
type: typeof displayConfig,
|
||||
value: displayConfig,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
flowDefinitionId: row.flow_definition_id,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export interface ExcelUploadResult {
|
|||
masterInserted: number;
|
||||
masterUpdated: number;
|
||||
detailInserted: number;
|
||||
detailUpdated: number;
|
||||
detailDeleted: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
|
@ -310,6 +311,7 @@ class MasterDetailExcelService {
|
|||
sourceColumn: string;
|
||||
alias: string;
|
||||
displayColumn: string;
|
||||
tableAlias: string; // "m" (마스터) 또는 "d" (디테일) - JOIN 시 소스 테이블 구분
|
||||
}> = [];
|
||||
|
||||
// SELECT 절 구성
|
||||
|
|
@ -332,6 +334,7 @@ class MasterDetailExcelService {
|
|||
sourceColumn: fkColumn.sourceColumn,
|
||||
alias,
|
||||
displayColumn,
|
||||
tableAlias: "m", // 마스터 테이블에서 조인
|
||||
});
|
||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||
} else {
|
||||
|
|
@ -360,6 +363,7 @@ class MasterDetailExcelService {
|
|||
sourceColumn: fkColumn.sourceColumn,
|
||||
alias,
|
||||
displayColumn,
|
||||
tableAlias: "d", // 디테일 테이블에서 조인
|
||||
});
|
||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||
} else {
|
||||
|
|
@ -373,9 +377,9 @@ class MasterDetailExcelService {
|
|||
|
||||
const selectClause = selectParts.join(", ");
|
||||
|
||||
// 엔티티 조인 절 구성
|
||||
// 엔티티 조인 절 구성 (마스터/디테일 테이블 alias 구분)
|
||||
const entityJoinClauses = entityJoins.map(ej =>
|
||||
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
||||
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON ${ej.tableAlias}."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
||||
).join("\n ");
|
||||
|
||||
// WHERE 절 구성
|
||||
|
|
@ -410,6 +414,16 @@ class MasterDetailExcelService {
|
|||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 디테일 테이블의 id 컬럼 존재 여부 확인 (user_info 등 id가 없는 테이블 대응)
|
||||
const detailIdCheck = await queryOne<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'id'
|
||||
) as exists`,
|
||||
[detailTable]
|
||||
);
|
||||
const detailOrderColumn = detailIdCheck?.exists ? `d."id"` : `d."${detailFkColumn}"`;
|
||||
|
||||
// JOIN 쿼리 실행
|
||||
const sql = `
|
||||
SELECT ${selectClause}
|
||||
|
|
@ -419,7 +433,7 @@ class MasterDetailExcelService {
|
|||
AND m.company_code = d.company_code
|
||||
${entityJoinClauses}
|
||||
${whereClause}
|
||||
ORDER BY m."${masterKeyColumn}", d.id
|
||||
ORDER BY m."${masterKeyColumn}", ${detailOrderColumn}
|
||||
`;
|
||||
|
||||
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
|
||||
|
|
@ -478,14 +492,172 @@ class MasterDetailExcelService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환
|
||||
* 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback
|
||||
*/
|
||||
private async detectNumberingRuleForColumn(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
companyCode?: string
|
||||
): Promise<{ numberingRuleId: string } | null> {
|
||||
try {
|
||||
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저)
|
||||
const companyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($3, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
const params = companyCode && companyCode !== "*"
|
||||
? [tableName, columnName, companyCode]
|
||||
: [tableName, columnName];
|
||||
|
||||
const result = await query<any>(
|
||||
`SELECT input_type, detail_settings, company_code
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
params
|
||||
);
|
||||
|
||||
// 채번 타입인 행 찾기 (회사별 우선)
|
||||
for (const row of result) {
|
||||
if (row.input_type === "numbering") {
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
|
||||
if (settings?.numberingRuleId) {
|
||||
return { numberingRuleId: settings.numberingRuleId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 모든 채번 컬럼을 한 번에 조회
|
||||
* 회사별 설정 우선, 공통(*) 설정 fallback
|
||||
* @returns Map<columnName, numberingRuleId>
|
||||
*/
|
||||
private async detectAllNumberingColumns(
|
||||
tableName: string,
|
||||
companyCode?: string
|
||||
): Promise<Map<string, string>> {
|
||||
const numberingCols = new Map<string, string>();
|
||||
try {
|
||||
const companyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($2, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
const params = companyCode && companyCode !== "*"
|
||||
? [tableName, companyCode]
|
||||
: [tableName];
|
||||
|
||||
const result = await query<any>(
|
||||
`SELECT column_name, detail_settings, company_code
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
params
|
||||
);
|
||||
|
||||
// 컬럼별로 회사 설정 우선 적용
|
||||
for (const row of result) {
|
||||
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
if (settings?.numberingRuleId) {
|
||||
numberingCols.set(row.column_name, settings.numberingRuleId);
|
||||
}
|
||||
}
|
||||
|
||||
if (numberingCols.size > 0) {
|
||||
logger.info(`테이블 ${tableName} 채번 컬럼 감지:`, Object.fromEntries(numberingCols));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`테이블 ${tableName} 채번 컬럼 감지 실패:`, error);
|
||||
}
|
||||
return numberingCols;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용)
|
||||
* PK가 비즈니스 키이면 사용, auto-increment 'id'만이면 유니크 인덱스 탐색
|
||||
* @returns 고유 키 컬럼 배열 (빈 배열이면 매칭 불가 → INSERT만 수행)
|
||||
*/
|
||||
private async detectUniqueKeyColumns(
|
||||
client: any,
|
||||
tableName: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
// 1. PK 컬럼 조회
|
||||
const pkResult = await client.query(
|
||||
`SELECT array_agg(a.attname ORDER BY x.n) AS columns
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS x(attnum, n)
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
|
||||
WHERE n.nspname = 'public' AND t.relname = $1 AND c.contype = 'p'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
if (pkResult.rows.length > 0 && pkResult.rows[0].columns) {
|
||||
const pkCols: string[] = typeof pkResult.rows[0].columns === "string"
|
||||
? pkResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim())
|
||||
: pkResult.rows[0].columns;
|
||||
|
||||
// PK가 'id' 하나만 있으면 auto-increment이므로 사용 불가
|
||||
if (!(pkCols.length === 1 && pkCols[0] === "id")) {
|
||||
logger.info(`디테일 테이블 ${tableName} 고유 키 (PK): ${pkCols.join(", ")}`);
|
||||
return pkCols;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. PK가 'id'뿐이면 유니크 인덱스 탐색
|
||||
const uqResult = await client.query(
|
||||
`SELECT array_agg(a.attname ORDER BY x.n) AS columns
|
||||
FROM pg_index ix
|
||||
JOIN pg_class t ON t.oid = ix.indrelid
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n)
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
|
||||
WHERE n.nspname = 'public' AND t.relname = $1
|
||||
AND ix.indisunique = true AND ix.indisprimary = false
|
||||
GROUP BY i.relname
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
if (uqResult.rows.length > 0 && uqResult.rows[0].columns) {
|
||||
const uqCols: string[] = typeof uqResult.rows[0].columns === "string"
|
||||
? uqResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim())
|
||||
: uqResult.rows[0].columns;
|
||||
logger.info(`디테일 테이블 ${tableName} 고유 키 (UNIQUE INDEX): ${uqCols.join(", ")}`);
|
||||
return uqCols;
|
||||
}
|
||||
|
||||
logger.info(`디테일 테이블 ${tableName} 고유 키 없음 → INSERT 전용`);
|
||||
return [];
|
||||
} catch (error) {
|
||||
logger.error(`디테일 테이블 ${tableName} 고유 키 감지 실패:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
||||
*
|
||||
* 처리 로직:
|
||||
* 1. 엑셀 데이터를 마스터 키로 그룹화
|
||||
* 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT
|
||||
* 3. 해당 마스터 키의 기존 디테일 삭제
|
||||
* 4. 새 디테일 데이터 INSERT
|
||||
* 1. 마스터 키 컬럼이 채번 타입인지 확인
|
||||
* 2-A. 채번인 경우: 다른 마스터 컬럼 값으로 그룹화 → 키 자동 생성 → INSERT
|
||||
* 2-B. 채번 아닌 경우: 마스터 키 값으로 그룹화 → UPSERT
|
||||
* 3. 디테일 데이터 개별 행 UPSERT (고유 키 기반)
|
||||
*/
|
||||
async uploadJoinedData(
|
||||
relation: MasterDetailRelation,
|
||||
|
|
@ -498,6 +670,7 @@ class MasterDetailExcelService {
|
|||
masterInserted: 0,
|
||||
masterUpdated: 0,
|
||||
detailInserted: 0,
|
||||
detailUpdated: 0,
|
||||
detailDeleted: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
|
@ -510,118 +683,322 @@ class MasterDetailExcelService {
|
|||
|
||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||
|
||||
// 1. 데이터를 마스터 키로 그룹화
|
||||
// 마스터/디테일 테이블의 실제 컬럼 존재 여부 확인 (writer, created_date 등 하드코딩 방지)
|
||||
const masterColsResult = await client.query(
|
||||
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,
|
||||
[masterTable]
|
||||
);
|
||||
const masterExistingCols = new Set(masterColsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
const detailColsResult = await client.query(
|
||||
`SELECT column_name FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1`,
|
||||
[detailTable]
|
||||
);
|
||||
const detailExistingCols = new Set(detailColsResult.rows.map((r: any) => r.column_name));
|
||||
|
||||
// 마스터 키 컬럼의 채번 규칙 자동 감지 (회사별 설정 우선)
|
||||
const numberingInfo = await this.detectNumberingRuleForColumn(masterTable, masterKeyColumn, companyCode);
|
||||
const isAutoNumbering = !!numberingInfo;
|
||||
|
||||
logger.info(`마스터 키 채번 감지:`, {
|
||||
masterKeyColumn,
|
||||
isAutoNumbering,
|
||||
numberingRuleId: numberingInfo?.numberingRuleId
|
||||
});
|
||||
|
||||
// 데이터 그룹화
|
||||
const groupedData = new Map<string, Record<string, any>[]>();
|
||||
|
||||
for (const row of data) {
|
||||
const masterKey = row[masterKeyColumn];
|
||||
if (!masterKey) {
|
||||
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
||||
continue;
|
||||
if (isAutoNumbering) {
|
||||
// 채번 모드: 마스터 키 제외한 다른 마스터 컬럼 값으로 그룹화
|
||||
const otherMasterCols = masterColumns.filter(c => c.name !== masterKeyColumn).map(c => c.name);
|
||||
|
||||
for (const row of data) {
|
||||
// 다른 마스터 컬럼 값들을 조합해 그룹 키 생성
|
||||
const groupKey = otherMasterCols.map(col => row[col] ?? "").join("|||");
|
||||
if (!groupedData.has(groupKey)) {
|
||||
groupedData.set(groupKey, []);
|
||||
}
|
||||
groupedData.get(groupKey)!.push(row);
|
||||
}
|
||||
|
||||
if (!groupedData.has(masterKey)) {
|
||||
groupedData.set(masterKey, []);
|
||||
logger.info(`채번 모드 그룹화 완료: ${groupedData.size}개 그룹 (기준: ${otherMasterCols.join(", ")})`);
|
||||
} else {
|
||||
// 일반 모드: 마스터 키 값으로 그룹화
|
||||
for (const row of data) {
|
||||
const masterKey = row[masterKeyColumn];
|
||||
if (!masterKey) {
|
||||
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
|
||||
continue;
|
||||
}
|
||||
if (!groupedData.has(masterKey)) {
|
||||
groupedData.set(masterKey, []);
|
||||
}
|
||||
groupedData.get(masterKey)!.push(row);
|
||||
}
|
||||
groupedData.get(masterKey)!.push(row);
|
||||
|
||||
logger.info(`일반 모드 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
||||
}
|
||||
|
||||
logger.info(`데이터 그룹화 완료: ${groupedData.size}개 마스터 그룹`);
|
||||
// 디테일 테이블의 채번 컬럼 사전 감지 (1회 쿼리로 모든 채번 컬럼 조회)
|
||||
const detailNumberingCols = await this.detectAllNumberingColumns(detailTable, companyCode);
|
||||
// 마스터 테이블의 비-키 채번 컬럼도 감지
|
||||
const masterNumberingCols = await this.detectAllNumberingColumns(masterTable, companyCode);
|
||||
|
||||
// 2. 각 그룹 처리
|
||||
for (const [masterKey, rows] of groupedData.entries()) {
|
||||
// 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용)
|
||||
// PK가 비즈니스 키인 경우 사용, auto-increment 'id'만 있으면 유니크 인덱스 탐색
|
||||
const detailUniqueKeyCols = await this.detectUniqueKeyColumns(client, detailTable);
|
||||
|
||||
// 각 그룹 처리
|
||||
for (const [groupKey, rows] of groupedData.entries()) {
|
||||
try {
|
||||
// 2a. 마스터 데이터 추출 (첫 번째 행에서)
|
||||
const masterData: Record<string, any> = {};
|
||||
// 마스터 키 결정 (채번이면 자동 생성, 아니면 그룹 키 자체가 마스터 키)
|
||||
let masterKey: string;
|
||||
let existingMasterKey: string | null = null;
|
||||
|
||||
// 마스터 데이터 추출 (첫 번째 행에서, 키 제외)
|
||||
const masterDataWithoutKey: Record<string, any> = {};
|
||||
for (const col of masterColumns) {
|
||||
if (col.name === masterKeyColumn) continue;
|
||||
if (rows[0][col.name] !== undefined) {
|
||||
masterData[col.name] = rows[0][col.name];
|
||||
masterDataWithoutKey[col.name] = rows[0][col.name];
|
||||
}
|
||||
}
|
||||
|
||||
// 회사 코드, 작성자 추가
|
||||
masterData.company_code = companyCode;
|
||||
if (userId) {
|
||||
if (isAutoNumbering) {
|
||||
// 채번 모드: 동일한 마스터가 이미 DB에 있는지 먼저 확인
|
||||
// 마스터 키 제외한 다른 컬럼들로 매칭 (예: dept_name이 같은 부서가 있는지)
|
||||
const matchCols = Object.keys(masterDataWithoutKey)
|
||||
.filter(k => k !== "company_code" && k !== "writer" && k !== "created_date" && k !== "updated_date" && k !== "id"
|
||||
&& masterDataWithoutKey[k] !== undefined && masterDataWithoutKey[k] !== null && masterDataWithoutKey[k] !== "");
|
||||
|
||||
if (matchCols.length > 0) {
|
||||
const whereClause = matchCols.map((col, i) => `"${col}" = $${i + 1}`).join(" AND ");
|
||||
const companyIdx = matchCols.length + 1;
|
||||
const matchResult = await client.query(
|
||||
`SELECT "${masterKeyColumn}" FROM "${masterTable}" WHERE ${whereClause} AND company_code = $${companyIdx} LIMIT 1`,
|
||||
[...matchCols.map(k => masterDataWithoutKey[k]), companyCode]
|
||||
);
|
||||
if (matchResult.rows.length > 0) {
|
||||
existingMasterKey = matchResult.rows[0][masterKeyColumn];
|
||||
logger.info(`채번 모드: 기존 마스터 발견 → ${masterKeyColumn}=${existingMasterKey} (매칭: ${matchCols.map(c => `${c}=${masterDataWithoutKey[c]}`).join(", ")})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (existingMasterKey) {
|
||||
// 기존 마스터 사용 (UPDATE)
|
||||
masterKey = existingMasterKey;
|
||||
const updateKeys = matchCols.filter(k => k !== masterKeyColumn);
|
||||
if (updateKeys.length > 0) {
|
||||
const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`);
|
||||
const setValues = updateKeys.map(k => masterDataWithoutKey[k]);
|
||||
const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
|
||||
await client.query(
|
||||
`UPDATE "${masterTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE "${masterKeyColumn}" = $${setValues.length + 1} AND company_code = $${setValues.length + 2}`,
|
||||
[...setValues, masterKey, companyCode]
|
||||
);
|
||||
}
|
||||
result.masterUpdated++;
|
||||
} else {
|
||||
// 새 마스터 생성 (채번)
|
||||
masterKey = await this.generateNumberWithRule(client, numberingInfo!.numberingRuleId, companyCode);
|
||||
logger.info(`채번 생성: ${masterKey}`);
|
||||
}
|
||||
} else {
|
||||
masterKey = groupKey;
|
||||
}
|
||||
|
||||
// 마스터 데이터 조립
|
||||
const masterData: Record<string, any> = {};
|
||||
masterData[masterKeyColumn] = masterKey;
|
||||
Object.assign(masterData, masterDataWithoutKey);
|
||||
|
||||
// 회사 코드, 작성자 추가 (테이블에 해당 컬럼이 있을 때만)
|
||||
if (masterExistingCols.has("company_code")) {
|
||||
masterData.company_code = companyCode;
|
||||
}
|
||||
if (userId && masterExistingCols.has("writer")) {
|
||||
masterData.writer = userId;
|
||||
}
|
||||
|
||||
// 2b. 마스터 UPSERT
|
||||
const existingMaster = await client.query(
|
||||
`SELECT id FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
|
||||
if (existingMaster.rows.length > 0) {
|
||||
// UPDATE
|
||||
const updateCols = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map((k, i) => `"${k}" = $${i + 1}`);
|
||||
const updateValues = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map(k => masterData[k]);
|
||||
|
||||
if (updateCols.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE "${masterTable}"
|
||||
SET ${updateCols.join(", ")}, updated_date = NOW()
|
||||
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
||||
[...updateValues, masterKey, companyCode]
|
||||
);
|
||||
// 마스터 비-키 채번 컬럼 자동 생성 (매핑되지 않은 경우)
|
||||
for (const [colName, ruleId] of masterNumberingCols) {
|
||||
if (colName === masterKeyColumn) continue;
|
||||
if (!masterData[colName] || masterData[colName] === "") {
|
||||
const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode);
|
||||
masterData[colName] = generatedValue;
|
||||
logger.info(`마스터 채번 생성: ${masterTable}.${colName} = ${generatedValue}`);
|
||||
}
|
||||
result.masterUpdated++;
|
||||
} else {
|
||||
// INSERT
|
||||
const insertCols = Object.keys(masterData);
|
||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||
const insertValues = insertCols.map(k => masterData[k]);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "${masterTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||
insertValues
|
||||
);
|
||||
result.masterInserted++;
|
||||
}
|
||||
|
||||
// 2c. 기존 디테일 삭제
|
||||
const deleteResult = await client.query(
|
||||
`DELETE FROM "${detailTable}" WHERE "${detailFkColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
result.detailDeleted += deleteResult.rowCount || 0;
|
||||
// INSERT SQL 생성 헬퍼 (created_date 존재 시만 추가)
|
||||
const buildInsertSQL = (table: string, data: Record<string, any>, existingCols: Set<string>) => {
|
||||
const cols = Object.keys(data);
|
||||
const hasCreatedDate = existingCols.has("created_date");
|
||||
const colList = hasCreatedDate ? [...cols, "created_date"] : cols;
|
||||
const placeholders = cols.map((_, i) => `$${i + 1}`);
|
||||
const valList = hasCreatedDate ? [...placeholders, "NOW()"] : placeholders;
|
||||
const values = cols.map(k => data[k]);
|
||||
return {
|
||||
sql: `INSERT INTO "${table}" (${colList.map(c => `"${c}"`).join(", ")}) VALUES (${valList.join(", ")})`,
|
||||
values,
|
||||
};
|
||||
};
|
||||
|
||||
// 2d. 새 디테일 INSERT
|
||||
if (isAutoNumbering && !existingMasterKey) {
|
||||
// 채번 모드 + 새 마스터: INSERT
|
||||
const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols);
|
||||
await client.query(sql, values);
|
||||
result.masterInserted++;
|
||||
} else if (!isAutoNumbering) {
|
||||
// 일반 모드: UPSERT (있으면 UPDATE, 없으면 INSERT)
|
||||
const existingMaster = await client.query(
|
||||
`SELECT 1 FROM "${masterTable}" WHERE "${masterKeyColumn}" = $1 AND company_code = $2`,
|
||||
[masterKey, companyCode]
|
||||
);
|
||||
|
||||
if (existingMaster.rows.length > 0) {
|
||||
const updateCols = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map((k, i) => `"${k}" = $${i + 1}`);
|
||||
const updateValues = Object.keys(masterData)
|
||||
.filter(k => k !== masterKeyColumn && k !== "id")
|
||||
.map(k => masterData[k]);
|
||||
|
||||
if (updateCols.length > 0) {
|
||||
const updatedDateClause = masterExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
|
||||
await client.query(
|
||||
`UPDATE "${masterTable}"
|
||||
SET ${updateCols.join(", ")}${updatedDateClause}
|
||||
WHERE "${masterKeyColumn}" = $${updateValues.length + 1} AND company_code = $${updateValues.length + 2}`,
|
||||
[...updateValues, masterKey, companyCode]
|
||||
);
|
||||
}
|
||||
result.masterUpdated++;
|
||||
} else {
|
||||
const { sql, values } = buildInsertSQL(masterTable, masterData, masterExistingCols);
|
||||
await client.query(sql, values);
|
||||
result.masterInserted++;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 디테일 개별 행 UPSERT 처리
|
||||
for (const row of rows) {
|
||||
const detailData: Record<string, any> = {};
|
||||
|
||||
// FK 컬럼 추가
|
||||
// FK 컬럼에 마스터 키 주입
|
||||
detailData[detailFkColumn] = masterKey;
|
||||
detailData.company_code = companyCode;
|
||||
if (userId) {
|
||||
if (detailExistingCols.has("company_code")) {
|
||||
detailData.company_code = companyCode;
|
||||
}
|
||||
if (userId && detailExistingCols.has("writer")) {
|
||||
detailData.writer = userId;
|
||||
}
|
||||
|
||||
// 디테일 컬럼 데이터 추출
|
||||
// 디테일 컬럼 데이터 추출 (분할 패널 설정 컬럼 기준)
|
||||
for (const col of detailColumns) {
|
||||
if (row[col.name] !== undefined) {
|
||||
detailData[col.name] = row[col.name];
|
||||
}
|
||||
}
|
||||
|
||||
const insertCols = Object.keys(detailData);
|
||||
const insertPlaceholders = insertCols.map((_, i) => `$${i + 1}`);
|
||||
const insertValues = insertCols.map(k => detailData[k]);
|
||||
// 분할 패널에 없지만 엑셀에서 매핑된 디테일 컬럼도 포함
|
||||
// (user_id 등 화면에 표시되지 않지만 NOT NULL인 컬럼 처리)
|
||||
const detailColNames = new Set(detailColumns.map(c => c.name));
|
||||
const skipCols = new Set([
|
||||
detailFkColumn, masterKeyColumn,
|
||||
"company_code", "writer", "created_date", "updated_date", "id",
|
||||
]);
|
||||
for (const key of Object.keys(row)) {
|
||||
if (!detailColNames.has(key) && !skipCols.has(key) && detailExistingCols.has(key) && row[key] !== undefined && row[key] !== null && row[key] !== "") {
|
||||
const isMasterCol = masterColumns.some(mc => mc.name === key);
|
||||
if (!isMasterCol) {
|
||||
detailData[key] = row[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO "${detailTable}" (${insertCols.map(c => `"${c}"`).join(", ")}, created_date)
|
||||
VALUES (${insertPlaceholders.join(", ")}, NOW())`,
|
||||
insertValues
|
||||
);
|
||||
result.detailInserted++;
|
||||
// 디테일 채번 컬럼 자동 생성 (매핑되지 않은 채번 컬럼에 값 주입)
|
||||
for (const [colName, ruleId] of detailNumberingCols) {
|
||||
if (!detailData[colName] || detailData[colName] === "") {
|
||||
const generatedValue = await this.generateNumberWithRule(client, ruleId, companyCode);
|
||||
detailData[colName] = generatedValue;
|
||||
logger.info(`디테일 채번 생성: ${detailTable}.${colName} = ${generatedValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 고유 키 기반 UPSERT: 존재하면 UPDATE, 없으면 INSERT
|
||||
const hasUniqueKey = detailUniqueKeyCols.length > 0;
|
||||
const uniqueKeyValues = hasUniqueKey
|
||||
? detailUniqueKeyCols.map(col => detailData[col])
|
||||
: [];
|
||||
// 고유 키 값이 모두 있어야 매칭 가능 (채번으로 생성된 값도 포함)
|
||||
const canMatch = hasUniqueKey && uniqueKeyValues.every(v => v !== undefined && v !== null && v !== "");
|
||||
|
||||
if (canMatch) {
|
||||
// 기존 행 존재 여부 확인
|
||||
const whereClause = detailUniqueKeyCols
|
||||
.map((col, i) => `"${col}" = $${i + 1}`)
|
||||
.join(" AND ");
|
||||
const companyParam = detailExistingCols.has("company_code")
|
||||
? ` AND company_code = $${detailUniqueKeyCols.length + 1}`
|
||||
: "";
|
||||
const checkParams = detailExistingCols.has("company_code")
|
||||
? [...uniqueKeyValues, companyCode]
|
||||
: uniqueKeyValues;
|
||||
|
||||
const existingRow = await client.query(
|
||||
`SELECT 1 FROM "${detailTable}" WHERE ${whereClause}${companyParam} LIMIT 1`,
|
||||
checkParams
|
||||
);
|
||||
|
||||
if (existingRow.rows.length > 0) {
|
||||
// UPDATE: 고유 키와 시스템 컬럼 제외한 나머지 업데이트
|
||||
const updateExclude = new Set([
|
||||
...detailUniqueKeyCols, "id", "company_code", "created_date",
|
||||
]);
|
||||
const updateKeys = Object.keys(detailData).filter(k => !updateExclude.has(k));
|
||||
|
||||
if (updateKeys.length > 0) {
|
||||
const setClauses = updateKeys.map((k, i) => `"${k}" = $${i + 1}`);
|
||||
const setValues = updateKeys.map(k => detailData[k]);
|
||||
const updatedDateClause = detailExistingCols.has("updated_date") ? `, updated_date = NOW()` : "";
|
||||
|
||||
const whereParams = detailUniqueKeyCols.map((col, i) => `"${col}" = $${setValues.length + i + 1}`);
|
||||
const companyWhere = detailExistingCols.has("company_code")
|
||||
? ` AND company_code = $${setValues.length + detailUniqueKeyCols.length + 1}`
|
||||
: "";
|
||||
const allValues = [
|
||||
...setValues,
|
||||
...uniqueKeyValues,
|
||||
...(detailExistingCols.has("company_code") ? [companyCode] : []),
|
||||
];
|
||||
|
||||
await client.query(
|
||||
`UPDATE "${detailTable}" SET ${setClauses.join(", ")}${updatedDateClause} WHERE ${whereParams.join(" AND ")}${companyWhere}`,
|
||||
allValues
|
||||
);
|
||||
result.detailUpdated = (result.detailUpdated || 0) + 1;
|
||||
logger.info(`디테일 UPDATE: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`);
|
||||
}
|
||||
} else {
|
||||
// INSERT: 새로운 행
|
||||
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols);
|
||||
await client.query(sql, values);
|
||||
result.detailInserted++;
|
||||
logger.info(`디테일 INSERT: ${detailUniqueKeyCols.map((c, i) => `${c}=${uniqueKeyValues[i]}`).join(", ")}`);
|
||||
}
|
||||
} else {
|
||||
// 고유 키가 없거나 값이 없으면 INSERT 전용
|
||||
const { sql, values } = buildInsertSQL(detailTable, detailData, detailExistingCols);
|
||||
await client.query(sql, values);
|
||||
result.detailInserted++;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
result.errors.push(`마스터 키 ${masterKey} 처리 실패: ${error.message}`);
|
||||
logger.error(`마스터 키 ${masterKey} 처리 실패:`, error);
|
||||
result.errors.push(`그룹 처리 실패: ${error.message}`);
|
||||
logger.error(`그룹 처리 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -632,7 +1009,7 @@ class MasterDetailExcelService {
|
|||
masterInserted: result.masterInserted,
|
||||
masterUpdated: result.masterUpdated,
|
||||
detailInserted: result.detailInserted,
|
||||
detailDeleted: result.detailDeleted,
|
||||
detailUpdated: result.detailUpdated,
|
||||
errors: result.errors.length,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ export interface ExecutionContext {
|
|||
buttonContext?: ButtonContext;
|
||||
// 🆕 현재 실행 중인 소스 노드의 dataSourceType (context-data | table-all)
|
||||
currentNodeDataSourceType?: string;
|
||||
// 저장 전 원본 데이터 (after 타이밍에서 DB 기존값 비교용)
|
||||
originalData?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface ButtonContext {
|
||||
|
|
@ -248,8 +250,14 @@ export class NodeFlowExecutionService {
|
|||
contextData.selectedRowsData ||
|
||||
contextData.context?.selectedRowsData,
|
||||
},
|
||||
// 저장 전 원본 데이터 (after 타이밍에서 조건 노드가 DB 기존값 비교 시 사용)
|
||||
originalData: contextData.originalData || null,
|
||||
};
|
||||
|
||||
if (context.originalData) {
|
||||
logger.info(`📦 저장 전 원본 데이터 전달됨 (originalData 필드 수: ${Object.keys(context.originalData).length})`);
|
||||
}
|
||||
|
||||
logger.info(`📦 실행 컨텍스트:`, {
|
||||
dataSourceType: context.dataSourceType,
|
||||
sourceDataCount: context.sourceData?.length || 0,
|
||||
|
|
@ -2830,12 +2838,12 @@ export class NodeFlowExecutionService {
|
|||
inputData: any,
|
||||
context: ExecutionContext
|
||||
): Promise<any> {
|
||||
const { conditions, logic } = node.data;
|
||||
const { conditions, logic, targetLookup } = node.data;
|
||||
|
||||
logger.info(
|
||||
`🔍 조건 노드 실행 - inputData 타입: ${typeof inputData}, 배열 여부: ${Array.isArray(inputData)}, 길이: ${Array.isArray(inputData) ? inputData.length : "N/A"}`
|
||||
);
|
||||
logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}`);
|
||||
logger.info(`🔍 조건 개수: ${conditions?.length || 0}, 로직: ${logic}, 타겟조회: ${targetLookup ? targetLookup.tableName : "없음"}`);
|
||||
|
||||
if (inputData) {
|
||||
console.log(
|
||||
|
|
@ -2865,6 +2873,9 @@ export class NodeFlowExecutionService {
|
|||
|
||||
// 배열의 각 항목에 대해 조건 평가 (EXISTS 조건은 비동기)
|
||||
for (const item of inputData) {
|
||||
// 타겟 테이블 조회 (DB 기존값 비교용)
|
||||
const targetRow = await this.lookupTargetRow(targetLookup, item, context);
|
||||
|
||||
const results: boolean[] = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
|
|
@ -2887,9 +2898,14 @@ export class NodeFlowExecutionService {
|
|||
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||
);
|
||||
} else {
|
||||
// 일반 연산자 처리
|
||||
// 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값)
|
||||
let compareValue = condition.value;
|
||||
if (condition.valueType === "field") {
|
||||
if (condition.valueType === "target" && targetRow) {
|
||||
compareValue = targetRow[condition.value];
|
||||
logger.info(
|
||||
`🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})`
|
||||
);
|
||||
} else if (condition.valueType === "field") {
|
||||
compareValue = item[condition.value];
|
||||
logger.info(
|
||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||
|
|
@ -2931,6 +2947,9 @@ export class NodeFlowExecutionService {
|
|||
}
|
||||
|
||||
// 단일 객체인 경우
|
||||
// 타겟 테이블 조회 (DB 기존값 비교용)
|
||||
const targetRow = await this.lookupTargetRow(targetLookup, inputData, context);
|
||||
|
||||
const results: boolean[] = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
|
|
@ -2953,9 +2972,14 @@ export class NodeFlowExecutionService {
|
|||
`🔍 EXISTS 조건: ${condition.field} (${fieldValue}) ${condition.operator} ${condition.lookupTable}.${condition.lookupField} => ${existsResult}`
|
||||
);
|
||||
} else {
|
||||
// 일반 연산자 처리
|
||||
// 비교값 결정: static(고정값) / field(같은 데이터 내 필드) / target(DB 기존값)
|
||||
let compareValue = condition.value;
|
||||
if (condition.valueType === "field") {
|
||||
if (condition.valueType === "target" && targetRow) {
|
||||
compareValue = targetRow[condition.value];
|
||||
logger.info(
|
||||
`🎯 타겟(DB) 비교: ${condition.field} (${fieldValue}) vs DB.${condition.value} (${compareValue})`
|
||||
);
|
||||
} else if (condition.valueType === "field") {
|
||||
compareValue = inputData[condition.value];
|
||||
logger.info(
|
||||
`🔄 필드 참조 비교: ${condition.field} (${fieldValue}) vs ${condition.value} (${compareValue})`
|
||||
|
|
@ -2990,6 +3014,71 @@ export class NodeFlowExecutionService {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 노드의 타겟 테이블 조회 (DB 기존값 비교용)
|
||||
* targetLookup 설정이 있을 때, 소스 데이터의 키값으로 DB에서 기존 레코드를 조회
|
||||
*/
|
||||
private static async lookupTargetRow(
|
||||
targetLookup: any,
|
||||
sourceRow: any,
|
||||
context: ExecutionContext
|
||||
): Promise<any | null> {
|
||||
if (!targetLookup?.tableName || !targetLookup?.lookupKeys?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// 저장 전 원본 데이터가 있으면 DB 조회 대신 원본 데이터 사용
|
||||
// (after 타이밍에서는 DB가 이미 업데이트되어 있으므로 원본 데이터가 필요)
|
||||
if (context.originalData && Object.keys(context.originalData).length > 0) {
|
||||
logger.info(`🎯 조건 노드: 저장 전 원본 데이터(originalData) 사용 (DB 조회 스킵)`);
|
||||
logger.info(`🎯 originalData 필드: ${Object.keys(context.originalData).join(", ")}`);
|
||||
return context.originalData;
|
||||
}
|
||||
|
||||
const whereConditions = targetLookup.lookupKeys
|
||||
.map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`)
|
||||
.join(" AND ");
|
||||
|
||||
const lookupValues = targetLookup.lookupKeys.map(
|
||||
(key: any) => sourceRow[key.sourceField]
|
||||
);
|
||||
|
||||
// 키값이 비어있으면 조회 불필요
|
||||
if (lookupValues.some((v: any) => v === null || v === undefined || v === "")) {
|
||||
logger.info(`⚠️ 조건 노드 타겟 조회: 키값이 비어있어 스킵`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// company_code 필터링 (멀티테넌시)
|
||||
const companyCode = context.buttonContext?.companyCode || sourceRow.company_code;
|
||||
let sql = `SELECT * FROM "${targetLookup.tableName}" WHERE ${whereConditions}`;
|
||||
const params = [...lookupValues];
|
||||
|
||||
if (companyCode && companyCode !== "*") {
|
||||
sql += ` AND company_code = $${params.length + 1}`;
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
sql += " LIMIT 1";
|
||||
|
||||
logger.info(`🎯 조건 노드 타겟 조회: ${targetLookup.tableName}, 조건: ${whereConditions}, 값: ${JSON.stringify(lookupValues)}`);
|
||||
|
||||
const targetRow = await queryOne(sql, params);
|
||||
|
||||
if (targetRow) {
|
||||
logger.info(`🎯 타겟 데이터 조회 성공`);
|
||||
} else {
|
||||
logger.info(`🎯 타겟 데이터 없음 (신규 레코드)`);
|
||||
}
|
||||
|
||||
return targetRow;
|
||||
} catch (error: any) {
|
||||
logger.warn(`⚠️ 조건 노드 타겟 조회 실패: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EXISTS_IN / NOT_EXISTS_IN 조건 평가
|
||||
* 다른 테이블에 값이 존재하는지 확인
|
||||
|
|
|
|||
|
|
@ -1739,7 +1739,7 @@ export class ScreenManagementService {
|
|||
|
||||
// V2 레이아웃이 있으면 V2 형식으로 반환
|
||||
if (v2Layout && v2Layout.layout_data) {
|
||||
console.log(`V2 레이아웃 발견, V2 형식으로 반환`);
|
||||
|
||||
const layoutData = v2Layout.layout_data;
|
||||
|
||||
// URL에서 컴포넌트 타입 추출하는 헬퍼 함수
|
||||
|
|
@ -1799,7 +1799,7 @@ export class ScreenManagementService {
|
|||
};
|
||||
}
|
||||
|
||||
console.log(`V2 레이아웃 없음, V1 테이블 조회`);
|
||||
|
||||
|
||||
const layouts = await query<any>(
|
||||
`SELECT * FROM screen_layouts
|
||||
|
|
@ -4254,7 +4254,7 @@ export class ScreenManagementService {
|
|||
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
||||
);
|
||||
|
||||
console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`);
|
||||
|
||||
} catch (error) {
|
||||
console.error("V2 레이아웃 복사 중 오류:", error);
|
||||
// 레이아웃 복사 실패해도 화면 생성은 유지
|
||||
|
|
@ -5045,8 +5045,7 @@ export class ScreenManagementService {
|
|||
companyCode: string,
|
||||
userType?: string,
|
||||
): Promise<any | null> {
|
||||
console.log(`=== V2 레이아웃 로드 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`);
|
||||
|
||||
|
||||
// SUPER_ADMIN 여부 확인
|
||||
const isSuperAdmin = userType === "SUPER_ADMIN";
|
||||
|
|
@ -5136,13 +5135,11 @@ export class ScreenManagementService {
|
|||
}
|
||||
|
||||
if (!layout) {
|
||||
console.log(`V2 레이아웃 없음: screen_id=${screenId}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
|
||||
);
|
||||
|
||||
return layout.layout_data;
|
||||
}
|
||||
|
||||
|
|
@ -5158,11 +5155,11 @@ export class ScreenManagementService {
|
|||
): Promise<void> {
|
||||
const layerId = layoutData.layerId || 1;
|
||||
const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`);
|
||||
// conditionConfig가 명시적으로 전달되었는지 확인 (undefined = 미전달, null/object = 명시적 전달)
|
||||
const hasConditionConfig = 'conditionConfig' in layoutData;
|
||||
const conditionConfig = layoutData.conditionConfig || null;
|
||||
|
||||
console.log(`=== V2 레이아웃 저장 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`);
|
||||
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
|
||||
|
||||
|
||||
// 권한 확인
|
||||
const screens = await query<{ company_code: string | null }>(
|
||||
|
|
@ -5187,16 +5184,27 @@ export class ScreenManagementService {
|
|||
...pureLayoutData,
|
||||
};
|
||||
|
||||
// UPSERT (레이어별 저장)
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
|
||||
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
|
||||
);
|
||||
if (hasConditionConfig) {
|
||||
// conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
|
||||
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
|
||||
);
|
||||
} else {
|
||||
// conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`,
|
||||
[screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId})`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -5349,6 +5357,172 @@ export class ScreenManagementService {
|
|||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 조건부 영역(Zone) 관리
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 화면의 조건부 영역(Zone) 목록 조회
|
||||
*/
|
||||
async getScreenZones(screenId: number, companyCode: string): Promise<any[]> {
|
||||
let zones;
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사 Zone 조회 가능
|
||||
zones = await query<any>(
|
||||
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1 ORDER BY zone_id`,
|
||||
[screenId],
|
||||
);
|
||||
} else {
|
||||
// 일반 회사: 자사 Zone + 공통(*) Zone 조회
|
||||
zones = await query<any>(
|
||||
`SELECT * FROM screen_conditional_zones
|
||||
WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY zone_id`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
}
|
||||
return zones;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 영역(Zone) 생성
|
||||
*/
|
||||
async createZone(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
zoneData: {
|
||||
zone_name?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
trigger_component_id?: string;
|
||||
trigger_operator?: string;
|
||||
},
|
||||
): Promise<any> {
|
||||
const result = await queryOne<any>(
|
||||
`INSERT INTO screen_conditional_zones
|
||||
(screen_id, company_code, zone_name, x, y, width, height, trigger_component_id, trigger_operator)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[
|
||||
screenId,
|
||||
companyCode,
|
||||
zoneData.zone_name || '조건부 영역',
|
||||
zoneData.x,
|
||||
zoneData.y,
|
||||
zoneData.width,
|
||||
zoneData.height,
|
||||
zoneData.trigger_component_id || null,
|
||||
zoneData.trigger_operator || 'eq',
|
||||
],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 영역(Zone) 업데이트 (위치/크기/트리거)
|
||||
*/
|
||||
async updateZone(
|
||||
zoneId: number,
|
||||
companyCode: string,
|
||||
updates: {
|
||||
zone_name?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
trigger_component_id?: string;
|
||||
trigger_operator?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const setClauses: string[] = ['updated_at = NOW()'];
|
||||
const params: any[] = [zoneId, companyCode];
|
||||
let paramIdx = 3;
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== undefined) {
|
||||
setClauses.push(`${key} = $${paramIdx}`);
|
||||
params.push(value);
|
||||
paramIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE screen_conditional_zones SET ${setClauses.join(', ')}
|
||||
WHERE zone_id = $1 AND company_code = $2`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 영역(Zone) 삭제 + 소속 레이어들의 condition_config 정리
|
||||
*/
|
||||
async deleteZone(zoneId: number, companyCode: string): Promise<void> {
|
||||
// Zone에 소속된 레이어들의 condition_config에서 zone_id 제거
|
||||
await query(
|
||||
`UPDATE screen_layouts_v2 SET condition_config = NULL, updated_at = NOW()
|
||||
WHERE company_code = $1 AND condition_config->>'zone_id' = $2::text`,
|
||||
[companyCode, String(zoneId)],
|
||||
);
|
||||
|
||||
await query(
|
||||
`DELETE FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
|
||||
[zoneId, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone에 레이어 추가 (빈 레이아웃으로 새 레이어 생성 + zone_id 할당)
|
||||
*/
|
||||
async addLayerToZone(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
zoneId: number,
|
||||
conditionValue: string,
|
||||
layerName?: string,
|
||||
): Promise<{ layerId: number }> {
|
||||
// 다음 layer_id 계산
|
||||
const maxResult = await queryOne<{ max_id: number }>(
|
||||
`SELECT COALESCE(MAX(layer_id), 1) as max_id FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
const newLayerId = (maxResult?.max_id || 1) + 1;
|
||||
|
||||
// Zone 정보로 캔버스 크기 결정 (company_code 필터링 필수)
|
||||
const zone = await queryOne<any>(
|
||||
`SELECT * FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
|
||||
[zoneId, companyCode],
|
||||
);
|
||||
|
||||
const layoutData = {
|
||||
version: "2.1",
|
||||
components: [],
|
||||
screenResolution: zone
|
||||
? { width: zone.width, height: zone.height }
|
||||
: { width: 800, height: 200 },
|
||||
};
|
||||
|
||||
const conditionConfig = {
|
||||
zone_id: zoneId,
|
||||
condition_value: conditionValue,
|
||||
};
|
||||
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, layer_id, layer_name, condition_config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE
|
||||
SET layout_data = EXCLUDED.layout_data,
|
||||
layer_name = EXCLUDED.layer_name,
|
||||
condition_config = EXCLUDED.condition_config,
|
||||
updated_at = NOW()`,
|
||||
[screenId, companyCode, JSON.stringify(layoutData), newLayerId, layerName || `레이어 ${newLayerId}`, JSON.stringify(conditionConfig)],
|
||||
);
|
||||
|
||||
return { layerId: newLayerId };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// POP 레이아웃 관리 (모바일/태블릿)
|
||||
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)
|
||||
|
|
|
|||
|
|
@ -1371,39 +1371,66 @@ class TableCategoryValueService {
|
|||
|
||||
const pool = getPool();
|
||||
|
||||
// 동적으로 파라미터 플레이스홀더 생성
|
||||
const placeholders = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
|
||||
const n = valueCodes.length;
|
||||
|
||||
// 첫 번째 쿼리용 플레이스홀더: $1 ~ $n
|
||||
const placeholders1 = valueCodes.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 카테고리 값 조회
|
||||
// 최고 관리자: 두 테이블 모두에서 조회 (UNION으로 병합)
|
||||
// 두 번째 쿼리용 플레이스홀더: $n+1 ~ $2n
|
||||
const placeholders2 = valueCodes.map((_, i) => `$${n + i + 1}`).join(", ");
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders})
|
||||
AND is_active = true
|
||||
SELECT value_code, value_label FROM (
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
AND is_active = true
|
||||
UNION ALL
|
||||
SELECT value_code, value_label
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders2})
|
||||
AND is_active = true
|
||||
) combined
|
||||
`;
|
||||
params = valueCodes;
|
||||
params = [...valueCodes, ...valueCodes];
|
||||
} else {
|
||||
// 일반 회사: 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
// 일반 회사: 두 테이블에서 자신의 카테고리 값 + 공통 카테고리 값 조회
|
||||
// 첫 번째: $1~$n (valueCodes), $n+1 (companyCode)
|
||||
// 두 번째: $n+2~$2n+1 (valueCodes), $2n+2 (companyCode)
|
||||
const companyIdx1 = n + 1;
|
||||
const placeholders2 = valueCodes.map((_, i) => `$${n + 1 + i + 1}`).join(", ");
|
||||
const companyIdx2 = 2 * n + 2;
|
||||
|
||||
query = `
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders})
|
||||
AND is_active = true
|
||||
AND (company_code = $${valueCodes.length + 1} OR company_code = '*')
|
||||
SELECT value_code, value_label FROM (
|
||||
SELECT value_code, value_label
|
||||
FROM table_column_category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
AND is_active = true
|
||||
AND (company_code = $${companyIdx1} OR company_code = '*')
|
||||
UNION ALL
|
||||
SELECT value_code, value_label
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders2})
|
||||
AND is_active = true
|
||||
AND (company_code = $${companyIdx2} OR company_code = '*')
|
||||
) combined
|
||||
`;
|
||||
params = [...valueCodes, companyCode];
|
||||
params = [...valueCodes, companyCode, ...valueCodes, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
// { [code]: label } 형태로 변환
|
||||
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
|
||||
const labels: Record<string, string> = {};
|
||||
for (const row of result.rows) {
|
||||
labels[row.value_code] = row.value_label;
|
||||
if (!labels[row.value_code]) {
|
||||
labels[row.value_code] = row.value_label;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`카테고리 라벨 ${Object.keys(labels).length}개 조회 완료`, { companyCode });
|
||||
|
|
|
|||
|
|
@ -2875,10 +2875,11 @@ export class TableManagementService {
|
|||
};
|
||||
}
|
||||
|
||||
// Entity 조인 설정 감지 (화면별 엔티티 설정 전달)
|
||||
// Entity 조인 설정 감지 (화면별 엔티티 설정 + 회사코드 전달)
|
||||
let joinConfigs = await entityJoinService.detectEntityJoins(
|
||||
tableName,
|
||||
options.screenEntityConfigs
|
||||
options.screenEntityConfigs,
|
||||
options.companyCode
|
||||
);
|
||||
|
||||
logger.info(
|
||||
|
|
@ -2978,31 +2979,49 @@ export class TableManagementService {
|
|||
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
|
||||
}
|
||||
|
||||
// 추가 조인 컬럼 설정 생성
|
||||
const additionalJoinConfig: EntityJoinConfig = {
|
||||
sourceTable: tableName,
|
||||
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
|
||||
referenceTable:
|
||||
(additionalColumn as any).referenceTable ||
|
||||
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
|
||||
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
|
||||
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
|
||||
displayColumn: actualColumnName, // 하위 호환성
|
||||
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
|
||||
separator: " - ", // 기본 구분자
|
||||
};
|
||||
|
||||
joinConfigs.push(additionalJoinConfig);
|
||||
logger.info(
|
||||
`✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}`
|
||||
// 🆕 같은 sourceColumn + referenceTable 조합의 기존 config가 있으면 displayColumns에 병합
|
||||
const existingConfig = joinConfigs.find(
|
||||
(config) =>
|
||||
config.sourceColumn === sourceColumn &&
|
||||
config.referenceTable === ((additionalColumn as any).referenceTable || baseJoinConfig.referenceTable)
|
||||
);
|
||||
logger.info(`🔍 추가된 조인 설정 상세:`, {
|
||||
sourceTable: additionalJoinConfig.sourceTable,
|
||||
sourceColumn: additionalJoinConfig.sourceColumn,
|
||||
referenceTable: additionalJoinConfig.referenceTable,
|
||||
displayColumns: additionalJoinConfig.displayColumns,
|
||||
aliasColumn: additionalJoinConfig.aliasColumn,
|
||||
});
|
||||
|
||||
if (existingConfig) {
|
||||
// 기존 config에 display column 추가 (중복 방지)
|
||||
if (!existingConfig.displayColumns?.includes(actualColumnName)) {
|
||||
existingConfig.displayColumns = existingConfig.displayColumns || [];
|
||||
existingConfig.displayColumns.push(actualColumnName);
|
||||
logger.info(
|
||||
`🔄 기존 조인 설정에 컬럼 병합: ${existingConfig.aliasColumn} ← ${actualColumnName} (총 ${existingConfig.displayColumns.length}개)`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 새 조인 설정 생성
|
||||
const additionalJoinConfig: EntityJoinConfig = {
|
||||
sourceTable: tableName,
|
||||
sourceColumn: sourceColumn, // 실제 소스 컬럼 (partner_id)
|
||||
referenceTable:
|
||||
(additionalColumn as any).referenceTable ||
|
||||
baseJoinConfig.referenceTable, // 참조 테이블 (customer_mng)
|
||||
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (customer_code)
|
||||
displayColumns: [actualColumnName], // 표시할 컬럼들 (customer_name)
|
||||
displayColumn: actualColumnName, // 하위 호환성
|
||||
aliasColumn: correctedJoinAlias, // 수정된 별칭 (partner_id_customer_name)
|
||||
separator: " - ", // 기본 구분자
|
||||
};
|
||||
|
||||
joinConfigs.push(additionalJoinConfig);
|
||||
logger.info(
|
||||
`✅ 추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}`
|
||||
);
|
||||
logger.info(`🔍 추가된 조인 설정 상세:`, {
|
||||
sourceTable: additionalJoinConfig.sourceTable,
|
||||
sourceColumn: additionalJoinConfig.sourceColumn,
|
||||
referenceTable: additionalJoinConfig.referenceTable,
|
||||
displayColumns: additionalJoinConfig.displayColumns,
|
||||
aliasColumn: additionalJoinConfig.aliasColumn,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3258,6 +3277,28 @@ export class TableManagementService {
|
|||
startTime: number
|
||||
): Promise<EntityJoinResponse> {
|
||||
try {
|
||||
// 🆕 참조 테이블별 전체 컬럼 목록 미리 조회
|
||||
const referenceTableColumns = new Map<string, string[]>();
|
||||
const uniqueRefTables = new Set(
|
||||
joinConfigs
|
||||
.filter((c) => c.referenceTable !== "table_column_category_values") // 카테고리는 제외
|
||||
.map((c) => `${c.referenceTable}:${c.sourceColumn}`)
|
||||
);
|
||||
|
||||
for (const key of uniqueRefTables) {
|
||||
const refTable = key.split(":")[0];
|
||||
if (!referenceTableColumns.has(key)) {
|
||||
const cols = await query<{ column_name: string }>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND table_schema = 'public'
|
||||
ORDER BY ordinal_position`,
|
||||
[refTable]
|
||||
);
|
||||
referenceTableColumns.set(key, cols.map((c) => c.column_name));
|
||||
logger.info(`🔍 참조 테이블 컬럼 조회: ${refTable} → ${cols.length}개`);
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터 조회 쿼리
|
||||
const dataQuery = entityJoinService.buildJoinQuery(
|
||||
tableName,
|
||||
|
|
@ -3266,7 +3307,9 @@ export class TableManagementService {
|
|||
whereClause,
|
||||
orderBy,
|
||||
limit,
|
||||
offset
|
||||
offset,
|
||||
undefined,
|
||||
referenceTableColumns // 🆕 참조 테이블 전체 컬럼 전달
|
||||
).query;
|
||||
|
||||
// 카운트 쿼리
|
||||
|
|
@ -3767,12 +3810,12 @@ export class TableManagementService {
|
|||
reference_table: string;
|
||||
reference_column: string;
|
||||
}>(
|
||||
`SELECT column_name, reference_table, reference_column
|
||||
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type = 'entity'
|
||||
AND reference_table = $2
|
||||
AND company_code = '*'
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END
|
||||
LIMIT 1`,
|
||||
[tableName, refTable]
|
||||
);
|
||||
|
|
@ -3883,7 +3926,7 @@ export class TableManagementService {
|
|||
/**
|
||||
* 참조 테이블의 표시 컬럼 목록 조회
|
||||
*/
|
||||
async getReferenceTableColumns(tableName: string): Promise<
|
||||
async getReferenceTableColumns(tableName: string, companyCode?: string): Promise<
|
||||
Array<{
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
|
|
@ -3891,7 +3934,7 @@ export class TableManagementService {
|
|||
inputType?: string;
|
||||
}>
|
||||
> {
|
||||
return await entityJoinService.getReferenceTableColumns(tableName);
|
||||
return await entityJoinService.getReferenceTableColumns(tableName, companyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -5005,14 +5048,14 @@ export class TableManagementService {
|
|||
input_type: string;
|
||||
display_column: string | null;
|
||||
}>(
|
||||
`SELECT column_name, reference_column, input_type, display_column
|
||||
`SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type IN ('entity', 'category')
|
||||
AND reference_table = $2
|
||||
AND reference_column IS NOT NULL
|
||||
AND reference_column != ''
|
||||
AND company_code = '*'`,
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
[rightTable, leftTable]
|
||||
);
|
||||
|
||||
|
|
@ -5034,14 +5077,14 @@ export class TableManagementService {
|
|||
input_type: string;
|
||||
display_column: string | null;
|
||||
}>(
|
||||
`SELECT column_name, reference_column, input_type, display_column
|
||||
`SELECT DISTINCT ON (column_name) column_name, reference_column, input_type, display_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type IN ('entity', 'category')
|
||||
AND reference_table = $2
|
||||
AND reference_column IS NOT NULL
|
||||
AND reference_column != ''
|
||||
AND company_code = '*'`,
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
[leftTable, rightTable]
|
||||
);
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,955 @@
|
|||
# WACE ERP 시스템 전체 워크플로우 문서
|
||||
|
||||
> 작성일: 2026-02-06
|
||||
> 분석 방법: Multi-Agent System (Backend + Frontend + DB 전문가 병렬 분석)
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [시스템 개요](#1-시스템-개요)
|
||||
2. [기술 스택](#2-기술-스택)
|
||||
3. [전체 아키텍처](#3-전체-아키텍처)
|
||||
4. [백엔드 아키텍처](#4-백엔드-아키텍처)
|
||||
5. [프론트엔드 아키텍처](#5-프론트엔드-아키텍처)
|
||||
6. [데이터베이스 구조](#6-데이터베이스-구조)
|
||||
7. [인증/인가 워크플로우](#7-인증인가-워크플로우)
|
||||
8. [화면 디자이너 워크플로우](#8-화면-디자이너-워크플로우)
|
||||
9. [사용자 업무 워크플로우](#9-사용자-업무-워크플로우)
|
||||
10. [플로우 엔진 워크플로우](#10-플로우-엔진-워크플로우)
|
||||
11. [데이터플로우 시스템](#11-데이터플로우-시스템)
|
||||
12. [대시보드 시스템](#12-대시보드-시스템)
|
||||
13. [배치/스케줄 시스템](#13-배치스케줄-시스템)
|
||||
14. [멀티테넌시 아키텍처](#14-멀티테넌시-아키텍처)
|
||||
15. [외부 연동](#15-외부-연동)
|
||||
16. [배포 환경](#16-배포-환경)
|
||||
|
||||
---
|
||||
|
||||
## 1. 시스템 개요
|
||||
|
||||
WACE는 **로우코드(Low-Code) ERP 플랫폼**이다. 관리자가 코드 없이 드래그앤드롭으로 업무 화면을 설계하면, 사용자는 해당 화면으로 바로 업무를 처리할 수 있는 구조다.
|
||||
|
||||
### 핵심 컨셉
|
||||
|
||||
```
|
||||
관리자 → 화면 디자이너로 화면 설계 → 메뉴에 연결
|
||||
↓
|
||||
사용자 → 메뉴 클릭 → 화면 자동 렌더링 → 업무 수행
|
||||
```
|
||||
|
||||
### 주요 특징
|
||||
|
||||
- **드래그앤드롭 화면 디자이너**: 코드 없이 UI 구성
|
||||
- **동적 컴포넌트 시스템**: V2 통합 컴포넌트 10종으로 모든 UI 표현
|
||||
- **플로우 엔진**: 워크플로우(승인, 이동 등) 자동화
|
||||
- **데이터플로우**: 비즈니스 로직을 비주얼 다이어그램으로 설계
|
||||
- **멀티테넌시**: 회사별 완벽한 데이터 격리
|
||||
- **다국어 지원**: KR/EN/CN 다국어 라벨 관리
|
||||
|
||||
---
|
||||
|
||||
## 2. 기술 스택
|
||||
|
||||
| 영역 | 기술 | 비고 |
|
||||
|------|------|------|
|
||||
| **Frontend** | Next.js 15 (App Router) | React 19, TypeScript |
|
||||
| **UI 라이브러리** | shadcn/ui + Radix UI | Tailwind CSS 4 |
|
||||
| **상태 관리** | React Context + Zustand | React Query (서버 상태) |
|
||||
| **Backend** | Node.js + Express | TypeScript |
|
||||
| **Database** | PostgreSQL | Raw Query (ORM 미사용) |
|
||||
| **인증** | JWT | 자동 갱신, 세션 관리 |
|
||||
| **빌드/배포** | Docker | dev/prod 분리 |
|
||||
| **포트** | FE: 9771(dev)/5555(prod) | BE: 8080 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 전체 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 사용자 브라우저 │
|
||||
│ Next.js App (React 19 + shadcn/ui + Tailwind CSS) │
|
||||
│ ├── 인증: JWT + Cookie + localStorage │
|
||||
│ ├── 상태: Context + Zustand + React Query │
|
||||
│ └── API: Axios Client (lib/api/) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ HTTP/JSON (JWT Bearer Token)
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Express Backend (Node.js) │
|
||||
│ ├── Middleware: Helmet → CORS → RateLimit → Auth → Permission │
|
||||
│ ├── Routes: 60+ 모듈 │
|
||||
│ ├── Controllers: 69개 │
|
||||
│ ├── Services: 87개 │
|
||||
│ └── Database: pg Pool (Raw Query) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ TCP/SQL
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL Database │
|
||||
│ ├── 시스템 테이블: 사용자, 회사, 메뉴, 권한, 화면 │
|
||||
│ ├── 메타데이터: 테이블/컬럼 정의, 코드, 카테고리 │
|
||||
│ ├── 비즈니스: 동적 생성 테이블 (화면별) │
|
||||
│ └── 멀티테넌시: 모든 테이블에 company_code │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 아키텍처
|
||||
|
||||
### 4.1 디렉토리 구조
|
||||
|
||||
```
|
||||
backend-node/src/
|
||||
├── app.ts # Express 앱 진입점
|
||||
├── config/ # 환경설정, Multer
|
||||
├── controllers/ # 69개 컨트롤러
|
||||
├── services/ # 87개 서비스
|
||||
├── routes/ # 60+ 라우트 모듈
|
||||
├── middleware/ # 인증, 권한, 에러 처리
|
||||
│ ├── authMiddleware.ts # JWT 인증
|
||||
│ ├── permissionMiddleware.ts # 3단계 권한 체크
|
||||
│ ├── superAdminMiddleware.ts # 슈퍼관리자 전용
|
||||
│ └── errorHandler.ts # 전역 에러 처리
|
||||
├── database/ # DB 연결, 커넥터 팩토리
|
||||
│ ├── db.ts # PostgreSQL Pool
|
||||
│ ├── DatabaseConnectorFactory.ts
|
||||
│ ├── PostgreSQLConnector.ts
|
||||
│ ├── MySQLConnector.ts
|
||||
│ └── MariaDBConnector.ts
|
||||
├── types/ # TypeScript 타입 (26개)
|
||||
└── utils/ # 유틸리티 (16개)
|
||||
```
|
||||
|
||||
### 4.2 미들웨어 스택 (실행 순서)
|
||||
|
||||
```
|
||||
요청 → Helmet (보안 헤더)
|
||||
→ Compression (응답 압축)
|
||||
→ Body Parser (JSON/URLEncoded, 10MB)
|
||||
→ CORS (교차 출처 허용)
|
||||
→ Rate Limiter (10,000 req/min)
|
||||
→ Token Refresh (자동 갱신)
|
||||
→ Route Handlers (비즈니스 로직)
|
||||
→ Error Handler (전역 에러 처리)
|
||||
```
|
||||
|
||||
### 4.3 API 라우트 도메인별 분류
|
||||
|
||||
#### 인증/사용자 관리
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/auth` | 로그인, 로그아웃, 토큰 갱신, 회사 전환 |
|
||||
| `/api/admin/users` | 사용자 CRUD, 비밀번호 초기화, 상태 변경 |
|
||||
| `/api/company-management` | 회사 CRUD |
|
||||
| `/api/departments` | 부서 관리 |
|
||||
| `/api/roles` | 권한 그룹 관리 |
|
||||
|
||||
#### 화면/메뉴 관리
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/screen-management` | 화면 정의 CRUD, 그룹, 파일, 임베딩 |
|
||||
| `/api/admin/menus` | 메뉴 트리 CRUD, 화면 할당 |
|
||||
| `/api/table-management` | 테이블 CRUD, 엔티티 조인, 카테고리 |
|
||||
| `/api/common-codes` | 공통 코드/카테고리 관리 |
|
||||
| `/api/multilang` | 다국어 키/번역 관리 |
|
||||
|
||||
#### 데이터 관리
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/data` | 동적 테이블 CRUD, 조인 쿼리 |
|
||||
| `/api/data/:tableName` | 특정 테이블 데이터 조회 |
|
||||
| `/api/data/join` | 조인 쿼리 실행 |
|
||||
| `/api/dynamic-form` | 동적 폼 데이터 저장 |
|
||||
| `/api/entity-search` | 엔티티 검색 |
|
||||
| `/api/entity-reference` | 엔티티 참조 |
|
||||
| `/api/numbering-rules` | 채번 규칙 관리 |
|
||||
| `/api/cascading-*` | 연쇄 드롭다운 관계 |
|
||||
|
||||
#### 자동화
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/flow` | 플로우 정의/단계/연결/실행 |
|
||||
| `/api/dataflow` | 데이터플로우 다이어그램/실행 |
|
||||
| `/api/batch-configs` | 배치 작업 설정 |
|
||||
| `/api/batch-management` | 배치 작업 관리 |
|
||||
| `/api/batch-execution-logs` | 배치 실행 로그 |
|
||||
|
||||
#### 대시보드/리포트
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/dashboards` | 대시보드 CRUD, 쿼리 실행 |
|
||||
| `/api/reports` | 리포트 생성 |
|
||||
|
||||
#### 외부 연동
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/external-db-connections` | 외부 DB 연결 (PostgreSQL, MySQL, MariaDB, MSSQL, Oracle) |
|
||||
| `/api/external-rest-api-connections` | 외부 REST API 연결 |
|
||||
| `/api/mail` | 메일 발송/수신/템플릿 |
|
||||
| `/api/tax-invoice` | 세금계산서 |
|
||||
|
||||
#### 특수 도메인
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/delivery` | 배송/화물 관리 |
|
||||
| `/api/risk-alerts` | 위험 알림 |
|
||||
| `/api/todos` | 할일 관리 |
|
||||
| `/api/bookings` | 예약 관리 |
|
||||
| `/api/digital-twin` | 디지털 트윈 (야드 모니터링) |
|
||||
| `/api/schedule` | 스케줄 자동 생성 |
|
||||
| `/api/vehicle` | 차량 운행 |
|
||||
| `/api/driver` | 운전자 관리 |
|
||||
| `/api/files` | 파일 업로드/다운로드 |
|
||||
| `/api/ddl` | DDL 실행 (슈퍼관리자 전용) |
|
||||
|
||||
### 4.4 서비스 레이어 패턴
|
||||
|
||||
```typescript
|
||||
// 표준 서비스 패턴
|
||||
class ExampleService {
|
||||
// 목록 조회 (멀티테넌시 적용)
|
||||
async findAll(companyCode: string, filters?: any) {
|
||||
if (companyCode === "*") {
|
||||
// 슈퍼관리자: 전체 데이터
|
||||
return await db.query("SELECT * FROM table ORDER BY company_code");
|
||||
} else {
|
||||
// 일반 사용자: 자기 회사 데이터만
|
||||
return await db.query(
|
||||
"SELECT * FROM table WHERE company_code = $1",
|
||||
[companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 에러 처리 전략
|
||||
|
||||
```typescript
|
||||
// 전역 에러 핸들러 (errorHandler.ts)
|
||||
- PostgreSQL 에러: 중복키(23505), 외래키(23503), 널 제약(23502) 등
|
||||
- JWT 에러: 만료, 유효하지 않은 토큰
|
||||
- 일반 에러: 500 Internal Server Error
|
||||
- 개발 환경: 상세 에러 스택 포함
|
||||
- 운영 환경: 일반적인 에러 메시지만 반환
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 아키텍처
|
||||
|
||||
### 5.1 디렉토리 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── (auth)/ # 인증 (로그인)
|
||||
│ ├── (main)/ # 메인 앱 (인증 필요)
|
||||
│ ├── (pop)/ # 모바일/팝업
|
||||
│ └── (admin)/ # 특수 관리자
|
||||
├── components/ # React 컴포넌트
|
||||
│ ├── screen/ # 화면 디자이너 & 뷰어
|
||||
│ ├── admin/ # 관리 기능
|
||||
│ ├── dashboard/ # 대시보드 위젯
|
||||
│ ├── dataflow/ # 데이터플로우 디자이너
|
||||
│ ├── v2/ # V2 통합 컴포넌트
|
||||
│ ├── ui/ # shadcn/ui 기본 컴포넌트
|
||||
│ └── report/ # 리포트 디자이너
|
||||
├── lib/
|
||||
│ ├── api/ # API 클라이언트 (57개 모듈)
|
||||
│ ├── registry/ # 컴포넌트 레지스트리 (482개)
|
||||
│ ├── utils/ # 유틸리티
|
||||
│ └── v2-core/ # V2 코어 로직
|
||||
├── contexts/ # React Context (인증, 메뉴, 화면 등)
|
||||
├── hooks/ # Custom Hooks
|
||||
├── stores/ # Zustand 상태관리
|
||||
└── middleware.ts # Next.js 인증 미들웨어
|
||||
```
|
||||
|
||||
### 5.2 페이지 라우팅 구조
|
||||
|
||||
```
|
||||
/login → 로그인
|
||||
/main → 메인 대시보드
|
||||
/screens/[screenId] → 동적 화면 뷰어 (사용자)
|
||||
|
||||
/admin/screenMng/screenMngList → 화면 관리
|
||||
/admin/screenMng/dashboardList → 대시보드 관리
|
||||
/admin/screenMng/reportList → 리포트 관리
|
||||
/admin/systemMng/tableMngList → 테이블 관리
|
||||
/admin/systemMng/commonCodeList → 공통코드 관리
|
||||
/admin/systemMng/dataflow → 데이터플로우 관리
|
||||
/admin/systemMng/i18nList → 다국어 관리
|
||||
/admin/userMng/userMngList → 사용자 관리
|
||||
/admin/userMng/companyList → 회사 관리
|
||||
/admin/userMng/rolesList → 권한 관리
|
||||
/admin/automaticMng/flowMgmtList → 플로우 관리
|
||||
/admin/automaticMng/batchmngList → 배치 관리
|
||||
/admin/automaticMng/mail/* → 메일 시스템
|
||||
/admin/menu → 메뉴 관리
|
||||
|
||||
/dashboard/[dashboardId] → 대시보드 뷰어
|
||||
/pop/work → 모바일 작업 화면
|
||||
```
|
||||
|
||||
### 5.3 V2 통합 컴포넌트 시스템
|
||||
|
||||
**"하나의 컴포넌트, 여러 모드"** 철학으로 설계된 10개 통합 컴포넌트:
|
||||
|
||||
| 컴포넌트 | 모드 | 역할 |
|
||||
|----------|------|------|
|
||||
| **V2Input** | text, number, password, slider, color | 텍스트/숫자 입력 |
|
||||
| **V2Select** | dropdown, radio, checkbox, tag, toggle | 선택 입력 |
|
||||
| **V2Date** | date, datetime, time, range | 날짜/시간 입력 |
|
||||
| **V2List** | table, card, kanban, list | 데이터 목록 표시 |
|
||||
| **V2Layout** | grid, split-panel, flex | 레이아웃 구성 |
|
||||
| **V2Group** | tab, accordion, section, modal | 그룹 컨테이너 |
|
||||
| **V2Media** | image, video, audio, file | 미디어 표시 |
|
||||
| **V2Biz** | flow, rack, numbering-rule | 비즈니스 로직 |
|
||||
| **V2Hierarchy** | tree, org-chart, BOM, cascading | 계층 구조 |
|
||||
| **V2Repeater** | inline-table, modal, button | 반복 데이터 |
|
||||
|
||||
### 5.4 API 클라이언트 규칙
|
||||
|
||||
```typescript
|
||||
// 절대 금지: fetch 직접 사용
|
||||
const res = await fetch('/api/flow/definitions'); // ❌
|
||||
|
||||
// 반드시 사용: lib/api/ 클라이언트
|
||||
import { getFlowDefinitions } from '@/lib/api/flow';
|
||||
const res = await getFlowDefinitions(); // ✅
|
||||
```
|
||||
|
||||
환경별 URL 자동 처리:
|
||||
| 환경 | 프론트엔드 | 백엔드 API |
|
||||
|------|-----------|-----------|
|
||||
| 로컬 개발 | localhost:9771 | localhost:8080/api |
|
||||
| 운영 | v1.vexplor.com | api.vexplor.com/api |
|
||||
|
||||
### 5.5 상태 관리 체계
|
||||
|
||||
```
|
||||
전역 상태
|
||||
├── AuthContext → 인증/세션/토큰
|
||||
├── MenuContext → 메뉴 트리/권한
|
||||
├── ScreenPreviewContext → 프리뷰 모드
|
||||
├── ScreenMultiLangContext → 다국어 라벨
|
||||
├── TableOptionsContext → 테이블 옵션
|
||||
└── ActiveTabContext → 활성 탭
|
||||
|
||||
로컬 상태
|
||||
├── Zustand Stores → 화면 디자이너 상태, 사용자 상태
|
||||
└── React Query → 서버 데이터 캐시 (5분 stale, 30분 GC)
|
||||
```
|
||||
|
||||
### 5.6 레지스트리 시스템
|
||||
|
||||
```typescript
|
||||
// 컴포넌트 등록 (482개 등록됨)
|
||||
ComponentRegistry.registerComponent({
|
||||
id: "v2-input",
|
||||
name: "통합 입력",
|
||||
category: ComponentCategory.V2,
|
||||
component: V2Input,
|
||||
configPanel: V2InputConfigPanel,
|
||||
defaultConfig: { inputType: "text" }
|
||||
});
|
||||
|
||||
// 동적 렌더링
|
||||
<DynamicComponentRenderer
|
||||
component={componentData}
|
||||
formData={formData}
|
||||
onFormDataChange={handleChange}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 데이터베이스 구조
|
||||
|
||||
### 6.1 테이블 도메인별 분류
|
||||
|
||||
#### 사용자/인증/회사
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `company_mng` | 회사 마스터 |
|
||||
| `user_info` | 사용자 정보 |
|
||||
| `user_info_history` | 사용자 변경 이력 |
|
||||
| `user_dept` | 사용자-부서 매핑 |
|
||||
| `dept_info` | 부서 정보 |
|
||||
| `authority_master` | 권한 그룹 마스터 |
|
||||
| `authority_sub_user` | 사용자-권한 매핑 |
|
||||
| `login_access_log` | 로그인 로그 |
|
||||
|
||||
#### 메뉴/화면
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `menu_info` | 메뉴 트리 구조 |
|
||||
| `screen_definitions` | 화면 정의 (screenId, 테이블명 등) |
|
||||
| `screen_layouts_v2` | V2 레이아웃 (JSON) |
|
||||
| `screen_layouts` | V1 레이아웃 (레거시) |
|
||||
| `screen_groups` | 화면 그룹 (계층구조) |
|
||||
| `screen_group_screens` | 화면-그룹 매핑 |
|
||||
| `screen_menu_assignments` | 화면-메뉴 할당 |
|
||||
| `screen_field_joins` | 화면 필드 조인 설정 |
|
||||
| `screen_data_flows` | 화면 데이터 플로우 |
|
||||
| `screen_table_relations` | 화면-테이블 관계 |
|
||||
|
||||
#### 메타데이터
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `table_type_columns` | 테이블 타입별 컬럼 정의 (회사별) |
|
||||
| `table_column_category_values` | 컬럼 카테고리 값 |
|
||||
| `code_category` | 공통 코드 카테고리 |
|
||||
| `code_info` | 공통 코드 값 |
|
||||
| `category_column_mapping` | 카테고리-컬럼 매핑 |
|
||||
| `cascading_relation` | 연쇄 드롭다운 관계 |
|
||||
| `numbering_rules` | 채번 규칙 |
|
||||
| `numbering_rule_parts` | 채번 규칙 파트 |
|
||||
|
||||
#### 플로우/자동화
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `flow_definition` | 플로우 정의 |
|
||||
| `flow_step` | 플로우 단계 |
|
||||
| `flow_step_connection` | 플로우 단계 연결 |
|
||||
| `node_flows` | 노드 플로우 (버튼 액션) |
|
||||
| `dataflow_diagrams` | 데이터플로우 다이어그램 |
|
||||
| `batch_definitions` | 배치 작업 정의 |
|
||||
| `batch_schedules` | 배치 스케줄 |
|
||||
| `batch_execution_logs` | 배치 실행 로그 |
|
||||
|
||||
#### 외부 연동
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `external_db_connections` | 외부 DB 연결 정보 |
|
||||
| `external_rest_api_connections` | 외부 REST API 연결 |
|
||||
|
||||
#### 다국어
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `multi_lang_key_master` | 다국어 키 마스터 |
|
||||
|
||||
#### 기타
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `work_history` | 작업 이력 |
|
||||
| `todo_items` | 할일 목록 |
|
||||
| `file_uploads` | 파일 업로드 |
|
||||
| `ddl_audit_log` | DDL 감사 로그 |
|
||||
|
||||
### 6.2 동적 테이블 생성 패턴
|
||||
|
||||
관리자가 화면 생성 시 비즈니스 테이블이 동적으로 생성된다:
|
||||
|
||||
```sql
|
||||
CREATE TABLE "dynamic_table_name" (
|
||||
"id" VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" TIMESTAMP DEFAULT now(),
|
||||
"updated_date" TIMESTAMP DEFAULT now(),
|
||||
"writer" VARCHAR(500),
|
||||
"company_code" VARCHAR(500), -- 멀티테넌시 필수!
|
||||
-- 사용자 정의 컬럼들 (모두 VARCHAR(500))
|
||||
"product_name" VARCHAR(500),
|
||||
"price" VARCHAR(500),
|
||||
...
|
||||
);
|
||||
CREATE INDEX idx_dynamic_company ON "dynamic_table_name"(company_code);
|
||||
```
|
||||
|
||||
### 6.3 테이블 관계도
|
||||
|
||||
```
|
||||
company_mng (company_code PK)
|
||||
│
|
||||
├── user_info (company_code FK)
|
||||
│ ├── authority_sub_user (user_id FK)
|
||||
│ └── user_dept (user_id FK)
|
||||
│
|
||||
├── menu_info (company_code)
|
||||
│ └── screen_menu_assignments (menu_objid FK)
|
||||
│
|
||||
├── screen_definitions (company_code)
|
||||
│ ├── screen_layouts_v2 (screen_id FK)
|
||||
│ ├── screen_groups → screen_group_screens (screen_id FK)
|
||||
│ └── screen_field_joins (screen_id FK)
|
||||
│
|
||||
├── authority_master (company_code)
|
||||
│ └── authority_sub_user (master_objid FK)
|
||||
│
|
||||
├── flow_definition (company_code)
|
||||
│ ├── flow_step (flow_id FK)
|
||||
│ └── flow_step_connection (flow_id FK)
|
||||
│
|
||||
└── [동적 비즈니스 테이블들] (company_code)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 인증/인가 워크플로우
|
||||
|
||||
### 7.1 로그인 프로세스
|
||||
|
||||
```
|
||||
┌─── 사용자 ───┐ ┌─── 프론트엔드 ───┐ ┌─── 백엔드 ───┐ ┌─── DB ───┐
|
||||
│ │ │ │ │ │ │ │
|
||||
│ ID/PW 입력 │────→│ POST /auth/login │────→│ 비밀번호 검증 │────→│ user_info│
|
||||
│ │ │ │ │ │ │ 조회 │
|
||||
│ │ │ │ │ JWT 토큰 생성 │ │ │
|
||||
│ │ │ │←────│ 토큰 반환 │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ localStorage 저장│ │ │ │ │
|
||||
│ │ │ Cookie 저장 │ │ │ │ │
|
||||
│ │ │ /main 리다이렉트 │ │ │ │ │
|
||||
└──────────────┘ └──────────────────┘ └──────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
### 7.2 JWT 토큰 관리
|
||||
|
||||
```
|
||||
토큰 저장: localStorage (주 저장소) + Cookie (SSR 미들웨어용)
|
||||
|
||||
자동 갱신:
|
||||
├── 10분마다 만료 시간 체크
|
||||
├── 만료 30분 전: 백그라운드 자동 갱신
|
||||
├── 401 응답 시: 즉시 갱신 시도
|
||||
└── 갱신 실패 시: /login 리다이렉트
|
||||
|
||||
세션 관리:
|
||||
├── 데스크톱: 30분 비활성 → 세션 만료 (5분 전 경고)
|
||||
└── 모바일: 24시간 비활성 → 세션 만료 (1시간 전 경고)
|
||||
```
|
||||
|
||||
### 7.3 권한 체계 (3단계)
|
||||
|
||||
```
|
||||
SUPER_ADMIN (company_code = "*")
|
||||
├── 모든 회사 데이터 접근 가능
|
||||
├── DDL 실행 가능
|
||||
├── 시스템 설정 변경
|
||||
└── 다른 회사로 전환 (switch-company)
|
||||
|
||||
COMPANY_ADMIN (userType = "COMPANY_ADMIN")
|
||||
├── 자기 회사 데이터만 접근
|
||||
├── 사용자 관리 가능
|
||||
└── 메뉴/화면 관리 가능
|
||||
|
||||
USER (일반 사용자)
|
||||
├── 자기 회사 데이터만 접근
|
||||
├── 권한 그룹에 따른 메뉴 접근
|
||||
└── 할당된 화면만 사용 가능
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 화면 디자이너 워크플로우
|
||||
|
||||
### 8.1 관리자: 화면 설계
|
||||
|
||||
```
|
||||
Step 1: 화면 생성
|
||||
└→ /admin/screenMng/screenMngList
|
||||
└→ "새 화면" 클릭 → 화면명, 설명, 메인 테이블 입력
|
||||
|
||||
Step 2: 화면 디자이너 진입 (ScreenDesigner.tsx)
|
||||
├── 좌측 패널: 컴포넌트 팔레트 (V2 컴포넌트 10종)
|
||||
├── 중앙 캔버스: 드래그앤드롭 영역
|
||||
└── 우측 패널: 선택된 컴포넌트 속성 설정
|
||||
|
||||
Step 3: 컴포넌트 배치
|
||||
└→ V2Input 드래그 → 캔버스 배치 → 속성 설정:
|
||||
├── 위치: x, y 좌표
|
||||
├── 크기: width, height
|
||||
├── 데이터 바인딩: columnName = "product_name"
|
||||
├── 라벨: "제품명"
|
||||
├── 조건부 표시: 특정 조건에서만 보이기
|
||||
└── 플로우 연결: 버튼 클릭 시 실행할 플로우
|
||||
|
||||
Step 4: 레이아웃 저장
|
||||
└→ screen_layouts_v2 테이블에 JSON 형태로 저장
|
||||
└→ Zod 스키마 검증 → V2 형식 우선, V1 호환 저장
|
||||
|
||||
Step 5: 메뉴에 화면 할당
|
||||
└→ /admin/menu → 메뉴 트리에서 "제품 관리" 선택
|
||||
└→ 화면 연결 (screen_menu_assignments)
|
||||
```
|
||||
|
||||
### 8.2 화면 레이아웃 저장 구조 (V2)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v2",
|
||||
"components": [
|
||||
{
|
||||
"id": "comp-1",
|
||||
"componentType": "v2-input",
|
||||
"position": { "x": 100, "y": 50 },
|
||||
"size": { "width": 200, "height": 40 },
|
||||
"config": {
|
||||
"inputType": "text",
|
||||
"columnName": "product_name",
|
||||
"label": "제품명",
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "comp-2",
|
||||
"componentType": "v2-list",
|
||||
"position": { "x": 100, "y": 150 },
|
||||
"size": { "width": 600, "height": 400 },
|
||||
"config": {
|
||||
"listType": "table",
|
||||
"tableName": "products",
|
||||
"columns": ["product_name", "price", "quantity"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 사용자 업무 워크플로우
|
||||
|
||||
### 9.1 전체 흐름
|
||||
|
||||
```
|
||||
사용자 로그인
|
||||
↓
|
||||
메인 대시보드 (/main)
|
||||
↓
|
||||
좌측 메뉴에서 "제품 관리" 클릭
|
||||
↓
|
||||
/screens/[screenId] 라우팅
|
||||
↓
|
||||
InteractiveScreenViewer 렌더링
|
||||
├── screen_definitions에서 화면 정보 로드
|
||||
├── screen_layouts_v2에서 레이아웃 JSON 로드
|
||||
├── V2 → Legacy 변환 (호환성)
|
||||
└── 메인 테이블 데이터 자동 로드
|
||||
↓
|
||||
컴포넌트별 렌더링
|
||||
├── V2Input → formData 바인딩
|
||||
├── V2List → 테이블 데이터 표시
|
||||
├── V2Select → 드롭다운/라디오 선택
|
||||
└── Button → 플로우/액션 연결
|
||||
↓
|
||||
사용자 인터랙션
|
||||
├── 폼 입력 → formData 업데이트
|
||||
├── 테이블 행 선택 → selectedRowsData 업데이트
|
||||
└── 버튼 클릭 → 플로우 실행
|
||||
↓
|
||||
플로우 실행 (nodeFlowButtonExecutor)
|
||||
├── Step 1: 데이터 검증
|
||||
├── Step 2: API 호출 (INSERT/UPDATE/DELETE)
|
||||
├── Step 3: 성공/실패 처리
|
||||
└── Step 4: 테이블 자동 새로고침
|
||||
```
|
||||
|
||||
### 9.2 조건부 표시 워크플로우
|
||||
|
||||
```
|
||||
관리자 설정:
|
||||
"특별 할인 입력" 컴포넌트
|
||||
└→ 조건: product_type === "PREMIUM" 일 때만 표시
|
||||
|
||||
사용자 사용:
|
||||
1. 화면 진입 → evaluateConditional() 실행
|
||||
2. product_type ≠ "PREMIUM" → "특별 할인 입력" 숨김
|
||||
3. 사용자가 product_type을 "PREMIUM"으로 변경
|
||||
4. formData 업데이트 → evaluateConditional() 재평가
|
||||
5. product_type === "PREMIUM" → "특별 할인 입력" 표시!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 플로우 엔진 워크플로우
|
||||
|
||||
### 10.1 플로우 정의 (관리자)
|
||||
|
||||
```
|
||||
/admin/automaticMng/flowMgmtList
|
||||
↓
|
||||
플로우 생성:
|
||||
├── 이름: "제품 승인 플로우"
|
||||
├── 테이블: "products"
|
||||
└── 단계 정의:
|
||||
Step 1: "신청" (requester)
|
||||
Step 2: "부서장 승인" (manager)
|
||||
Step 3: "최종 승인" (director)
|
||||
연결: Step 1 → Step 2 → Step 3
|
||||
```
|
||||
|
||||
### 10.2 플로우 실행 (사용자)
|
||||
|
||||
```
|
||||
1. 사용자: 제품 신청
|
||||
└→ "저장" 버튼 클릭
|
||||
└→ flowApi.startFlow() → 상태: "부서장 승인 대기"
|
||||
|
||||
2. 부서장: 승인 화면
|
||||
└→ V2Biz (flow) 컴포넌트 → 현재 단계 표시
|
||||
└→ [승인] 클릭 → flowApi.approveStep()
|
||||
└→ 상태: "최종 승인 대기"
|
||||
|
||||
3. 이사: 최종 승인
|
||||
└→ [승인] 클릭 → flowApi.approveStep()
|
||||
└→ 상태: "완료"
|
||||
└→ products.approval_status = "APPROVED"
|
||||
```
|
||||
|
||||
### 10.3 데이터 이동 (moveData)
|
||||
|
||||
```
|
||||
플로우의 핵심 동작: 데이터를 한 스텝에서 다음 스텝으로 이동
|
||||
|
||||
Step 1 (접수) → Step 2 (검토) → Step 3 (완료)
|
||||
├── 단건 이동: moveData(flowId, dataId, fromStep, toStep)
|
||||
└── 배치 이동: moveBatchData(flowId, dataIds[], fromStep, toStep)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 데이터플로우 시스템
|
||||
|
||||
### 11.1 개요
|
||||
|
||||
데이터플로우는 비즈니스 로직을 **비주얼 다이어그램**으로 설계하는 시스템이다.
|
||||
|
||||
```
|
||||
/admin/systemMng/dataflow
|
||||
↓
|
||||
React Flow 기반 캔버스
|
||||
├── InputNode: 데이터 입력 (폼 데이터, 테이블 데이터)
|
||||
├── TransformNode: 데이터 변환 (매핑, 필터링, 계산)
|
||||
├── DatabaseNode: DB 조회/저장
|
||||
├── RestApiNode: 외부 API 호출
|
||||
├── ConditionNode: 조건 분기
|
||||
├── LoopNode: 반복 처리
|
||||
├── MergeNode: 데이터 합치기
|
||||
└── OutputNode: 결과 출력
|
||||
```
|
||||
|
||||
### 11.2 데이터플로우 실행
|
||||
|
||||
```
|
||||
버튼 클릭 → 데이터플로우 트리거
|
||||
↓
|
||||
InputNode: formData 수집
|
||||
↓
|
||||
TransformNode: 데이터 가공
|
||||
↓
|
||||
ConditionNode: 조건 분기 (가격 > 10000?)
|
||||
├── Yes → DatabaseNode: INSERT INTO premium_products
|
||||
└── No → DatabaseNode: INSERT INTO standard_products
|
||||
↓
|
||||
OutputNode: 결과 반환 → toast.success("저장 완료")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 대시보드 시스템
|
||||
|
||||
### 12.1 구조
|
||||
|
||||
```
|
||||
관리자: /admin/screenMng/dashboardList
|
||||
└→ 대시보드 생성 → 위젯 추가 → 레이아웃 저장
|
||||
|
||||
사용자: /dashboard/[dashboardId]
|
||||
└→ 위젯 그리드 렌더링 → 실시간 데이터 표시
|
||||
```
|
||||
|
||||
### 12.2 위젯 종류
|
||||
|
||||
| 카테고리 | 위젯 | 역할 |
|
||||
|----------|------|------|
|
||||
| 시각화 | CustomMetricWidget | 커스텀 메트릭 표시 |
|
||||
| | StatusSummaryWidget | 상태 요약 |
|
||||
| 리스트 | CargoListWidget | 화물 목록 |
|
||||
| | VehicleListWidget | 차량 목록 |
|
||||
| 지도 | MapTestWidget | 지도 표시 |
|
||||
| | WeatherMapWidget | 날씨 지도 |
|
||||
| 작업 | TodoWidget | 할일 목록 |
|
||||
| | WorkHistoryWidget | 작업 이력 |
|
||||
| 알림 | BookingAlertWidget | 예약 알림 |
|
||||
| | RiskAlertWidget | 위험 알림 |
|
||||
| 기타 | ClockWidget | 시계 |
|
||||
| | CalendarWidget | 캘린더 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 배치/스케줄 시스템
|
||||
|
||||
### 13.1 구조
|
||||
|
||||
```
|
||||
관리자: /admin/automaticMng/batchmngList
|
||||
↓
|
||||
배치 작업 생성:
|
||||
├── 이름: "일일 재고 집계"
|
||||
├── 실행 쿼리: SQL 또는 데이터플로우 ID
|
||||
├── 스케줄: Cron 표현식 ("0 0 * * *" = 매일 자정)
|
||||
└── 활성화/비활성화
|
||||
↓
|
||||
배치 스케줄러 (batch_schedules)
|
||||
↓
|
||||
자동 실행 → 실행 로그 (batch_execution_logs)
|
||||
```
|
||||
|
||||
### 13.2 배치 실행 흐름
|
||||
|
||||
```
|
||||
Cron 트리거 → 배치 정의 조회 → SQL/데이터플로우 실행
|
||||
↓
|
||||
성공: execution_log에 "SUCCESS" 기록
|
||||
실패: execution_log에 "FAILED" + 에러 메시지 기록
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 멀티테넌시 아키텍처
|
||||
|
||||
### 14.1 핵심 원칙
|
||||
|
||||
```
|
||||
모든 비즈니스 테이블: company_code 컬럼 필수
|
||||
모든 쿼리: WHERE company_code = $1 필수
|
||||
모든 JOIN: ON a.company_code = b.company_code 필수
|
||||
모든 집계: GROUP BY company_code 필수
|
||||
```
|
||||
|
||||
### 14.2 데이터 격리
|
||||
|
||||
```
|
||||
회사 A (company_code = "COMPANY_A"):
|
||||
└→ 자기 데이터만 조회/수정/삭제 가능
|
||||
|
||||
회사 B (company_code = "COMPANY_B"):
|
||||
└→ 자기 데이터만 조회/수정/삭제 가능
|
||||
|
||||
슈퍼관리자 (company_code = "*"):
|
||||
└→ 모든 회사 데이터 조회 가능
|
||||
└→ 일반 회사는 "*" 데이터를 볼 수 없음
|
||||
|
||||
중요: company_code = "*"는 공통 데이터가 아니라 슈퍼관리자 전용 데이터!
|
||||
```
|
||||
|
||||
### 14.3 코드 패턴
|
||||
|
||||
```typescript
|
||||
// 백엔드 표준 패턴
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 슈퍼관리자: 전체 데이터
|
||||
query = "SELECT * FROM table ORDER BY company_code";
|
||||
} else {
|
||||
// 일반 사용자: 자기 회사만, "*" 제외
|
||||
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
|
||||
params = [companyCode];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. 외부 연동
|
||||
|
||||
### 15.1 외부 DB 연결
|
||||
|
||||
```
|
||||
지원 DB: PostgreSQL, MySQL, MariaDB, MSSQL, Oracle
|
||||
|
||||
관리: /api/external-db-connections
|
||||
├── 연결 정보 등록 (host, port, database, credentials)
|
||||
├── 연결 테스트
|
||||
├── 쿼리 실행
|
||||
└── 데이터플로우에서 DatabaseNode로 사용
|
||||
```
|
||||
|
||||
### 15.2 외부 REST API 연결
|
||||
|
||||
```
|
||||
관리: /api/external-rest-api-connections
|
||||
├── API 엔드포인트 등록 (URL, method, headers)
|
||||
├── 인증 설정 (Bearer, Basic, API Key)
|
||||
├── 테스트 호출
|
||||
└── 데이터플로우에서 RestApiNode로 사용
|
||||
```
|
||||
|
||||
### 15.3 메일 시스템
|
||||
|
||||
```
|
||||
관리: /admin/automaticMng/mail/*
|
||||
├── 메일 템플릿 관리
|
||||
├── 메일 발송 (개별/대량)
|
||||
├── 수신 메일 확인
|
||||
└── 발송 이력 조회
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 배포 환경
|
||||
|
||||
### 16.1 Docker 구성
|
||||
|
||||
```
|
||||
개발 환경 (Mac):
|
||||
├── docker/dev/docker-compose.backend.mac.yml (BE: 8080)
|
||||
└── docker/dev/docker-compose.frontend.mac.yml (FE: 9771)
|
||||
|
||||
운영 환경:
|
||||
├── docker/prod/docker-compose.backend.prod.yml (BE: 8080)
|
||||
└── docker/prod/docker-compose.frontend.prod.yml (FE: 5555)
|
||||
```
|
||||
|
||||
### 16.2 서버 정보
|
||||
|
||||
| 환경 | 서버 | 포트 | DB |
|
||||
|------|------|------|-----|
|
||||
| 개발 | 39.117.244.52 | FE:9771, BE:8080 | 39.117.244.52:11132 |
|
||||
| 운영 | 211.115.91.141 | FE:5555, BE:8080 | 211.115.91.141:11134 |
|
||||
|
||||
### 16.3 백엔드 시작 시 자동 작업
|
||||
|
||||
```
|
||||
서버 시작 (app.ts)
|
||||
├── 마이그레이션 실행 (DB 스키마 업데이트)
|
||||
├── 배치 스케줄러 초기화
|
||||
├── 위험 알림 캐시 로드
|
||||
└── 메일 정리 Cron 시작
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 부록: 업무 진행 요약
|
||||
|
||||
### 새로운 업무 화면을 만드는 전체 프로세스
|
||||
|
||||
```
|
||||
1. [DB] 테이블 관리에서 비즈니스 테이블 생성
|
||||
└→ 컬럼 정의, 타입 설정
|
||||
|
||||
2. [화면] 화면 관리에서 새 화면 생성
|
||||
└→ 메인 테이블 지정
|
||||
|
||||
3. [디자인] 화면 디자이너에서 UI 구성
|
||||
└→ V2 컴포넌트 배치, 데이터 바인딩
|
||||
|
||||
4. [로직] 데이터플로우 설계 (필요시)
|
||||
└→ 저장/수정/삭제 로직 다이어그램
|
||||
|
||||
5. [플로우] 플로우 정의 (승인 프로세스 필요시)
|
||||
└→ 단계 정의, 연결
|
||||
|
||||
6. [메뉴] 메뉴에 화면 할당
|
||||
└→ 사용자가 접근할 수 있게 메뉴 트리 배치
|
||||
|
||||
7. [권한] 권한 그룹에 메뉴 할당
|
||||
└→ 특정 사용자 그룹만 접근 가능하게
|
||||
|
||||
8. [사용] 사용자가 메뉴 클릭 → 업무 시작!
|
||||
```
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
# WACE ERP Backend - 분석 문서 인덱스
|
||||
|
||||
> **분석 완료일**: 2026-02-06
|
||||
> **분석자**: Backend Specialist
|
||||
|
||||
---
|
||||
|
||||
## 📚 문서 목록
|
||||
|
||||
### 1. 📖 상세 분석 문서
|
||||
**파일**: `backend-architecture-detailed-analysis.md`
|
||||
**내용**: 백엔드 전체 아키텍처 상세 분석 (16개 섹션)
|
||||
|
||||
- 전체 개요 및 기술 스택
|
||||
- 디렉토리 구조
|
||||
- 미들웨어 스택 구성
|
||||
- 인증/인가 시스템 (JWT, 3단계 권한)
|
||||
- 멀티테넌시 구현 방식
|
||||
- API 라우트 전체 목록
|
||||
- 비즈니스 도메인별 모듈 (8개 도메인)
|
||||
- 데이터베이스 접근 방식 (Raw Query)
|
||||
- 외부 시스템 연동 (DB/REST API)
|
||||
- 배치/스케줄 처리 (node-cron)
|
||||
- 파일 처리 (multer)
|
||||
- 에러 핸들링
|
||||
- 로깅 시스템 (Winston)
|
||||
- 보안 및 권한 관리
|
||||
- 성능 최적화
|
||||
|
||||
**특징**: 워크플로우 문서에 통합하기 위한 완전한 아키텍처 분석
|
||||
|
||||
---
|
||||
|
||||
### 2. 📄 요약 문서
|
||||
**파일**: `backend-architecture-summary.md`
|
||||
**내용**: 백엔드 아키텍처 핵심 요약 (16개 섹션 압축)
|
||||
|
||||
- 기술 스택 요약
|
||||
- 계층 구조 다이어그램
|
||||
- 디렉토리 구조
|
||||
- 미들웨어 스택 순서
|
||||
- 인증/인가 흐름도
|
||||
- 멀티테넌시 핵심 원칙
|
||||
- API 라우트 카테고리별 정리
|
||||
- 비즈니스 도메인 8개 요약
|
||||
- 데이터베이스 접근 패턴
|
||||
- 외부 연동 아키텍처
|
||||
- 배치 스케줄러 시스템
|
||||
- 파일 처리 흐름
|
||||
- 보안 정책
|
||||
- 에러 핸들링 전략
|
||||
- 로깅 구조
|
||||
- 성능 최적화 전략
|
||||
- **핵심 체크리스트** (개발 시 필수 규칙 8개)
|
||||
|
||||
**특징**: 빠른 참조를 위한 간결한 요약
|
||||
|
||||
---
|
||||
|
||||
### 3. 🔗 API 라우트 완전 매핑
|
||||
**파일**: `backend-api-route-mapping.md`
|
||||
**내용**: 프론트엔드 개발자용 API 엔드포인트 전체 목록 (200+개)
|
||||
|
||||
#### 포함된 API 카테고리
|
||||
1. 인증 API (7개)
|
||||
2. 관리자 API (15개)
|
||||
3. 테이블 관리 API (30개)
|
||||
4. 화면 관리 API (10개)
|
||||
5. 플로우 API (15개)
|
||||
6. 데이터플로우 API (10개)
|
||||
7. 외부 연동 API (15개)
|
||||
8. 배치 API (10개)
|
||||
9. 메일 API (5개)
|
||||
10. 파일 API (5개)
|
||||
11. 대시보드 API (5개)
|
||||
12. 공통코드 API (3개)
|
||||
13. 다국어 API (3개)
|
||||
14. 회사 관리 API (4개)
|
||||
15. 부서 API (2개)
|
||||
16. 권한 그룹 API (2개)
|
||||
17. DDL 실행 API (1개)
|
||||
18. 외부 API 프록시 (2개)
|
||||
19. 디지털 트윈 API (3개)
|
||||
20. 3D 필드 API (2개)
|
||||
21. 스케줄 API (1개)
|
||||
22. 채번 규칙 API (3개)
|
||||
23. 엔티티 검색 API (2개)
|
||||
24. To-Do API (3개)
|
||||
25. 예약 요청 API (2개)
|
||||
26. 리스크/알림 API (2개)
|
||||
27. 헬스 체크 (1개)
|
||||
|
||||
#### 각 API 정보 포함
|
||||
- HTTP 메서드
|
||||
- 엔드포인트 경로
|
||||
- 필요 권한 (공개/인증/관리자/슈퍼관리자)
|
||||
- 기능 설명
|
||||
- Request Body/Query Params
|
||||
- Response 형식
|
||||
|
||||
#### 추가 정보
|
||||
- Base URL (개발/운영)
|
||||
- 공통 헤더 (Authorization)
|
||||
- 응답 형식 (성공/에러)
|
||||
- 에러 코드 목록
|
||||
|
||||
**특징**: 프론트엔드에서 API 호출 시 즉시 참조 가능
|
||||
|
||||
---
|
||||
|
||||
### 4. 📊 JSON 응답 요약
|
||||
**파일**: `backend-analysis-response.json`
|
||||
**내용**: 구조화된 JSON 형식의 분석 결과
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"confidence": "high",
|
||||
"result": {
|
||||
"summary": "...",
|
||||
"details": "...",
|
||||
"files_affected": [...],
|
||||
"key_findings": {
|
||||
"architecture_pattern": "...",
|
||||
"tech_stack": {...},
|
||||
"middleware_stack": [...],
|
||||
"authentication_flow": {...},
|
||||
"permission_levels": {...},
|
||||
"multi_tenancy": {...},
|
||||
"business_domains": {...},
|
||||
"database_access": {...},
|
||||
"security": {...},
|
||||
"performance_optimization": {...}
|
||||
},
|
||||
"critical_rules": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**특징**: 프로그래밍 방식으로 분석 결과 활용 가능
|
||||
|
||||
---
|
||||
|
||||
## 🎯 핵심 요약
|
||||
|
||||
### 아키텍처
|
||||
- **패턴**: Layered Architecture (Controller → Service → Database)
|
||||
- **언어**: TypeScript (Strict Mode)
|
||||
- **프레임워크**: Express.js
|
||||
- **데이터베이스**: PostgreSQL (Raw Query, Connection Pool)
|
||||
- **인증**: JWT (24시간 만료, 자동 갱신)
|
||||
|
||||
### 멀티테넌시
|
||||
```typescript
|
||||
// ✅ 핵심 원칙
|
||||
const companyCode = req.user!.companyCode; // JWT에서 추출
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 슈퍼관리자: 모든 데이터
|
||||
query = "SELECT * FROM table ORDER BY company_code";
|
||||
} else {
|
||||
// 일반 사용자: 자기 회사만 + 슈퍼관리자 숨김
|
||||
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
|
||||
params = [companyCode];
|
||||
}
|
||||
```
|
||||
|
||||
### 권한 체계 (3단계)
|
||||
1. **SUPER_ADMIN** (`company_code = "*"`)
|
||||
- 전체 회사 데이터 접근
|
||||
- DDL 실행, 회사 생성/삭제
|
||||
|
||||
2. **COMPANY_ADMIN** (`company_code = "ILSHIN"`)
|
||||
- 자기 회사 데이터만 접근
|
||||
- 사용자/설정 관리
|
||||
|
||||
3. **USER** (`company_code = "ILSHIN"`)
|
||||
- 자기 회사 데이터만 접근
|
||||
- 읽기/쓰기만
|
||||
|
||||
### 주요 도메인 (8개)
|
||||
1. **관리자** - 사용자/메뉴/권한
|
||||
2. **테이블/화면** - 메타데이터, 동적 화면
|
||||
3. **플로우** - 워크플로우 엔진
|
||||
4. **데이터플로우** - ERD, 관계도
|
||||
5. **외부 연동** - 외부 DB/REST API
|
||||
6. **배치** - Cron 스케줄러
|
||||
7. **메일** - 발송/수신
|
||||
8. **파일** - 업로드/다운로드
|
||||
|
||||
### API 통계
|
||||
- **총 라우트**: 70+개
|
||||
- **총 API**: 200+개
|
||||
- **컨트롤러**: 70+개
|
||||
- **서비스**: 80+개
|
||||
- **미들웨어**: 4개
|
||||
|
||||
---
|
||||
|
||||
## 🚨 개발 시 필수 규칙
|
||||
|
||||
✅ **모든 쿼리에 `company_code` 필터 추가**
|
||||
✅ **JWT 토큰에서 `company_code` 추출 (클라이언트 신뢰 금지)**
|
||||
✅ **Parameterized Query 사용 (SQL Injection 방지)**
|
||||
✅ **슈퍼관리자 데이터 숨김 (`company_code != '*'`)**
|
||||
✅ **비밀번호는 bcrypt, 민감정보는 AES-256**
|
||||
✅ **에러 핸들링 try/catch 필수**
|
||||
✅ **트랜잭션이 필요한 경우 `transaction()` 사용**
|
||||
✅ **파일 업로드는 회사별 디렉토리 분리**
|
||||
|
||||
---
|
||||
|
||||
## 📁 문서 위치
|
||||
|
||||
```
|
||||
ERP-node/docs/
|
||||
├── backend-architecture-detailed-analysis.md (상세 분석, 16개 섹션)
|
||||
├── backend-architecture-summary.md (요약, 간결한 참조)
|
||||
├── backend-api-route-mapping.md (API 200+개 전체 매핑)
|
||||
└── backend-analysis-response.json (JSON 구조화 데이터)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 문서 사용 가이드
|
||||
|
||||
### 처음 백엔드를 이해하려면
|
||||
→ `backend-architecture-summary.md` 읽기 (20분)
|
||||
|
||||
### 특정 기능을 구현하려면
|
||||
→ `backend-architecture-detailed-analysis.md`에서 해당 도메인 섹션 참조
|
||||
|
||||
### API를 호출하려면
|
||||
→ `backend-api-route-mapping.md`에서 엔드포인트 검색
|
||||
|
||||
### 워크플로우 문서에 통합하려면
|
||||
→ `backend-architecture-detailed-analysis.md` 전체 복사
|
||||
|
||||
### 프로그래밍 방식으로 활용하려면
|
||||
→ `backend-analysis-response.json` 파싱
|
||||
|
||||
---
|
||||
|
||||
**문서 버전**: 1.0
|
||||
**마지막 업데이트**: 2026-02-06
|
||||
**다음 업데이트 예정**: 신규 API 추가 시
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
{
|
||||
"status": "success",
|
||||
"confidence": "high",
|
||||
"result": {
|
||||
"summary": "WACE ERP 백엔드 전체 아키텍처 분석 완료",
|
||||
"details": "Node.js + Express + TypeScript + PostgreSQL Raw Query 기반 멀티테넌시 시스템. 70+ 라우트, 70+ 컨트롤러, 80+ 서비스로 구성된 계층형 아키텍처. JWT 인증, 3단계 권한 체계(SUPER_ADMIN/COMPANY_ADMIN/USER), company_code 기반 완전한 데이터 격리 구현.",
|
||||
"files_affected": [
|
||||
"docs/backend-architecture-detailed-analysis.md (상세 분석 문서)",
|
||||
"docs/backend-architecture-summary.md (요약 문서)",
|
||||
"docs/backend-api-route-mapping.md (API 라우트 전체 매핑)"
|
||||
],
|
||||
"key_findings": {
|
||||
"architecture_pattern": "Layered Architecture (Controller → Service → Database)",
|
||||
"tech_stack": {
|
||||
"language": "TypeScript",
|
||||
"runtime": "Node.js 20.10.0+",
|
||||
"framework": "Express.js",
|
||||
"database": "PostgreSQL (pg 라이브러리, Raw Query)",
|
||||
"authentication": "JWT (jsonwebtoken)",
|
||||
"scheduler": "node-cron",
|
||||
"external_db_support": ["PostgreSQL", "MySQL", "MSSQL", "Oracle"]
|
||||
},
|
||||
"directory_structure": {
|
||||
"controllers": "70+ 파일 (API 요청 수신, 응답 생성)",
|
||||
"services": "80+ 파일 (비즈니스 로직, 트랜잭션 관리)",
|
||||
"routes": "70+ 파일 (API 라우팅)",
|
||||
"middleware": "4개 (인증, 권한, 슈퍼관리자, 에러핸들러)",
|
||||
"types": "26개 (TypeScript 타입 정의)",
|
||||
"utils": "유틸리티 함수 (JWT, 암호화, 로거)"
|
||||
},
|
||||
"middleware_stack": [
|
||||
"1. Process Level Exception Handlers",
|
||||
"2. Helmet (보안 헤더)",
|
||||
"3. Compression (Gzip)",
|
||||
"4. Body Parser (10MB limit)",
|
||||
"5. Static Files (/uploads)",
|
||||
"6. CORS (credentials: true)",
|
||||
"7. Rate Limiting (1분 10000회)",
|
||||
"8. Token Auto Refresh (1시간 이내 만료 시 갱신)",
|
||||
"9. API Routes (70+개)",
|
||||
"10. 404 Handler",
|
||||
"11. Error Handler"
|
||||
],
|
||||
"authentication_flow": {
|
||||
"step1": "로그인 요청 → AuthController.login()",
|
||||
"step2": "AuthService.processLogin() → loginPwdCheck() (bcrypt 검증)",
|
||||
"step3": "getPersonBeanFromSession() → 사용자 정보 조회",
|
||||
"step4": "insertLoginAccessLog() → 로그인 이력 저장",
|
||||
"step5": "JwtUtils.generateToken() → JWT 토큰 생성",
|
||||
"step6": "응답: { token, userInfo, firstMenuPath }"
|
||||
},
|
||||
"jwt_payload": {
|
||||
"userId": "사용자 ID",
|
||||
"userName": "사용자명",
|
||||
"companyCode": "회사 코드 (멀티테넌시 키)",
|
||||
"userType": "권한 레벨 (SUPER_ADMIN/COMPANY_ADMIN/USER)",
|
||||
"exp": "만료 시간 (24시간)"
|
||||
},
|
||||
"permission_levels": {
|
||||
"SUPER_ADMIN": {
|
||||
"company_code": "*",
|
||||
"userType": "SUPER_ADMIN",
|
||||
"capabilities": [
|
||||
"전체 회사 데이터 접근",
|
||||
"DDL 실행",
|
||||
"회사 생성/삭제",
|
||||
"시스템 설정 변경"
|
||||
]
|
||||
},
|
||||
"COMPANY_ADMIN": {
|
||||
"company_code": "특정 회사 (예: ILSHIN)",
|
||||
"userType": "COMPANY_ADMIN",
|
||||
"capabilities": [
|
||||
"자기 회사 데이터만 접근",
|
||||
"자기 회사 사용자 관리",
|
||||
"회사 설정 변경"
|
||||
]
|
||||
},
|
||||
"USER": {
|
||||
"company_code": "특정 회사",
|
||||
"userType": "USER",
|
||||
"capabilities": [
|
||||
"자기 회사 데이터만 접근",
|
||||
"읽기/쓰기 권한만"
|
||||
]
|
||||
}
|
||||
},
|
||||
"multi_tenancy": {
|
||||
"principle": "모든 쿼리에 company_code 필터 필수",
|
||||
"pattern": "JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)",
|
||||
"super_admin_visibility": "일반 회사 사용자에게 슈퍼관리자(company_code='*') 숨김",
|
||||
"correct_pattern": "WHERE company_code = $1 AND company_code != '*'",
|
||||
"wrong_pattern": "req.body.companyCode 사용 (보안 위험!)"
|
||||
},
|
||||
"api_routes": {
|
||||
"total_count": "200+개",
|
||||
"categories": {
|
||||
"인증/관리자": "15개",
|
||||
"테이블/화면": "40개",
|
||||
"플로우": "15개",
|
||||
"데이터플로우": "5개",
|
||||
"외부 연동": "15개",
|
||||
"배치": "10개",
|
||||
"메일": "5개",
|
||||
"파일": "5개",
|
||||
"기타": "90개"
|
||||
}
|
||||
},
|
||||
"business_domains": {
|
||||
"관리자": {
|
||||
"controller": "adminController.ts",
|
||||
"service": "adminService.ts",
|
||||
"features": ["사용자 관리", "메뉴 관리", "권한 그룹 관리", "시스템 설정"]
|
||||
},
|
||||
"테이블/화면": {
|
||||
"controller": "tableManagementController.ts, screenManagementController.ts",
|
||||
"service": "tableManagementService.ts, screenManagementService.ts",
|
||||
"features": ["테이블 메타데이터", "화면 정의", "화면 그룹", "테이블 로그", "엔티티 관계"]
|
||||
},
|
||||
"플로우": {
|
||||
"controller": "flowController.ts",
|
||||
"service": "flowExecutionService.ts, flowDefinitionService.ts",
|
||||
"features": ["워크플로우 설계", "단계 관리", "데이터 이동", "조건부 이동", "오딧 로그"]
|
||||
},
|
||||
"데이터플로우": {
|
||||
"controller": "dataflowController.ts, dataflowDiagramController.ts",
|
||||
"service": "dataflowService.ts, dataflowDiagramService.ts",
|
||||
"features": ["테이블 관계 정의", "ERD", "다이어그램 시각화", "관계 실행"]
|
||||
},
|
||||
"외부 연동": {
|
||||
"controller": "externalDbConnectionController.ts, externalRestApiConnectionController.ts",
|
||||
"service": "externalDbConnectionService.ts, dbConnectionManager.ts",
|
||||
"features": ["외부 DB 연결", "Connection Pool 관리", "REST API 프록시"]
|
||||
},
|
||||
"배치": {
|
||||
"controller": "batchController.ts, batchManagementController.ts",
|
||||
"service": "batchService.ts, batchSchedulerService.ts",
|
||||
"features": ["Cron 스케줄러", "외부 DB → 내부 DB 동기화", "컬럼 매핑", "실행 이력"]
|
||||
},
|
||||
"메일": {
|
||||
"controller": "mailSendSimpleController.ts, mailReceiveBasicController.ts",
|
||||
"service": "mailSendSimpleService.ts, mailReceiveBasicService.ts",
|
||||
"features": ["메일 발송 (nodemailer)", "메일 수신 (IMAP)", "템플릿 관리", "첨부파일"]
|
||||
},
|
||||
"파일": {
|
||||
"controller": "fileController.ts, screenFileController.ts",
|
||||
"service": "fileSystemManager.ts",
|
||||
"features": ["파일 업로드 (multer)", "파일 다운로드", "화면별 파일 관리"]
|
||||
}
|
||||
},
|
||||
"database_access": {
|
||||
"connection_pool": {
|
||||
"min": "2~5 (환경별)",
|
||||
"max": "10~20 (환경별)",
|
||||
"connectionTimeout": "30000ms",
|
||||
"idleTimeout": "600000ms",
|
||||
"statementTimeout": "60000ms"
|
||||
},
|
||||
"query_patterns": {
|
||||
"multi_row": "query('SELECT ...', [params])",
|
||||
"single_row": "queryOne('SELECT ...', [params])",
|
||||
"transaction": "transaction(async (client) => { ... })"
|
||||
},
|
||||
"sql_injection_prevention": "Parameterized Query 사용 (pg 라이브러리)"
|
||||
},
|
||||
"external_integration": {
|
||||
"supported_databases": ["PostgreSQL", "MySQL", "MSSQL", "Oracle"],
|
||||
"connector_pattern": "Factory Pattern (DatabaseConnectorFactory)",
|
||||
"rest_api": "axios 기반 프록시"
|
||||
},
|
||||
"batch_scheduler": {
|
||||
"library": "node-cron",
|
||||
"timezone": "Asia/Seoul",
|
||||
"cron_examples": {
|
||||
"매일 새벽 2시": "0 2 * * *",
|
||||
"5분마다": "*/5 * * * *",
|
||||
"평일 오전 8시": "0 8 * * 1-5"
|
||||
},
|
||||
"execution_flow": [
|
||||
"1. 소스 DB에서 데이터 조회",
|
||||
"2. 컬럼 매핑 적용",
|
||||
"3. 타겟 DB에 INSERT/UPDATE",
|
||||
"4. 실행 로그 기록"
|
||||
]
|
||||
},
|
||||
"file_handling": {
|
||||
"upload_path": "uploads/{company_code}/{timestamp}-{uuid}-{filename}",
|
||||
"max_file_size": "10MB",
|
||||
"allowed_types": ["이미지", "PDF", "Office 문서"],
|
||||
"library": "multer"
|
||||
},
|
||||
"security": {
|
||||
"password_encryption": "bcrypt (12 rounds)",
|
||||
"sensitive_data_encryption": "AES-256-CBC (외부 DB 비밀번호)",
|
||||
"jwt_secret": "환경변수 관리",
|
||||
"security_headers": ["Helmet (CSP, X-Frame-Options)", "CORS (credentials: true)", "Rate Limiting (1분 10000회)"],
|
||||
"sql_injection_prevention": "Parameterized Query"
|
||||
},
|
||||
"error_handling": {
|
||||
"postgres_error_codes": {
|
||||
"23505": "중복된 데이터",
|
||||
"23503": "참조 무결성 위반",
|
||||
"23502": "필수 입력값 누락"
|
||||
},
|
||||
"process_level": {
|
||||
"unhandledRejection": "로깅 (서버 유지)",
|
||||
"uncaughtException": "로깅 (서버 유지, 주의)",
|
||||
"SIGTERM/SIGINT": "Graceful Shutdown"
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"library": "Winston",
|
||||
"log_files": {
|
||||
"error.log": "에러만 (10MB × 5파일)",
|
||||
"combined.log": "전체 로그 (10MB × 10파일)"
|
||||
},
|
||||
"log_levels": "error (0) → warn (1) → info (2) → debug (5)"
|
||||
},
|
||||
"performance_optimization": {
|
||||
"pool_monitoring": "5분마다 상태 체크, 대기 연결 5개 이상 시 경고",
|
||||
"slow_query_detection": "1초 이상 걸린 쿼리 자동 경고",
|
||||
"caching": "Redis (메뉴: 10분 TTL, 공통코드: 30분 TTL)",
|
||||
"compression": "Gzip (1KB 이상 응답, 레벨 6)"
|
||||
}
|
||||
},
|
||||
"critical_rules": [
|
||||
"✅ 모든 쿼리에 company_code 필터 추가",
|
||||
"✅ JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)",
|
||||
"✅ Parameterized Query 사용 (SQL Injection 방지)",
|
||||
"✅ 슈퍼관리자 데이터 숨김 (company_code != '*')",
|
||||
"✅ 비밀번호는 bcrypt, 민감정보는 AES-256",
|
||||
"✅ 에러 핸들링 try/catch 필수",
|
||||
"✅ 트랜잭션이 필요한 경우 transaction() 사용",
|
||||
"✅ 파일 업로드는 회사별 디렉토리 분리"
|
||||
]
|
||||
},
|
||||
"needs_from_others": [],
|
||||
"questions": []
|
||||
}
|
||||
|
|
@ -0,0 +1,542 @@
|
|||
# WACE ERP Backend - API 라우트 완전 매핑
|
||||
|
||||
> **작성일**: 2026-02-06
|
||||
> **목적**: 프론트엔드 개발자용 API 엔드포인트 전체 목록
|
||||
|
||||
---
|
||||
|
||||
## 📌 공통 규칙
|
||||
|
||||
### Base URL
|
||||
```
|
||||
개발: http://localhost:8080
|
||||
운영: http://39.117.244.52:8080
|
||||
```
|
||||
|
||||
### 헤더
|
||||
```http
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {JWT_TOKEN}
|
||||
```
|
||||
|
||||
### 응답 형식
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "성공 메시지",
|
||||
"data": { ... }
|
||||
}
|
||||
|
||||
// 에러 시
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"details": "에러 상세"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 인증 API (`/api/auth`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/auth/login` | 공개 | 로그인 | `{ userId, password }` | `{ token, userInfo, firstMenuPath }` |
|
||||
| POST | `/auth/logout` | 인증 | 로그아웃 | - | `{ success: true }` |
|
||||
| GET | `/auth/me` | 인증 | 현재 사용자 정보 | - | `{ userInfo }` |
|
||||
| GET | `/auth/status` | 공개 | 인증 상태 확인 | - | `{ isLoggedIn, isAdmin }` |
|
||||
| POST | `/auth/refresh` | 인증 | 토큰 갱신 | - | `{ token }` |
|
||||
| POST | `/auth/signup` | 공개 | 회원가입 (공차중계) | `{ userId, password, userName, phoneNumber, licenseNumber, vehicleNumber }` | `{ success: true }` |
|
||||
| POST | `/auth/switch-company` | 슈퍼관리자 | 회사 전환 | `{ companyCode }` | `{ token, companyCode }` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 관리자 API (`/api/admin`)
|
||||
|
||||
### 2.1 사용자 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/admin/users` | 관리자 | 사용자 목록 | `page, limit, search` | `{ users[], total }` |
|
||||
| POST | `/admin/users` | 관리자 | 사용자 생성 | - | `{ user }` |
|
||||
| PUT | `/admin/users/:userId` | 관리자 | 사용자 수정 | - | `{ user }` |
|
||||
| DELETE | `/admin/users/:userId` | 관리자 | 사용자 삭제 | - | `{ success: true }` |
|
||||
| GET | `/admin/users/:userId/history` | 관리자 | 사용자 이력 | - | `{ history[] }` |
|
||||
|
||||
### 2.2 메뉴 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/admin/menus` | 인증 | 메뉴 목록 (트리) | `userId, userLang` | `{ menus[] }` |
|
||||
| POST | `/admin/menus` | 관리자 | 메뉴 생성 | - | `{ menu }` |
|
||||
| PUT | `/admin/menus/:menuId` | 관리자 | 메뉴 수정 | - | `{ menu }` |
|
||||
| DELETE | `/admin/menus/:menuId` | 관리자 | 메뉴 삭제 | - | `{ success: true }` |
|
||||
|
||||
### 2.3 표준 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/admin/web-types` | 인증 | 웹타입 표준 목록 | `{ webTypes[] }` |
|
||||
| GET | `/admin/button-actions` | 인증 | 버튼 액션 표준 | `{ buttonActions[] }` |
|
||||
| GET | `/admin/component-standards` | 인증 | 컴포넌트 표준 | `{ components[] }` |
|
||||
| GET | `/admin/template-standards` | 인증 | 템플릿 표준 | `{ templates[] }` |
|
||||
| GET | `/admin/reports` | 인증 | 리포트 목록 | `{ reports[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 테이블 관리 API (`/api/table-management`)
|
||||
|
||||
### 3.1 테이블 메타데이터
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/table-management/tables` | 인증 | 테이블 목록 | `{ tables[] }` |
|
||||
| GET | `/table-management/tables/:table/columns` | 인증 | 컬럼 목록 | `{ columns[] }` |
|
||||
| GET | `/table-management/tables/:table/schema` | 인증 | 테이블 스키마 | `{ schema }` |
|
||||
| GET | `/table-management/tables/:table/exists` | 인증 | 테이블 존재 여부 | `{ exists: boolean }` |
|
||||
| GET | `/table-management/tables/:table/web-types` | 인증 | 웹타입 정보 | `{ webTypes }` |
|
||||
|
||||
### 3.2 컬럼 설정
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body |
|
||||
|--------|------|------|------|--------------|
|
||||
| POST | `/table-management/tables/:table/columns/:column/settings` | 인증 | 컬럼 설정 업데이트 | `{ web_type, input_type, ... }` |
|
||||
| POST | `/table-management/tables/:table/columns/settings` | 인증 | 전체 컬럼 일괄 업데이트 | `{ columns[] }` |
|
||||
| PUT | `/table-management/tables/:table/label` | 인증 | 테이블 라벨 설정 | `{ label }` |
|
||||
|
||||
### 3.3 데이터 CRUD
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/table-management/tables/:table/data` | 인증 | 데이터 조회 (페이징) | `{ page, limit, filters, sort }` | `{ data[], total }` |
|
||||
| POST | `/table-management/tables/:table/record` | 인증 | 단일 레코드 조회 | `{ conditions }` | `{ record }` |
|
||||
| POST | `/table-management/tables/:table/add` | 인증 | 데이터 추가 | `{ data }` | `{ success: true, id }` |
|
||||
| PUT | `/table-management/tables/:table/edit` | 인증 | 데이터 수정 | `{ conditions, data }` | `{ success: true }` |
|
||||
| DELETE | `/table-management/tables/:table/delete` | 인증 | 데이터 삭제 | `{ conditions }` | `{ success: true }` |
|
||||
|
||||
### 3.4 다중 테이블 저장
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body |
|
||||
|--------|------|------|------|--------------|
|
||||
| POST | `/table-management/multi-table-save` | 인증 | 메인+서브 테이블 저장 | `{ mainTable, mainData, subTables: [{ table, data[] }] }` |
|
||||
|
||||
### 3.5 로그 시스템
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body |
|
||||
|--------|------|------|------|--------------|
|
||||
| POST | `/table-management/tables/:table/log` | 관리자 | 로그 테이블 생성 | - |
|
||||
| GET | `/table-management/tables/:table/log/config` | 인증 | 로그 설정 조회 | - |
|
||||
| GET | `/table-management/tables/:table/log` | 인증 | 로그 데이터 조회 | - |
|
||||
| POST | `/table-management/tables/:table/log/toggle` | 관리자 | 로그 활성화/비활성화 | `{ is_active }` |
|
||||
|
||||
### 3.6 엔티티 관계
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params |
|
||||
|--------|------|------|------|--------------|
|
||||
| GET | `/table-management/tables/entity-relations` | 인증 | 두 테이블 간 관계 조회 | `leftTable, rightTable` |
|
||||
| GET | `/table-management/columns/:table/referenced-by` | 인증 | 현재 테이블 참조 목록 | - |
|
||||
|
||||
### 3.7 카테고리 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/table-management/category-columns` | 인증 | 회사별 카테고리 컬럼 | `{ categoryColumns[] }` |
|
||||
| GET | `/table-management/menu/:menuObjid/category-columns` | 인증 | 메뉴별 카테고리 컬럼 | `{ categoryColumns[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 화면 관리 API (`/api/screen-management`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/screen-management/screens` | 인증 | 화면 목록 | `page, limit` | `{ screens[], total }` |
|
||||
| GET | `/screen-management/screens/:id` | 인증 | 화면 상세 | - | `{ screen }` |
|
||||
| POST | `/screen-management/screens` | 관리자 | 화면 생성 | - | `{ screen }` |
|
||||
| PUT | `/screen-management/screens/:id` | 관리자 | 화면 수정 | - | `{ screen }` |
|
||||
| DELETE | `/screen-management/screens/:id` | 관리자 | 화면 삭제 | - | `{ success: true }` |
|
||||
|
||||
### 화면 그룹
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/screen-groups` | 인증 | 화면 그룹 목록 | `{ screenGroups[] }` |
|
||||
| POST | `/screen-groups` | 관리자 | 그룹 생성 | `{ group }` |
|
||||
|
||||
### 화면 파일
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/screen-files` | 인증 | 화면 파일 목록 | `{ files[] }` |
|
||||
| POST | `/screen-files` | 관리자 | 파일 업로드 | `{ file }` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 플로우 API (`/api/flow`)
|
||||
|
||||
### 5.1 플로우 정의
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/flow/definitions` | 인증 | 플로우 목록 | - | `{ flows[] }` |
|
||||
| GET | `/flow/definitions/:id` | 인증 | 플로우 상세 | - | `{ flow }` |
|
||||
| POST | `/flow/definitions` | 인증 | 플로우 생성 | `{ name, description, targetTable }` | `{ flow }` |
|
||||
| PUT | `/flow/definitions/:id` | 인증 | 플로우 수정 | `{ name, description }` | `{ flow }` |
|
||||
| DELETE | `/flow/definitions/:id` | 인증 | 플로우 삭제 | - | `{ success: true }` |
|
||||
|
||||
### 5.2 단계 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/flow/definitions/:flowId/steps` | 인증 | 단계 목록 | - | `{ steps[] }` |
|
||||
| POST | `/flow/definitions/:flowId/steps` | 인증 | 단계 생성 | `{ name, type, settings }` | `{ step }` |
|
||||
| PUT | `/flow/steps/:stepId` | 인증 | 단계 수정 | `{ name, settings }` | `{ step }` |
|
||||
| DELETE | `/flow/steps/:stepId` | 인증 | 단계 삭제 | - | `{ success: true }` |
|
||||
|
||||
### 5.3 연결 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/flow/connections/:flowId` | 인증 | 연결 목록 | - | `{ connections[] }` |
|
||||
| POST | `/flow/connections` | 인증 | 연결 생성 | `{ fromStepId, toStepId, condition }` | `{ connection }` |
|
||||
| DELETE | `/flow/connections/:connectionId` | 인증 | 연결 삭제 | - | `{ success: true }` |
|
||||
|
||||
### 5.4 데이터 이동
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/flow/move` | 인증 | 데이터 이동 (단건) | `{ flowId, fromStepId, toStepId, recordId }` | `{ success: true }` |
|
||||
| POST | `/flow/move-batch` | 인증 | 데이터 이동 (다건) | `{ flowId, fromStepId, toStepId, recordIds[] }` | `{ success: true, movedCount }` |
|
||||
|
||||
### 5.5 단계 데이터 조회
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/flow/:flowId/step/:stepId/count` | 인증 | 단계 데이터 개수 | - | `{ count }` |
|
||||
| GET | `/flow/:flowId/step/:stepId/list` | 인증 | 단계 데이터 목록 | `page, limit` | `{ data[], total }` |
|
||||
| GET | `/flow/:flowId/step/:stepId/column-labels` | 인증 | 컬럼 라벨 조회 | - | `{ labels }` |
|
||||
| GET | `/flow/:flowId/steps/counts` | 인증 | 모든 단계 카운트 | - | `{ counts[] }` |
|
||||
|
||||
### 5.6 단계 데이터 수정
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| PUT | `/flow/:flowId/step/:stepId/data/:recordId` | 인증 | 인라인 편집 | `{ data }` | `{ success: true }` |
|
||||
|
||||
### 5.7 오딧 로그
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/flow/audit/:flowId/:recordId` | 인증 | 레코드별 오딧 로그 | `{ auditLogs[] }` |
|
||||
| GET | `/flow/audit/:flowId` | 인증 | 플로우 전체 오딧 로그 | `{ auditLogs[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 데이터플로우 API (`/api/dataflow`)
|
||||
|
||||
### 6.1 관계 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/dataflow/relationships` | 인증 | 관계 목록 | - | `{ relationships[] }` |
|
||||
| POST | `/dataflow/relationships` | 인증 | 관계 생성 | `{ fromTable, toTable, fromColumn, toColumn, type }` | `{ relationship }` |
|
||||
| PUT | `/dataflow/relationships/:id` | 인증 | 관계 수정 | `{ name, type }` | `{ relationship }` |
|
||||
| DELETE | `/dataflow/relationships/:id` | 인증 | 관계 삭제 | - | `{ success: true }` |
|
||||
|
||||
### 6.2 다이어그램
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/dataflow-diagrams` | 인증 | 다이어그램 목록 | - | `{ diagrams[] }` |
|
||||
| GET | `/dataflow-diagrams/:id` | 인증 | 다이어그램 상세 | - | `{ diagram }` |
|
||||
| POST | `/dataflow-diagrams` | 인증 | 다이어그램 생성 | `{ name, description }` | `{ diagram }` |
|
||||
|
||||
### 6.3 실행
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/dataflow` | 인증 | 데이터플로우 실행 | `{ relationshipId, params }` | `{ result[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 외부 연동 API
|
||||
|
||||
### 7.1 외부 DB 연결 (`/api/external-db-connections`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/external-db-connections` | 인증 | 연결 목록 | - | `{ connections[] }` |
|
||||
| GET | `/external-db-connections/:id` | 인증 | 연결 상세 | - | `{ connection }` |
|
||||
| POST | `/external-db-connections` | 관리자 | 연결 생성 | `{ connectionName, dbType, host, port, database, username, password }` | `{ connection }` |
|
||||
| PUT | `/external-db-connections/:id` | 관리자 | 연결 수정 | `{ connectionName, ... }` | `{ connection }` |
|
||||
| DELETE | `/external-db-connections/:id` | 관리자 | 연결 삭제 | - | `{ success: true }` |
|
||||
| POST | `/external-db-connections/:id/test` | 인증 | 연결 테스트 | - | `{ success: boolean, message }` |
|
||||
| GET | `/external-db-connections/:id/tables` | 인증 | 테이블 목록 조회 | - | `{ tables[] }` |
|
||||
| GET | `/external-db-connections/:id/tables/:table/columns` | 인증 | 컬럼 목록 조회 | - | `{ columns[] }` |
|
||||
|
||||
### 7.2 외부 REST API (`/api/external-rest-api-connections`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/external-rest-api-connections` | 인증 | API 연결 목록 | - | `{ connections[] }` |
|
||||
| POST | `/external-rest-api-connections` | 관리자 | API 연결 생성 | `{ name, baseUrl, authType, ... }` | `{ connection }` |
|
||||
| POST | `/external-rest-api-connections/:id/test` | 인증 | API 테스트 | `{ endpoint, method }` | `{ response }` |
|
||||
|
||||
### 7.3 멀티 커넥션 (`/api/multi-connection`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/multi-connection/query` | 인증 | 멀티 DB 쿼리 | `{ connections: [{ connectionId, sql }] }` | `{ results[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 배치 API
|
||||
|
||||
### 8.1 배치 설정 (`/api/batch-configs`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/batch-configs` | 인증 | 배치 설정 목록 | - | `{ batchConfigs[] }` |
|
||||
| GET | `/batch-configs/:id` | 인증 | 배치 설정 상세 | - | `{ batchConfig }` |
|
||||
| POST | `/batch-configs` | 관리자 | 배치 설정 생성 | `{ batchName, cronSchedule, sourceConnection, targetTable, mappings }` | `{ batchConfig }` |
|
||||
| PUT | `/batch-configs/:id` | 관리자 | 배치 설정 수정 | `{ batchName, ... }` | `{ batchConfig }` |
|
||||
| DELETE | `/batch-configs/:id` | 관리자 | 배치 설정 삭제 | - | `{ success: true }` |
|
||||
| GET | `/batch-configs/connections` | 관리자 | 사용 가능한 커넥션 목록 | - | `{ connections[] }` |
|
||||
| GET | `/batch-configs/connections/:type/tables` | 관리자 | 테이블 목록 조회 | - | `{ tables[] }` |
|
||||
|
||||
### 8.2 배치 실행 (`/api/batch-management`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/batch-management/:id/execute` | 관리자 | 배치 즉시 실행 | - | `{ success: true, executionLogId }` |
|
||||
|
||||
### 8.3 실행 이력 (`/api/batch-execution-logs`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/batch-execution-logs` | 인증 | 실행 이력 목록 | `batchConfigId, page, limit` | `{ logs[], total }` |
|
||||
| GET | `/batch-execution-logs/:id` | 인증 | 실행 이력 상세 | - | `{ log }` |
|
||||
|
||||
---
|
||||
|
||||
## 9. 메일 API (`/api/mail`)
|
||||
|
||||
### 9.1 계정 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/mail/accounts` | 인증 | 계정 목록 | - | `{ accounts[] }` |
|
||||
| POST | `/mail/accounts` | 관리자 | 계정 추가 | `{ email, smtpHost, smtpPort, password }` | `{ account }` |
|
||||
|
||||
### 9.2 템플릿
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/mail/templates-file` | 인증 | 템플릿 목록 | - | `{ templates[] }` |
|
||||
| POST | `/mail/templates-file` | 관리자 | 템플릿 생성 | `{ name, subject, body }` | `{ template }` |
|
||||
|
||||
### 9.3 발송/수신
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/mail/send` | 인증 | 메일 발송 | `{ accountId, to, subject, body, attachments[] }` | `{ success: true, messageId }` |
|
||||
| GET | `/mail/sent` | 인증 | 발송 이력 | `page, limit` | `{ mails[], total }` |
|
||||
| POST | `/mail/receive` | 인증 | 메일 수신 | `{ accountId }` | `{ mails[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 10. 파일 API (`/api/files`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/files/upload` | 인증 | 파일 업로드 (multipart) | `FormData { file }` | `{ fileId, fileName, filePath, fileSize }` |
|
||||
| GET | `/files` | 인증 | 파일 목록 | `page, limit` | `{ files[], total }` |
|
||||
| GET | `/files/:id` | 인증 | 파일 정보 조회 | - | `{ file }` |
|
||||
| GET | `/files/download/:id` | 인증 | 파일 다운로드 | - | `(파일 스트림)` |
|
||||
| DELETE | `/files/:id` | 인증 | 파일 삭제 | - | `{ success: true }` |
|
||||
| GET | `/uploads/:filename` | 공개 | 정적 파일 서빙 | - | `(파일 스트림)` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 대시보드 API (`/api/dashboards`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/dashboards` | 인증 | 대시보드 목록 | - | `{ dashboards[] }` |
|
||||
| GET | `/dashboards/:id` | 인증 | 대시보드 상세 | - | `{ dashboard }` |
|
||||
| POST | `/dashboards` | 관리자 | 대시보드 생성 | - | `{ dashboard }` |
|
||||
| GET | `/dashboards/:id/widgets` | 인증 | 위젯 데이터 조회 | - | `{ widgets[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 12. 공통코드 API (`/api/common-codes`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/common-codes` | 인증 | 공통코드 목록 | `codeGroup` | `{ codes[] }` |
|
||||
| GET | `/common-codes/:codeGroup/:code` | 인증 | 공통코드 상세 | - | `{ code }` |
|
||||
| POST | `/common-codes` | 관리자 | 공통코드 생성 | `{ codeGroup, code, name }` | `{ code }` |
|
||||
|
||||
---
|
||||
|
||||
## 13. 다국어 API (`/api/multilang`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/multilang` | 인증 | 다국어 키 목록 | `lang` | `{ translations{} }` |
|
||||
| GET | `/multilang/:key` | 인증 | 특정 키 조회 | `lang` | `{ key, value }` |
|
||||
| POST | `/multilang` | 관리자 | 다국어 추가 | `{ key, ko, en, cn }` | `{ translation }` |
|
||||
|
||||
---
|
||||
|
||||
## 14. 회사 관리 API (`/api/company-management`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/company-management` | 슈퍼관리자 | 회사 목록 | - | `{ companies[] }` |
|
||||
| POST | `/company-management` | 슈퍼관리자 | 회사 생성 | `{ companyCode, companyName }` | `{ company }` |
|
||||
| PUT | `/company-management/:code` | 슈퍼관리자 | 회사 수정 | `{ companyName }` | `{ company }` |
|
||||
| DELETE | `/company-management/:code` | 슈퍼관리자 | 회사 삭제 | - | `{ success: true }` |
|
||||
|
||||
---
|
||||
|
||||
## 15. 부서 API (`/api/departments`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/departments` | 인증 | 부서 목록 (트리) | - | `{ departments[] }` |
|
||||
| POST | `/departments` | 관리자 | 부서 생성 | `{ deptCode, deptName, parentDeptCode }` | `{ department }` |
|
||||
|
||||
---
|
||||
|
||||
## 16. 권한 그룹 API (`/api/roles`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/roles` | 인증 | 권한 그룹 목록 | - | `{ roles[] }` |
|
||||
| POST | `/roles` | 관리자 | 권한 그룹 생성 | `{ roleName, permissions[] }` | `{ role }` |
|
||||
|
||||
---
|
||||
|
||||
## 17. DDL 실행 API (`/api/ddl`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/ddl` | 슈퍼관리자 | DDL 실행 | `{ sql }` | `{ success: true, result }` |
|
||||
|
||||
---
|
||||
|
||||
## 18. 외부 API 프록시 (`/api/open-api`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/open-api/weather` | 인증 | 날씨 정보 조회 | `location` | `{ weather }` |
|
||||
| GET | `/open-api/exchange` | 인증 | 환율 정보 조회 | `fromCurrency, toCurrency` | `{ rate }` |
|
||||
|
||||
---
|
||||
|
||||
## 19. 디지털 트윈 API (`/api/digital-twin`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/digital-twin/layouts` | 인증 | 레이아웃 목록 | - | `{ layouts[] }` |
|
||||
| GET | `/digital-twin/templates` | 인증 | 템플릿 목록 | - | `{ templates[] }` |
|
||||
| GET | `/digital-twin/data` | 인증 | 실시간 데이터 | - | `{ data[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 20. 3D 필드 API (`/api/yard-layouts`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/yard-layouts` | 인증 | 필드 레이아웃 목록 | - | `{ yardLayouts[] }` |
|
||||
| POST | `/yard-layouts` | 인증 | 레이아웃 저장 | `{ layout }` | `{ success: true }` |
|
||||
|
||||
---
|
||||
|
||||
## 21. 스케줄 API (`/api/schedule`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/schedule` | 인증 | 스케줄 자동 생성 | `{ params }` | `{ schedule }` |
|
||||
|
||||
---
|
||||
|
||||
## 22. 채번 규칙 API (`/api/numbering-rules`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/numbering-rules` | 인증 | 채번 규칙 목록 | - | `{ rules[] }` |
|
||||
| POST | `/numbering-rules` | 관리자 | 규칙 생성 | `{ ruleName, prefix, format }` | `{ rule }` |
|
||||
| POST | `/numbering-rules/:id/generate` | 인증 | 번호 생성 | - | `{ number }` |
|
||||
|
||||
---
|
||||
|
||||
## 23. 엔티티 검색 API (`/api/entity-search`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/entity-search` | 인증 | 엔티티 검색 | `{ table, filters, page, limit }` | `{ results[], total }` |
|
||||
| GET | `/entity/:table/options` | 인증 | V2Select용 옵션 | `search, limit` | `{ options[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 24. To-Do API (`/api/todos`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/todos` | 인증 | To-Do 목록 | `status, assignee` | `{ todos[] }` |
|
||||
| POST | `/todos` | 인증 | To-Do 생성 | `{ title, description, dueDate }` | `{ todo }` |
|
||||
| PUT | `/todos/:id` | 인증 | To-Do 수정 | `{ status }` | `{ todo }` |
|
||||
|
||||
---
|
||||
|
||||
## 25. 예약 요청 API (`/api/bookings`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/bookings` | 인증 | 예약 목록 | - | `{ bookings[] }` |
|
||||
| POST | `/bookings` | 인증 | 예약 생성 | `{ resourceId, startTime, endTime }` | `{ booking }` |
|
||||
|
||||
---
|
||||
|
||||
## 26. 리스크/알림 API (`/api/risk-alerts`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/risk-alerts` | 인증 | 리스크/알림 목록 | `priority, status` | `{ alerts[] }` |
|
||||
| POST | `/risk-alerts` | 인증 | 알림 생성 | `{ title, content, priority }` | `{ alert }` |
|
||||
|
||||
---
|
||||
|
||||
## 27. 헬스 체크
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/health` | 공개 | 서버 상태 확인 | `{ status: "OK", timestamp, uptime, environment }` |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 에러 코드 목록
|
||||
|
||||
| 코드 | HTTP Status | 설명 |
|
||||
|------|-------------|------|
|
||||
| `TOKEN_MISSING` | 401 | 인증 토큰 누락 |
|
||||
| `TOKEN_EXPIRED` | 401 | 토큰 만료 |
|
||||
| `INVALID_TOKEN` | 401 | 유효하지 않은 토큰 |
|
||||
| `AUTHENTICATION_REQUIRED` | 401 | 인증 필요 |
|
||||
| `INSUFFICIENT_PERMISSION` | 403 | 권한 부족 |
|
||||
| `SUPER_ADMIN_REQUIRED` | 403 | 슈퍼관리자 권한 필요 |
|
||||
| `COMPANY_ACCESS_DENIED` | 403 | 회사 데이터 접근 거부 |
|
||||
| `INVALID_INPUT` | 400 | 잘못된 입력 |
|
||||
| `RESOURCE_NOT_FOUND` | 404 | 리소스 없음 |
|
||||
| `DUPLICATE_ENTRY` | 400 | 중복 데이터 |
|
||||
| `FOREIGN_KEY_VIOLATION` | 400 | 참조 무결성 위반 |
|
||||
| `SERVER_ERROR` | 500 | 서버 오류 |
|
||||
|
||||
---
|
||||
|
||||
**문서 버전**: 1.0
|
||||
**마지막 업데이트**: 2026-02-06
|
||||
**총 API 개수**: 200+개
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,342 @@
|
|||
# WACE ERP Backend - 아키텍처 요약
|
||||
|
||||
> **작성일**: 2026-02-06
|
||||
> **목적**: 워크플로우 문서 통합용 백엔드 아키텍처 요약
|
||||
|
||||
---
|
||||
|
||||
## 1. 기술 스택
|
||||
|
||||
```
|
||||
언어: TypeScript (Node.js 20.10.0+)
|
||||
프레임워크: Express.js
|
||||
데이터베이스: PostgreSQL (pg 라이브러리, Raw Query)
|
||||
인증: JWT (jsonwebtoken)
|
||||
스케줄러: node-cron
|
||||
메일: nodemailer + IMAP
|
||||
파일업로드: multer
|
||||
외부DB: MySQL, MSSQL, Oracle 지원
|
||||
```
|
||||
|
||||
## 2. 계층 구조
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Controller │ ← API 요청 수신, 응답 생성
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Service │ ← 비즈니스 로직, 트랜잭션 관리
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Database │ ← PostgreSQL Raw Query
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 3. 디렉토리 구조
|
||||
|
||||
```
|
||||
backend-node/src/
|
||||
├── app.ts # Express 앱 진입점
|
||||
├── config/ # 환경설정
|
||||
├── controllers/ # 70+ 컨트롤러
|
||||
├── services/ # 80+ 서비스
|
||||
├── routes/ # 70+ 라우터
|
||||
├── middleware/ # 인증/권한/에러핸들러
|
||||
├── database/ # DB 연결 (pg Pool)
|
||||
├── types/ # TypeScript 타입 (26개)
|
||||
└── utils/ # 유틸리티 (JWT, 암호화, 로거)
|
||||
```
|
||||
|
||||
## 4. 미들웨어 스택 순서
|
||||
|
||||
```typescript
|
||||
1. Process Level Exception Handlers (unhandledRejection, uncaughtException)
|
||||
2. Helmet (보안 헤더)
|
||||
3. Compression (Gzip)
|
||||
4. Body Parser (JSON, URL-encoded, 10MB limit)
|
||||
5. Static Files (/uploads)
|
||||
6. CORS (credentials: true)
|
||||
7. Rate Limiting (1분 10000회)
|
||||
8. Token Auto Refresh (1시간 이내 만료 시 갱신)
|
||||
9. API Routes (70+개)
|
||||
10. 404 Handler
|
||||
11. Error Handler
|
||||
```
|
||||
|
||||
## 5. 인증/인가 시스템
|
||||
|
||||
### 5.1 인증 흐름
|
||||
|
||||
```
|
||||
로그인 요청
|
||||
↓
|
||||
AuthController.login()
|
||||
↓
|
||||
AuthService.processLogin()
|
||||
├─ loginPwdCheck() → 비밀번호 검증 (bcrypt)
|
||||
├─ getPersonBeanFromSession() → 사용자 정보 조회
|
||||
├─ insertLoginAccessLog() → 로그인 이력 저장
|
||||
└─ JwtUtils.generateToken() → JWT 토큰 생성
|
||||
↓
|
||||
응답: { token, userInfo, firstMenuPath }
|
||||
```
|
||||
|
||||
### 5.2 JWT Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": "user123",
|
||||
"userName": "홍길동",
|
||||
"companyCode": "ILSHIN",
|
||||
"userType": "COMPANY_ADMIN",
|
||||
"iat": 1234567890,
|
||||
"exp": 1234654290,
|
||||
"iss": "PMS-System"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 권한 체계 (3단계)
|
||||
|
||||
| 권한 | company_code | userType | 권한 범위 |
|
||||
|------|--------------|----------|-----------|
|
||||
| **SUPER_ADMIN** | `*` | `SUPER_ADMIN` | 전체 회사, DDL 실행, 회사 생성/삭제 |
|
||||
| **COMPANY_ADMIN** | `ILSHIN` | `COMPANY_ADMIN` | 자기 회사만, 사용자/설정 관리 |
|
||||
| **USER** | `ILSHIN` | `USER` | 자기 회사만, 읽기/쓰기 |
|
||||
|
||||
## 6. 멀티테넌시 구현
|
||||
|
||||
### 핵심 원칙
|
||||
```typescript
|
||||
// ✅ 올바른 패턴
|
||||
const companyCode = req.user!.companyCode; // JWT에서 추출
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 슈퍼관리자: 모든 데이터 조회
|
||||
query = "SELECT * FROM table ORDER BY company_code";
|
||||
} else {
|
||||
// 일반 사용자: 자기 회사 + 슈퍼관리자 데이터 제외
|
||||
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
|
||||
params = [companyCode];
|
||||
}
|
||||
|
||||
// ❌ 잘못된 패턴 (보안 위험!)
|
||||
const companyCode = req.body.companyCode; // 클라이언트에서 받음
|
||||
```
|
||||
|
||||
### 슈퍼관리자 숨김 규칙
|
||||
```sql
|
||||
-- 일반 회사 사용자에게 슈퍼관리자(company_code='*')는 보이면 안 됨
|
||||
SELECT * FROM user_info
|
||||
WHERE company_code = $1
|
||||
AND company_code != '*' -- 필수!
|
||||
```
|
||||
|
||||
## 7. API 라우트 (70+개)
|
||||
|
||||
### 7.1 인증/관리자
|
||||
- `POST /api/auth/login` - 로그인
|
||||
- `GET /api/auth/me` - 현재 사용자 정보
|
||||
- `POST /api/auth/switch-company` - 회사 전환 (슈퍼관리자)
|
||||
- `GET /api/admin/users` - 사용자 목록
|
||||
- `GET /api/admin/menus` - 메뉴 목록
|
||||
|
||||
### 7.2 테이블/화면
|
||||
- `GET /api/table-management/tables` - 테이블 목록
|
||||
- `POST /api/table-management/tables/:table/data` - 데이터 조회
|
||||
- `POST /api/table-management/multi-table-save` - 다중 테이블 저장
|
||||
- `GET /api/screen-management/screens` - 화면 목록
|
||||
|
||||
### 7.3 플로우
|
||||
- `GET /api/flow/definitions` - 플로우 정의 목록
|
||||
- `POST /api/flow/move` - 데이터 이동 (단건)
|
||||
- `POST /api/flow/move-batch` - 데이터 이동 (다건)
|
||||
|
||||
### 7.4 외부 연동
|
||||
- `GET /api/external-db-connections` - 외부 DB 연결 목록
|
||||
- `POST /api/external-db-connections/:id/test` - 연결 테스트
|
||||
- `POST /api/multi-connection/query` - 멀티 DB 쿼리
|
||||
|
||||
### 7.5 배치
|
||||
- `GET /api/batch-configs` - 배치 설정 목록
|
||||
- `POST /api/batch-management/:id/execute` - 배치 즉시 실행
|
||||
|
||||
### 7.6 메일
|
||||
- `POST /api/mail/send` - 메일 발송
|
||||
- `GET /api/mail/sent` - 발송 이력
|
||||
|
||||
### 7.7 파일
|
||||
- `POST /api/files/upload` - 파일 업로드
|
||||
- `GET /uploads/:filename` - 정적 파일 서빙
|
||||
|
||||
## 8. 비즈니스 도메인 (8개)
|
||||
|
||||
| 도메인 | 컨트롤러 | 주요 기능 |
|
||||
|--------|----------|-----------|
|
||||
| **관리자** | `adminController` | 사용자/메뉴/권한 관리 |
|
||||
| **테이블/화면** | `tableManagementController` | 메타데이터, 동적 화면 생성 |
|
||||
| **플로우** | `flowController` | 워크플로우 엔진, 데이터 이동 |
|
||||
| **데이터플로우** | `dataflowController` | ERD, 관계도 |
|
||||
| **외부 연동** | `externalDbConnectionController` | 외부 DB/REST API |
|
||||
| **배치** | `batchController` | Cron 스케줄러, 데이터 동기화 |
|
||||
| **메일** | `mailSendSimpleController` | 메일 발송/수신 |
|
||||
| **파일** | `fileController` | 파일 업로드/다운로드 |
|
||||
|
||||
## 9. 데이터베이스 접근
|
||||
|
||||
### Connection Pool 설정
|
||||
```typescript
|
||||
{
|
||||
min: 2~5, // 최소 연결 수
|
||||
max: 10~20, // 최대 연결 수
|
||||
connectionTimeout: 30000, // 30초
|
||||
idleTimeout: 600000, // 10분
|
||||
statementTimeout: 60000 // 쿼리 실행 60초
|
||||
}
|
||||
```
|
||||
|
||||
### Raw Query 패턴
|
||||
```typescript
|
||||
// 1. 다중 행
|
||||
const users = await query('SELECT * FROM user_info WHERE company_code = $1', [companyCode]);
|
||||
|
||||
// 2. 단일 행
|
||||
const user = await queryOne('SELECT * FROM user_info WHERE user_id = $1', [userId]);
|
||||
|
||||
// 3. 트랜잭션
|
||||
await transaction(async (client) => {
|
||||
await client.query('INSERT INTO table1 ...', [...]);
|
||||
await client.query('INSERT INTO table2 ...', [...]);
|
||||
});
|
||||
```
|
||||
|
||||
## 10. 외부 시스템 연동
|
||||
|
||||
### 지원 데이터베이스
|
||||
- PostgreSQL
|
||||
- MySQL
|
||||
- Microsoft SQL Server
|
||||
- Oracle
|
||||
|
||||
### Connector Factory Pattern
|
||||
```typescript
|
||||
DatabaseConnectorFactory
|
||||
├── PostgreSQLConnector
|
||||
├── MySQLConnector
|
||||
├── MSSQLConnector
|
||||
└── OracleConnector
|
||||
```
|
||||
|
||||
## 11. 배치/스케줄 시스템
|
||||
|
||||
### Cron 스케줄러
|
||||
```typescript
|
||||
// node-cron 기반
|
||||
// 매일 새벽 2시: "0 2 * * *"
|
||||
// 5분마다: "*/5 * * * *"
|
||||
// 평일 오전 8시: "0 8 * * 1-5"
|
||||
|
||||
// 서버 시작 시 자동 초기화
|
||||
BatchSchedulerService.initializeScheduler();
|
||||
```
|
||||
|
||||
### 배치 실행 흐름
|
||||
```
|
||||
1. 소스 DB에서 데이터 조회
|
||||
↓
|
||||
2. 컬럼 매핑 적용
|
||||
↓
|
||||
3. 타겟 DB에 INSERT/UPDATE
|
||||
↓
|
||||
4. 실행 로그 기록 (batch_execution_logs)
|
||||
```
|
||||
|
||||
## 12. 파일 처리
|
||||
|
||||
### 업로드 경로
|
||||
```
|
||||
uploads/
|
||||
└── {company_code}/
|
||||
└── {timestamp}-{uuid}-{filename}
|
||||
```
|
||||
|
||||
### Multer 설정
|
||||
- 최대 파일 크기: 10MB
|
||||
- 허용 타입: 이미지, PDF, Office 문서
|
||||
- 파일명 중복 방지: 타임스탬프 + UUID
|
||||
|
||||
## 13. 보안
|
||||
|
||||
### 암호화
|
||||
- **비밀번호**: bcrypt (12 rounds)
|
||||
- **민감정보**: AES-256-CBC (외부 DB 비밀번호 등)
|
||||
- **JWT Secret**: 환경변수 관리
|
||||
|
||||
### 보안 헤더
|
||||
- Helmet (CSP, X-Frame-Options)
|
||||
- CORS (credentials: true)
|
||||
- Rate Limiting (1분 10000회)
|
||||
|
||||
### SQL Injection 방지
|
||||
- Parameterized Query 사용 (pg 라이브러리)
|
||||
- 동적 쿼리 빌더 패턴
|
||||
|
||||
## 14. 에러 핸들링
|
||||
|
||||
### PostgreSQL 에러 코드 매핑
|
||||
- `23505` → "중복된 데이터"
|
||||
- `23503` → "참조 무결성 위반"
|
||||
- `23502` → "필수 입력값 누락"
|
||||
|
||||
### 프로세스 레벨
|
||||
- `unhandledRejection` → 로깅 (서버 유지)
|
||||
- `uncaughtException` → 로깅 (서버 유지, 주의)
|
||||
- `SIGTERM/SIGINT` → Graceful Shutdown
|
||||
|
||||
## 15. 로깅 (Winston)
|
||||
|
||||
### 로그 파일
|
||||
- `logs/error.log` - 에러만 (10MB × 5파일)
|
||||
- `logs/combined.log` - 전체 로그 (10MB × 10파일)
|
||||
|
||||
### 로그 레벨
|
||||
```
|
||||
error (0) → warn (1) → info (2) → debug (5)
|
||||
```
|
||||
|
||||
## 16. 성능 최적화
|
||||
|
||||
### Pool 모니터링
|
||||
- 5분마다 상태 체크
|
||||
- 대기 연결 5개 이상 시 경고
|
||||
|
||||
### 느린 쿼리 감지
|
||||
- 1초 이상 걸린 쿼리 자동 경고
|
||||
|
||||
### 캐싱 (Redis)
|
||||
- 메뉴 목록: 10분 TTL
|
||||
- 공통코드: 30분 TTL
|
||||
|
||||
### Gzip 압축
|
||||
- 1KB 이상 응답만 압축 (레벨 6)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 핵심 체크리스트
|
||||
|
||||
### 개발 시 반드시 지켜야 할 규칙
|
||||
|
||||
✅ **모든 쿼리에 `company_code` 필터 추가**
|
||||
✅ **JWT 토큰에서 `company_code` 추출 (클라이언트 신뢰 금지)**
|
||||
✅ **Parameterized Query 사용 (SQL Injection 방지)**
|
||||
✅ **슈퍼관리자 데이터 숨김 (`company_code != '*'`)**
|
||||
✅ **비밀번호는 bcrypt, 민감정보는 AES-256**
|
||||
✅ **에러 핸들링 try/catch 필수**
|
||||
✅ **트랜잭션이 필요한 경우 `transaction()` 사용**
|
||||
✅ **파일 업로드는 회사별 디렉토리 분리**
|
||||
|
||||
---
|
||||
|
||||
**문서 버전**: 1.0
|
||||
**마지막 업데이트**: 2026-02-06
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
Copy,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
|
|
@ -140,11 +141,22 @@ export default function TableManagementPage() {
|
|||
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
||||
const [logViewerTableName, setLogViewerTableName] = useState<string>("");
|
||||
|
||||
// 저장 중 상태 (중복 실행 방지)
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 테이블 삭제 확인 다이얼로그 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [tableToDelete, setTableToDelete] = useState<string>("");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// PK/인덱스 관리 상태
|
||||
const [constraints, setConstraints] = useState<{
|
||||
primaryKey: { name: string; columns: string[] };
|
||||
indexes: Array<{ name: string; columns: string[]; isUnique: boolean }>;
|
||||
}>({ primaryKey: { name: "", columns: [] }, indexes: [] });
|
||||
const [pkDialogOpen, setPkDialogOpen] = useState(false);
|
||||
const [pendingPkColumns, setPendingPkColumns] = useState<string[]>([]);
|
||||
|
||||
// 선택된 테이블 목록 (체크박스)
|
||||
const [selectedTableIds, setSelectedTableIds] = useState<Set<string>>(new Set());
|
||||
|
||||
|
|
@ -397,6 +409,19 @@ export default function TableManagementPage() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// PK/인덱스 제약조건 로드
|
||||
const loadConstraints = useCallback(async (tableName: string) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/constraints`);
|
||||
if (response.data.success) {
|
||||
setConstraints(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("제약조건 로드 실패:", error);
|
||||
setConstraints({ primaryKey: { name: "", columns: [] }, indexes: [] });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 테이블 선택
|
||||
const handleTableSelect = useCallback(
|
||||
(tableName: string) => {
|
||||
|
|
@ -410,8 +435,9 @@ export default function TableManagementPage() {
|
|||
setTableDescription(tableInfo?.description || "");
|
||||
|
||||
loadColumnTypes(tableName, 1, pageSize);
|
||||
loadConstraints(tableName);
|
||||
},
|
||||
[loadColumnTypes, pageSize, tables],
|
||||
[loadColumnTypes, loadConstraints, pageSize, tables],
|
||||
);
|
||||
|
||||
// 입력 타입 변경
|
||||
|
|
@ -757,7 +783,9 @@ export default function TableManagementPage() {
|
|||
// 전체 저장 (테이블 라벨 + 모든 컬럼 설정)
|
||||
const saveAllSettings = async () => {
|
||||
if (!selectedTable) return;
|
||||
if (isSaving) return; // 저장 중 중복 실행 방지
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// 1. 테이블 라벨 저장 (변경된 경우에만)
|
||||
if (tableLabel !== selectedTable || tableDescription) {
|
||||
|
|
@ -952,9 +980,30 @@ export default function TableManagementPage() {
|
|||
} catch (error) {
|
||||
// console.error("설정 저장 실패:", error);
|
||||
toast.error("설정 저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Ctrl+S 단축키: 테이블 설정 전체 저장
|
||||
// saveAllSettings를 ref로 참조하여 useEffect 의존성 문제 방지
|
||||
const saveAllSettingsRef = useRef(saveAllSettings);
|
||||
saveAllSettingsRef.current = saveAllSettings;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault(); // 브라우저 기본 저장 동작 방지
|
||||
if (selectedTable && columns.length > 0) {
|
||||
saveAllSettingsRef.current();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedTable, columns.length]);
|
||||
|
||||
// 필터링된 테이블 목록 (메모이제이션)
|
||||
const filteredTables = useMemo(
|
||||
() =>
|
||||
|
|
@ -1000,6 +1049,123 @@ export default function TableManagementPage() {
|
|||
}
|
||||
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
|
||||
|
||||
// PK 체크박스 변경 핸들러
|
||||
const handlePkToggle = useCallback(
|
||||
(columnName: string, checked: boolean) => {
|
||||
const currentPkCols = [...constraints.primaryKey.columns];
|
||||
let newPkCols: string[];
|
||||
if (checked) {
|
||||
newPkCols = [...currentPkCols, columnName];
|
||||
} else {
|
||||
newPkCols = currentPkCols.filter((c) => c !== columnName);
|
||||
}
|
||||
// PK 변경은 확인 다이얼로그 표시
|
||||
setPendingPkColumns(newPkCols);
|
||||
setPkDialogOpen(true);
|
||||
},
|
||||
[constraints.primaryKey.columns],
|
||||
);
|
||||
|
||||
// PK 변경 확인
|
||||
const handlePkConfirm = async () => {
|
||||
if (!selectedTable) return;
|
||||
try {
|
||||
if (pendingPkColumns.length === 0) {
|
||||
toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다.");
|
||||
setPkDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, {
|
||||
columns: pendingPkColumns,
|
||||
});
|
||||
if (response.data.success) {
|
||||
toast.success(response.data.message);
|
||||
await loadConstraints(selectedTable);
|
||||
} else {
|
||||
toast.error(response.data.message || "PK 설정 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || "PK 설정 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setPkDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 인덱스 토글 핸들러
|
||||
const handleIndexToggle = useCallback(
|
||||
async (columnName: string, indexType: "index" | "unique", checked: boolean) => {
|
||||
if (!selectedTable) return;
|
||||
const action = checked ? "create" : "drop";
|
||||
try {
|
||||
const response = await apiClient.post(`/table-management/tables/${selectedTable}/indexes`, {
|
||||
columnName,
|
||||
indexType,
|
||||
action,
|
||||
});
|
||||
if (response.data.success) {
|
||||
toast.success(response.data.message);
|
||||
await loadConstraints(selectedTable);
|
||||
} else {
|
||||
toast.error(response.data.message || "인덱스 설정 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || error?.response?.data?.error || "인덱스 설정 중 오류가 발생했습니다.");
|
||||
}
|
||||
},
|
||||
[selectedTable, loadConstraints],
|
||||
);
|
||||
|
||||
// 컬럼별 인덱스 상태 헬퍼
|
||||
const getColumnIndexState = useCallback(
|
||||
(columnName: string) => {
|
||||
const isPk = constraints.primaryKey.columns.includes(columnName);
|
||||
const hasIndex = constraints.indexes.some(
|
||||
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
|
||||
);
|
||||
const hasUnique = constraints.indexes.some(
|
||||
(idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
|
||||
);
|
||||
return { isPk, hasIndex, hasUnique };
|
||||
},
|
||||
[constraints],
|
||||
);
|
||||
|
||||
// NOT NULL 토글 핸들러
|
||||
const handleNullableToggle = useCallback(
|
||||
async (columnName: string, currentIsNullable: string) => {
|
||||
if (!selectedTable) return;
|
||||
// isNullable이 "YES"면 nullable, "NO"면 NOT NULL
|
||||
// 체크박스 체크 = NOT NULL 설정 (nullable: false)
|
||||
// 체크박스 해제 = NOT NULL 해제 (nullable: true)
|
||||
const isCurrentlyNotNull = currentIsNullable === "NO";
|
||||
const newNullable = isCurrentlyNotNull; // NOT NULL이면 해제, NULL이면 설정
|
||||
try {
|
||||
const response = await apiClient.put(
|
||||
`/table-management/tables/${selectedTable}/columns/${columnName}/nullable`,
|
||||
{ nullable: newNullable },
|
||||
);
|
||||
if (response.data.success) {
|
||||
toast.success(response.data.message);
|
||||
// 컬럼 상태 로컬 업데이트
|
||||
setColumns((prev) =>
|
||||
prev.map((col) =>
|
||||
col.columnName === columnName
|
||||
? { ...col, isNullable: newNullable ? "YES" : "NO" }
|
||||
: col,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
toast.error(response.data.message || "NOT NULL 설정 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error?.response?.data?.message || "NOT NULL 설정 중 오류가 발생했습니다.",
|
||||
);
|
||||
}
|
||||
},
|
||||
[selectedTable],
|
||||
);
|
||||
|
||||
// 테이블 삭제 확인
|
||||
const handleDeleteTableClick = (tableName: string) => {
|
||||
setTableToDelete(tableName);
|
||||
|
|
@ -1367,11 +1533,15 @@ export default function TableManagementPage() {
|
|||
{/* 저장 버튼 (항상 보이도록 상단에 배치) */}
|
||||
<Button
|
||||
onClick={saveAllSettings}
|
||||
disabled={!selectedTable || columns.length === 0}
|
||||
disabled={!selectedTable || columns.length === 0 || isSaving}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
전체 설정 저장
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Settings className="h-4 w-4" />
|
||||
)}
|
||||
{isSaving ? "저장 중..." : "전체 설정 저장"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
@ -1391,12 +1561,16 @@ export default function TableManagementPage() {
|
|||
{/* 컬럼 헤더 (고정) */}
|
||||
<div
|
||||
className="text-foreground grid h-12 flex-shrink-0 items-center border-b px-6 py-3 text-sm font-semibold"
|
||||
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
||||
style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
|
||||
>
|
||||
<div className="pr-4">컬럼명</div>
|
||||
<div className="px-4">라벨</div>
|
||||
<div className="pr-4">라벨</div>
|
||||
<div className="px-4">컬럼명</div>
|
||||
<div className="pr-6">입력 타입</div>
|
||||
<div className="pl-4">설명</div>
|
||||
<div className="text-center text-xs">Primary</div>
|
||||
<div className="text-center text-xs">NotNull</div>
|
||||
<div className="text-center text-xs">Index</div>
|
||||
<div className="text-center text-xs">Unique</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 리스트 (스크롤 영역) */}
|
||||
|
|
@ -1410,16 +1584,15 @@ export default function TableManagementPage() {
|
|||
}
|
||||
}}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
{columns.map((column, index) => {
|
||||
const idxState = getColumnIndexState(column.columnName);
|
||||
return (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
|
||||
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
|
||||
style={{ gridTemplateColumns: "160px 200px 250px minmax(80px,1fr) 70px 70px 70px 70px" }}
|
||||
>
|
||||
<div className="pt-1 pr-4">
|
||||
<div className="font-mono text-sm">{column.columnName}</div>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<div className="pr-4">
|
||||
<Input
|
||||
value={column.displayName || ""}
|
||||
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
||||
|
|
@ -1427,6 +1600,9 @@ export default function TableManagementPage() {
|
|||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 pt-1">
|
||||
<div className="font-mono text-sm">{column.columnName}</div>
|
||||
</div>
|
||||
<div className="pr-6">
|
||||
<div className="space-y-3">
|
||||
{/* 입력 타입 선택 */}
|
||||
|
|
@ -1689,141 +1865,11 @@ export default function TableManagementPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 표시 컬럼 - 검색 가능한 Combobox */}
|
||||
{column.referenceTable &&
|
||||
column.referenceTable !== "none" &&
|
||||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" && (
|
||||
<div className="w-56">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">표시 컬럼</label>
|
||||
<Popover
|
||||
open={entityComboboxOpen[column.columnName]?.displayColumn || false}
|
||||
onOpenChange={(open) =>
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
displayColumn: open,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={
|
||||
entityComboboxOpen[column.columnName]?.displayColumn || false
|
||||
}
|
||||
className="bg-background h-8 w-full justify-between text-xs"
|
||||
disabled={
|
||||
!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0
|
||||
}
|
||||
>
|
||||
{!referenceTableColumns[column.referenceTable] ||
|
||||
referenceTableColumns[column.referenceTable].length === 0 ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<div className="border-primary h-3 w-3 animate-spin rounded-full border border-t-transparent"></div>
|
||||
로딩중...
|
||||
</span>
|
||||
) : column.displayColumn && column.displayColumn !== "none" ? (
|
||||
column.displayColumn
|
||||
) : (
|
||||
"컬럼 선택..."
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty className="py-2 text-center text-xs">
|
||||
컬럼을 찾을 수 없습니다.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="none"
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_display_column",
|
||||
"none",
|
||||
);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
displayColumn: false,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.displayColumn === "none" || !column.displayColumn
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
-- 선택 안함 --
|
||||
</CommandItem>
|
||||
{referenceTableColumns[column.referenceTable]?.map((refCol) => (
|
||||
<CommandItem
|
||||
key={refCol.columnName}
|
||||
value={`${refCol.columnLabel || ""} ${refCol.columnName}`}
|
||||
onSelect={() => {
|
||||
handleDetailSettingsChange(
|
||||
column.columnName,
|
||||
"entity_display_column",
|
||||
refCol.columnName,
|
||||
);
|
||||
setEntityComboboxOpen((prev) => ({
|
||||
...prev,
|
||||
[column.columnName]: {
|
||||
...prev[column.columnName],
|
||||
displayColumn: false,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
column.displayColumn === refCol.columnName
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{refCol.columnName}</span>
|
||||
{refCol.columnLabel && (
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{refCol.columnLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 설정 완료 표시 */}
|
||||
{column.referenceTable &&
|
||||
column.referenceTable !== "none" &&
|
||||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" &&
|
||||
column.displayColumn &&
|
||||
column.displayColumn !== "none" && (
|
||||
column.referenceColumn !== "none" && (
|
||||
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs">
|
||||
<Check className="h-3 w-3" />
|
||||
<span className="truncate">설정 완료</span>
|
||||
|
|
@ -1953,8 +1999,49 @@ export default function TableManagementPage() {
|
|||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
{/* PK 체크박스 */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={idxState.isPk}
|
||||
onCheckedChange={(checked) =>
|
||||
handlePkToggle(column.columnName, checked as boolean)
|
||||
}
|
||||
aria-label={`${column.columnName} PK 설정`}
|
||||
/>
|
||||
</div>
|
||||
{/* NN (NOT NULL) 체크박스 */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={column.isNullable === "NO"}
|
||||
onCheckedChange={() =>
|
||||
handleNullableToggle(column.columnName, column.isNullable)
|
||||
}
|
||||
aria-label={`${column.columnName} NOT NULL 설정`}
|
||||
/>
|
||||
</div>
|
||||
{/* IDX 체크박스 */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={idxState.hasIndex}
|
||||
onCheckedChange={(checked) =>
|
||||
handleIndexToggle(column.columnName, "index", checked as boolean)
|
||||
}
|
||||
aria-label={`${column.columnName} 인덱스 설정`}
|
||||
/>
|
||||
</div>
|
||||
{/* UQ 체크박스 */}
|
||||
<div className="flex items-center justify-center pt-1">
|
||||
<Checkbox
|
||||
checked={idxState.hasUnique}
|
||||
onCheckedChange={(checked) =>
|
||||
handleIndexToggle(column.columnName, "unique", checked as boolean)
|
||||
}
|
||||
aria-label={`${column.columnName} 유니크 설정`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 로딩 표시 */}
|
||||
{columnsLoading && (
|
||||
|
|
@ -2120,6 +2207,52 @@ export default function TableManagementPage() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* PK 변경 확인 다이얼로그 */}
|
||||
<Dialog open={pkDialogOpen} onOpenChange={setPkDialogOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">PK 변경 확인</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
PK를 변경하면 기존 제약조건이 삭제되고 새로 생성됩니다.
|
||||
<br />데이터 무결성에 영향을 줄 수 있습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-medium">변경될 PK 컬럼:</p>
|
||||
{pendingPkColumns.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{pendingPkColumns.map((col) => (
|
||||
<Badge key={col} variant="secondary" className="font-mono text-xs">
|
||||
{col}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-destructive mt-2 text-sm">PK가 모두 제거됩니다</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPkDialogOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePkConfirm}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
변경
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Loader2 } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen";
|
||||
import { LayerDefinition } from "@/types/screen-management";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
|
|
@ -86,6 +87,11 @@ function ScreenViewPage() {
|
|||
// 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이)
|
||||
const [conditionalContainerHeights, setConditionalContainerHeights] = useState<Record<string, number>>({});
|
||||
|
||||
// 🆕 레이어 시스템 지원
|
||||
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
|
||||
// 🆕 조건부 영역(Zone) 목록
|
||||
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
|
||||
|
||||
// 편집 모달 상태
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editModalConfig, setEditModalConfig] = useState<{
|
||||
|
|
@ -204,6 +210,177 @@ function ScreenViewPage() {
|
|||
}
|
||||
}, [screenId]);
|
||||
|
||||
// 🆕 조건부 레이어 + Zone 로드
|
||||
useEffect(() => {
|
||||
const loadConditionalLayersAndZones = async () => {
|
||||
if (!screenId || !layout) return;
|
||||
|
||||
try {
|
||||
// 1. Zone 로드
|
||||
const loadedZones = await screenApi.getScreenZones(screenId);
|
||||
setZones(loadedZones);
|
||||
|
||||
// 2. 모든 레이어 목록 조회
|
||||
const allLayers = await screenApi.getScreenLayers(screenId);
|
||||
const nonBaseLayers = allLayers.filter((l: any) => l.layer_id > 1);
|
||||
|
||||
if (nonBaseLayers.length === 0) {
|
||||
setConditionalLayers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 각 레이어의 레이아웃 데이터 로드
|
||||
const layerDefinitions: LayerDefinition[] = [];
|
||||
|
||||
for (const layerInfo of nonBaseLayers) {
|
||||
try {
|
||||
const layerData = await screenApi.getLayerLayout(screenId, layerInfo.layer_id);
|
||||
const condConfig = layerInfo.condition_config || layerData?.conditionConfig || {};
|
||||
|
||||
// 레이어 컴포넌트 변환 (V2 → Legacy)
|
||||
let layerComponents: any[] = [];
|
||||
const rawComponents = layerData?.components;
|
||||
if (rawComponents && Array.isArray(rawComponents) && rawComponents.length > 0) {
|
||||
const tempV2 = {
|
||||
version: "2.0" as const,
|
||||
components: rawComponents,
|
||||
gridSettings: layerData.gridSettings,
|
||||
screenResolution: layerData.screenResolution,
|
||||
};
|
||||
if (isValidV2Layout(tempV2)) {
|
||||
const converted = convertV2ToLegacy(tempV2);
|
||||
if (converted) {
|
||||
layerComponents = converted.components || [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zone 기반 condition_config 처리
|
||||
const zoneId = condConfig.zone_id;
|
||||
const conditionValue = condConfig.condition_value;
|
||||
const zone = zoneId ? loadedZones.find((z: any) => z.zone_id === zoneId) : null;
|
||||
|
||||
// LayerDefinition 생성
|
||||
const layerDef: LayerDefinition = {
|
||||
id: String(layerInfo.layer_id),
|
||||
name: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
|
||||
type: "conditional",
|
||||
zIndex: layerInfo.layer_id * 10,
|
||||
isVisible: false,
|
||||
isLocked: false,
|
||||
// Zone 기반 조건 (Zone에서 트리거 정보를 가져옴)
|
||||
condition: zone ? {
|
||||
targetComponentId: zone.trigger_component_id || "",
|
||||
operator: (zone.trigger_operator as "eq" | "neq" | "in") || "eq",
|
||||
value: conditionValue,
|
||||
} : condConfig.targetComponentId ? {
|
||||
targetComponentId: condConfig.targetComponentId,
|
||||
operator: condConfig.operator || "eq",
|
||||
value: condConfig.value,
|
||||
} : undefined,
|
||||
// Zone 기반: displayRegion은 Zone에서 가져옴
|
||||
zoneId: zoneId || undefined,
|
||||
conditionValue: conditionValue || undefined,
|
||||
displayRegion: zone ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } : condConfig.displayRegion || undefined,
|
||||
components: layerComponents,
|
||||
};
|
||||
|
||||
layerDefinitions.push(layerDef);
|
||||
} catch (layerError) {
|
||||
console.warn(`레이어 ${layerInfo.layer_id} 로드 실패:`, layerError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({
|
||||
id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue,
|
||||
componentCount: l.components.length,
|
||||
condition: l.condition ? {
|
||||
targetComponentId: l.condition.targetComponentId,
|
||||
operator: l.condition.operator,
|
||||
value: l.condition.value,
|
||||
} : "없음",
|
||||
})));
|
||||
console.log("🗺️ Zone 정보:", loadedZones.map(z => ({
|
||||
zone_id: z.zone_id,
|
||||
trigger_component_id: z.trigger_component_id,
|
||||
trigger_operator: z.trigger_operator,
|
||||
})));
|
||||
setConditionalLayers(layerDefinitions);
|
||||
} catch (error) {
|
||||
console.error("레이어/Zone 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadConditionalLayersAndZones();
|
||||
}, [screenId, layout]);
|
||||
|
||||
// 🆕 조건부 레이어 조건 평가 (formData 변경 시 동기적으로 즉시 계산)
|
||||
const activeLayerIds = useMemo(() => {
|
||||
if (conditionalLayers.length === 0 || !layout) return [] as string[];
|
||||
|
||||
const allComponents = layout.components || [];
|
||||
const newActiveIds: string[] = [];
|
||||
|
||||
conditionalLayers.forEach((layer) => {
|
||||
if (layer.condition) {
|
||||
const { targetComponentId, operator, value } = layer.condition;
|
||||
|
||||
// 빈 targetComponentId는 무시
|
||||
if (!targetComponentId) return;
|
||||
|
||||
// 트리거 컴포넌트 찾기 (기본 레이어에서)
|
||||
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
|
||||
|
||||
// columnName으로 formData에서 값 조회
|
||||
const fieldKey =
|
||||
(targetComponent as any)?.columnName ||
|
||||
(targetComponent as any)?.componentConfig?.columnName ||
|
||||
targetComponentId;
|
||||
|
||||
const targetValue = formData[fieldKey];
|
||||
|
||||
let isMatch = false;
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
// 문자열로 변환하여 비교 (타입 불일치 방지)
|
||||
isMatch = String(targetValue ?? "") === String(value ?? "");
|
||||
break;
|
||||
case "neq":
|
||||
isMatch = String(targetValue ?? "") !== String(value ?? "");
|
||||
break;
|
||||
case "in":
|
||||
if (Array.isArray(value)) {
|
||||
isMatch = value.some(v => String(v) === String(targetValue ?? ""));
|
||||
} else if (typeof value === "string" && value.includes(",")) {
|
||||
// 쉼표로 구분된 문자열도 지원
|
||||
isMatch = value.split(",").map(v => v.trim()).includes(String(targetValue ?? ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 디버그 로깅 (값이 존재할 때만)
|
||||
if (targetValue !== undefined && targetValue !== "") {
|
||||
console.log("🔍 [레이어 조건 평가]", {
|
||||
layerId: layer.id,
|
||||
layerName: layer.name,
|
||||
targetComponentId,
|
||||
fieldKey,
|
||||
targetValue: String(targetValue),
|
||||
conditionValue: String(value),
|
||||
operator,
|
||||
isMatch,
|
||||
});
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
newActiveIds.push(layer.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return newActiveIds;
|
||||
}, [formData, conditionalLayers, layout]);
|
||||
|
||||
// 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
||||
// 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움
|
||||
useEffect(() => {
|
||||
|
|
@ -513,6 +690,7 @@ function ScreenViewPage() {
|
|||
{layoutReady && layout && layout.components.length > 0 ? (
|
||||
<ScreenMultiLangProvider components={layout.components} companyCode={companyCode}>
|
||||
<div
|
||||
data-screen-runtime="true"
|
||||
className="bg-background relative"
|
||||
style={{
|
||||
width: `${screenWidth}px`,
|
||||
|
|
@ -630,7 +808,25 @@ function ScreenViewPage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (totalHeightAdjustment > 0) {
|
||||
// 🆕 Zone 기반 높이 조정
|
||||
// Zone 단위로 활성 여부를 판단하여 Y 오프셋 계산
|
||||
// Zone은 겹치지 않으므로 merge 로직이 불필요 (단순 boolean 판단)
|
||||
for (const zone of zones) {
|
||||
const zoneBottom = zone.y + zone.height;
|
||||
// 컴포넌트가 Zone 하단보다 아래에 있는 경우
|
||||
if (component.position.y >= zoneBottom) {
|
||||
// Zone에 매칭되는 활성 레이어가 있는지 확인
|
||||
const hasActiveLayer = conditionalLayers.some(
|
||||
l => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id)
|
||||
);
|
||||
if (!hasActiveLayer) {
|
||||
// Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거)
|
||||
totalHeightAdjustment -= zone.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalHeightAdjustment !== 0) {
|
||||
return {
|
||||
...component,
|
||||
position: {
|
||||
|
|
@ -950,6 +1146,81 @@ function ScreenViewPage() {
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 🆕 조건부 레이어 컴포넌트 렌더링 (Zone 기반) */}
|
||||
{conditionalLayers.map((layer) => {
|
||||
const isActive = activeLayerIds.includes(layer.id);
|
||||
if (!isActive || !layer.components || layer.components.length === 0) return null;
|
||||
|
||||
// Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정
|
||||
const zone = layer.zoneId ? zones.find(z => z.zone_id === layer.zoneId) : null;
|
||||
const region = zone
|
||||
? { x: zone.x, y: zone.y, width: zone.width, height: zone.height }
|
||||
: layer.displayRegion;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`conditional-layer-${layer.id}`}
|
||||
data-conditional-layer="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: region ? `${region.x}px` : "0px",
|
||||
top: region ? `${region.y}px` : "0px",
|
||||
width: region ? `${region.width}px` : "100%",
|
||||
height: region ? `${region.height}px` : "auto",
|
||||
zIndex: layer.zIndex || 20,
|
||||
overflow: "hidden",
|
||||
transition: "none",
|
||||
}}
|
||||
>
|
||||
{layer.components
|
||||
.filter((comp) => !comp.parentId)
|
||||
.map((comp) => (
|
||||
<RealtimePreview
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
menuObjid={menuObjid}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
userId={user?.userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
sortBy={tableSortBy}
|
||||
sortOrder={tableSortOrder}
|
||||
columnOrder={tableColumnOrder}
|
||||
tableDisplayData={tableDisplayData}
|
||||
onSelectedRowsChange={(
|
||||
_,
|
||||
selectedData,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
columnOrder,
|
||||
tableDisplayData,
|
||||
) => {
|
||||
setSelectedRowsData(selectedData);
|
||||
setTableSortBy(sortBy);
|
||||
setTableSortOrder(sortOrder || "asc");
|
||||
setTableColumnOrder(columnOrder);
|
||||
setTableDisplayData(tableDisplayData || []);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]);
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -263,12 +263,20 @@ input,
|
|||
textarea,
|
||||
select {
|
||||
transition-property:
|
||||
color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter,
|
||||
color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, filter,
|
||||
backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* 런타임 화면에서 컴포넌트 위치 변경 시 모든 애니메이션/트랜지션 완전 제거 */
|
||||
[data-screen-runtime] [id^="component-"] {
|
||||
transition: none !important;
|
||||
}
|
||||
[data-screen-runtime] [data-conditional-layer] {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Disable animations for users who prefer reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
|
|
@ -281,6 +289,20 @@ select {
|
|||
}
|
||||
}
|
||||
|
||||
/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */
|
||||
[data-sonner-toaster] [data-sonner-toast] {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] {
|
||||
animation: none !important;
|
||||
}
|
||||
[data-sonner-toaster] [data-sonner-toast][data-removed="true"] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* ===== Print Styles ===== */
|
||||
@media print {
|
||||
* {
|
||||
|
|
|
|||
|
|
@ -145,13 +145,12 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
|||
const isFormValid = useMemo(() => {
|
||||
// 수정 모드에서는 비밀번호 선택 사항 (변경할 경우만 입력)
|
||||
const requiredFields = isEditMode
|
||||
? [formData.userId.trim(), formData.userName.trim(), formData.companyCode, formData.deptCode]
|
||||
? [formData.userId.trim(), formData.userName.trim(), formData.companyCode]
|
||||
: [
|
||||
formData.userId.trim(),
|
||||
formData.userPassword.trim(),
|
||||
formData.userName.trim(),
|
||||
formData.companyCode,
|
||||
formData.deptCode,
|
||||
];
|
||||
|
||||
// 모든 필수 필드가 입력되었는지 확인
|
||||
|
|
@ -327,11 +326,6 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
|||
return false;
|
||||
}
|
||||
|
||||
if (!formData.deptCode) {
|
||||
showAlert("입력 오류", "부서를 선택해주세요.", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 이메일 형식 검사 (입력된 경우만)
|
||||
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
showAlert("입력 오류", "올바른 이메일 형식을 입력해주세요.", "error");
|
||||
|
|
@ -581,7 +575,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
|||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deptCode" className="text-sm font-medium">
|
||||
부서 <span className="text-red-500">*</span>
|
||||
부서
|
||||
</Label>
|
||||
<Select value={formData.deptCode} onValueChange={(value) => handleInputChange("deptCode", value)}>
|
||||
<SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -84,12 +84,9 @@ export interface ExcelUploadModalProps {
|
|||
masterColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||
detailColumns: Array<{ name: string; label: string; inputType: string; isFromMaster: boolean }>;
|
||||
};
|
||||
// 🆕 마스터-디테일 엑셀 업로드 설정
|
||||
// 마스터-디테일 엑셀 업로드 설정
|
||||
masterDetailExcelConfig?: MasterDetailExcelConfig;
|
||||
// 🆕 단일 테이블 채번 설정
|
||||
numberingRuleId?: string;
|
||||
numberingTargetColumn?: string;
|
||||
// 🆕 업로드 후 제어 실행 설정
|
||||
// 업로드 후 제어 실행 설정
|
||||
afterUploadFlows?: Array<{ flowId: string; order: number }>;
|
||||
}
|
||||
|
||||
|
|
@ -112,9 +109,6 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
isMasterDetail = false,
|
||||
masterDetailRelation,
|
||||
masterDetailExcelConfig,
|
||||
// 단일 테이블 채번 설정
|
||||
numberingRuleId,
|
||||
numberingTargetColumn,
|
||||
// 업로드 후 제어 실행 설정
|
||||
afterUploadFlows,
|
||||
}) => {
|
||||
|
|
@ -459,6 +453,48 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 채번 정보 병합: table_type_columns에서 inputType 가져오기
|
||||
try {
|
||||
const { getTableColumns } = await import("@/lib/api/tableManagement");
|
||||
const targetTables = isMasterDetail && masterDetailRelation
|
||||
? [masterDetailRelation.masterTable, masterDetailRelation.detailTable]
|
||||
: [tableName];
|
||||
|
||||
// 테이블별 채번 컬럼 수집
|
||||
const numberingColSet = new Set<string>();
|
||||
for (const tbl of targetTables) {
|
||||
const typeResponse = await getTableColumns(tbl);
|
||||
if (typeResponse.success && typeResponse.data?.columns) {
|
||||
for (const tc of typeResponse.data.columns) {
|
||||
if (tc.inputType === "numbering") {
|
||||
try {
|
||||
const settings = typeof tc.detailSettings === "string"
|
||||
? JSON.parse(tc.detailSettings) : tc.detailSettings;
|
||||
if (settings?.numberingRuleId) {
|
||||
numberingColSet.add(tc.columnName);
|
||||
}
|
||||
} catch { /* 파싱 실패 무시 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// systemColumns에 isNumbering 플래그 추가
|
||||
if (numberingColSet.size > 0) {
|
||||
allColumns = allColumns.map((col) => {
|
||||
const rawName = (col as any).originalName || col.name;
|
||||
const colName = rawName.includes(".") ? rawName.split(".")[1] : rawName;
|
||||
if (numberingColSet.has(colName)) {
|
||||
return { ...col, isNumbering: true } as any;
|
||||
}
|
||||
return col;
|
||||
});
|
||||
console.log("✅ 채번 컬럼 감지:", Array.from(numberingColSet));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("채번 정보 로드 실패 (무시):", error);
|
||||
}
|
||||
|
||||
console.log("✅ 시스템 컬럼 로드 완료 (자동 생성 컬럼 제외):", allColumns);
|
||||
setSystemColumns(allColumns);
|
||||
|
||||
|
|
@ -619,6 +655,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 2단계 → 3단계 전환 시: NOT NULL 컬럼 매핑 필수 검증
|
||||
if (currentStep === 2) {
|
||||
// 매핑된 시스템 컬럼 (원본 이름 그대로 + dot 뒤 이름 둘 다 저장)
|
||||
const mappedSystemCols = new Set<string>();
|
||||
columnMappings.filter((m) => m.systemColumn).forEach((m) => {
|
||||
const colName = m.systemColumn!;
|
||||
mappedSystemCols.add(colName); // 원본 (예: user_info.user_id)
|
||||
if (colName.includes(".")) {
|
||||
mappedSystemCols.add(colName.split(".")[1]); // dot 뒤 (예: user_id)
|
||||
}
|
||||
});
|
||||
|
||||
const unmappedRequired = systemColumns.filter((col) => {
|
||||
const rawName = col.name.includes(".") ? col.name.split(".")[1] : col.name;
|
||||
if (AUTO_GENERATED_COLUMNS.includes(rawName.toLowerCase())) return false;
|
||||
if (col.nullable) return false;
|
||||
if (mappedSystemCols.has(col.name) || mappedSystemCols.has(rawName)) return false;
|
||||
if ((col as any).isNumbering) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (unmappedRequired.length > 0) {
|
||||
const colNames = unmappedRequired.map((c) => c.label || c.name).join(", ");
|
||||
toast.error(`필수(NOT NULL) 컬럼이 매핑되지 않았습니다: ${colNames}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
};
|
||||
|
||||
|
|
@ -627,6 +691,44 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
setCurrentStep((prev) => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
// 테이블 타입 관리에서 채번 컬럼 자동 감지
|
||||
const detectNumberingColumn = async (
|
||||
targetTableName: string
|
||||
): Promise<{ columnName: string; numberingRuleId: string } | null> => {
|
||||
try {
|
||||
const { getTableColumns } = await import("@/lib/api/tableManagement");
|
||||
const response = await getTableColumns(targetTableName);
|
||||
|
||||
if (response.success && response.data?.columns) {
|
||||
for (const col of response.data.columns) {
|
||||
if (col.inputType === "numbering") {
|
||||
try {
|
||||
const settings =
|
||||
typeof col.detailSettings === "string"
|
||||
? JSON.parse(col.detailSettings)
|
||||
: col.detailSettings;
|
||||
if (settings?.numberingRuleId) {
|
||||
console.log(
|
||||
`✅ 채번 컬럼 자동 감지: ${col.columnName} → 규칙 ID: ${settings.numberingRuleId}`
|
||||
);
|
||||
return {
|
||||
columnName: col.columnName,
|
||||
numberingRuleId: settings.numberingRuleId,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// detailSettings 파싱 실패 시 무시
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("채번 컬럼 감지 실패:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 업로드 핸들러
|
||||
const handleUpload = async () => {
|
||||
if (!file || !tableName) {
|
||||
|
|
@ -667,19 +769,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
`📊 엑셀 업로드: 전체 ${mappedData.length}행 중 유효한 ${filteredData.length}행`
|
||||
);
|
||||
|
||||
// 🆕 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번)
|
||||
// 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번 자동 감지)
|
||||
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
|
||||
// 마스터 테이블에서 채번 컬럼 자동 감지
|
||||
const masterNumberingInfo = await detectNumberingColumn(masterDetailRelation.masterTable);
|
||||
const detectedNumberingRuleId = masterNumberingInfo?.numberingRuleId || masterDetailExcelConfig?.numberingRuleId;
|
||||
|
||||
console.log("📊 마스터-디테일 간단 모드 업로드:", {
|
||||
masterDetailRelation,
|
||||
masterFieldValues,
|
||||
numberingRuleId: masterDetailExcelConfig?.numberingRuleId,
|
||||
detectedNumberingRuleId,
|
||||
autoDetected: !!masterNumberingInfo,
|
||||
});
|
||||
|
||||
const uploadResult = await DynamicFormApi.uploadMasterDetailSimple(
|
||||
screenId,
|
||||
filteredData,
|
||||
masterFieldValues,
|
||||
masterDetailExcelConfig?.numberingRuleId || undefined,
|
||||
detectedNumberingRuleId || undefined,
|
||||
masterDetailExcelConfig?.afterUploadFlowId || undefined, // 하위 호환성
|
||||
masterDetailExcelConfig?.afterUploadFlows || undefined // 다중 제어
|
||||
);
|
||||
|
|
@ -704,6 +811,24 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
else if (isMasterDetail && screenId && masterDetailRelation) {
|
||||
console.log("📊 마스터-디테일 업로드 모드:", masterDetailRelation);
|
||||
|
||||
// 마스터 키 컬럼 매핑 검증 (채번 타입이면 자동 생성되므로 검증 생략)
|
||||
const masterKeyCol = masterDetailRelation.masterKeyColumn;
|
||||
const hasMasterKey = filteredData.length > 0 && filteredData[0][masterKeyCol] !== undefined && filteredData[0][masterKeyCol] !== null && filteredData[0][masterKeyCol] !== "";
|
||||
if (!hasMasterKey) {
|
||||
// 채번 여부 확인 - 채번이면 백엔드에서 자동 생성하므로 통과
|
||||
const numberingInfo = await detectNumberingColumn(masterDetailRelation.masterTable);
|
||||
const isMasterKeyAutoNumbering = numberingInfo && numberingInfo.columnName === masterKeyCol;
|
||||
|
||||
if (!isMasterKeyAutoNumbering) {
|
||||
toast.error(
|
||||
`마스터 키 컬럼(${masterKeyCol})이 매핑되지 않았습니다. 컬럼 매핑에서 [마스터] 항목을 확인해주세요.`
|
||||
);
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
console.log(`✅ 마스터 키(${masterKeyCol})는 채번 타입 → 백엔드에서 자동 생성`);
|
||||
}
|
||||
|
||||
const uploadResult = await DynamicFormApi.uploadMasterDetailData(
|
||||
screenId,
|
||||
filteredData
|
||||
|
|
@ -731,8 +856,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
let skipCount = 0;
|
||||
let overwriteCount = 0;
|
||||
|
||||
// 단일 테이블 채번 설정 확인
|
||||
const hasNumbering = numberingRuleId && numberingTargetColumn;
|
||||
// 단일 테이블 채번 자동 감지 (테이블 타입 관리에서 input_type = 'numbering' 컬럼)
|
||||
const numberingInfo = await detectNumberingColumn(tableName);
|
||||
const hasNumbering = !!numberingInfo;
|
||||
|
||||
// 중복 체크 설정 확인
|
||||
const duplicateCheckMappings = columnMappings.filter(
|
||||
|
|
@ -816,14 +942,14 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
continue;
|
||||
}
|
||||
|
||||
// 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만)
|
||||
if (hasNumbering && uploadMode === "insert" && !shouldUpdate) {
|
||||
// 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용)
|
||||
if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`);
|
||||
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`);
|
||||
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
|
||||
if (numberingResponse.data?.success && generatedCode) {
|
||||
dataToSave[numberingTargetColumn] = generatedCode;
|
||||
dataToSave[numberingInfo.columnName] = generatedCode;
|
||||
}
|
||||
} catch (numError) {
|
||||
console.error("채번 오류:", numError);
|
||||
|
|
@ -1341,15 +1467,19 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
<SelectItem value="none" className="text-xs sm:text-sm">
|
||||
매핑 안함
|
||||
</SelectItem>
|
||||
{systemColumns.map((col) => (
|
||||
{systemColumns.map((col) => {
|
||||
const isRequired = !col.nullable && !AUTO_GENERATED_COLUMNS.includes(col.name.toLowerCase()) && !(col as any).isNumbering;
|
||||
return (
|
||||
<SelectItem
|
||||
key={col.name}
|
||||
value={col.name}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{isRequired && <span className="text-destructive mr-1">*</span>}
|
||||
{col.label || col.name} ({col.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 중복 체크 체크박스 */}
|
||||
|
|
@ -1371,6 +1501,38 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미매핑 필수(NOT NULL) 컬럼 경고 */}
|
||||
{(() => {
|
||||
const mappedCols = new Set<string>();
|
||||
columnMappings.filter((m) => m.systemColumn).forEach((m) => {
|
||||
const n = m.systemColumn!;
|
||||
mappedCols.add(n);
|
||||
if (n.includes(".")) mappedCols.add(n.split(".")[1]);
|
||||
});
|
||||
const missing = systemColumns.filter((col) => {
|
||||
const rawName = col.name.includes(".") ? col.name.split(".")[1] : col.name;
|
||||
if (AUTO_GENERATED_COLUMNS.includes(rawName.toLowerCase())) return false;
|
||||
if (col.nullable) return false;
|
||||
if (mappedCols.has(col.name) || mappedCols.has(rawName)) return false;
|
||||
if ((col as any).isNumbering) return false;
|
||||
return true;
|
||||
});
|
||||
if (missing.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
|
||||
<div className="text-[10px] text-destructive sm:text-xs">
|
||||
<p className="font-medium">필수(NOT NULL) 컬럼이 매핑되지 않았습니다:</p>
|
||||
<p className="mt-1">
|
||||
{missing.map((c) => c.label || c.name).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 중복 체크 안내 */}
|
||||
{duplicateCheckCount > 0 ? (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
|
|
@ -14,6 +24,7 @@ import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHei
|
|||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||
|
||||
interface ScreenModalState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -61,12 +72,21 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 🆕 선택된 데이터 상태 (RepeatScreenModal 등에서 사용)
|
||||
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
|
||||
|
||||
// 🆕 조건부 레이어 상태 (Zone 기반)
|
||||
const [conditionalLayers, setConditionalLayers] = useState<(LayerDefinition & { components: ComponentData[]; zone?: ConditionalZone })[]>([]);
|
||||
|
||||
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
||||
const [continuousMode, setContinuousMode] = useState(false);
|
||||
|
||||
// 화면 리셋 키 (컴포넌트 강제 리마운트용)
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
|
||||
// 모달 닫기 확인 다이얼로그 표시 상태
|
||||
const [showCloseConfirm, setShowCloseConfirm] = useState(false);
|
||||
|
||||
// 사용자가 폼 데이터를 실제로 변경했는지 추적 (변경 없으면 경고 없이 바로 닫기)
|
||||
const formDataChangedRef = useRef(false);
|
||||
|
||||
// localStorage에서 연속 모드 상태 복원
|
||||
useEffect(() => {
|
||||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||
|
|
@ -109,9 +129,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
|
||||
// 적절한 여백 추가
|
||||
const paddingX = 40;
|
||||
const paddingY = 40;
|
||||
// 여백 없이 컨텐츠 크기 그대로 사용
|
||||
const paddingX = 0;
|
||||
const paddingY = 0;
|
||||
|
||||
const finalWidth = Math.max(contentWidth + paddingX, 400);
|
||||
const finalHeight = Math.max(contentHeight + paddingY, 300);
|
||||
|
|
@ -119,8 +139,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
return {
|
||||
width: Math.min(finalWidth, window.innerWidth * 0.95),
|
||||
height: Math.min(finalHeight, window.innerHeight * 0.9),
|
||||
offsetX: Math.max(0, minX - paddingX / 2), // 좌측 여백 고려
|
||||
offsetY: Math.max(0, minY - paddingY / 2), // 상단 여백 고려
|
||||
offsetX: Math.max(0, minX), // 여백 없이 컨텐츠 시작점 기준
|
||||
offsetY: Math.max(0, minY), // 여백 없이 컨텐츠 시작점 기준
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -159,11 +179,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
selectedData: eventSelectedData,
|
||||
selectedIds,
|
||||
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
|
||||
fieldMappings, // 🆕 필드 매핑 정보 (명시적 매핑이 있으면 모든 매핑된 필드 전달)
|
||||
} = event.detail;
|
||||
|
||||
// 🆕 모달 열린 시간 기록
|
||||
modalOpenedAtRef.current = Date.now();
|
||||
|
||||
// 폼 변경 추적 초기화
|
||||
formDataChangedRef.current = false;
|
||||
|
||||
// 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용)
|
||||
if (eventSelectedData && Array.isArray(eventSelectedData)) {
|
||||
setSelectedData(eventSelectedData);
|
||||
|
|
@ -218,10 +242,33 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const parentDataMapping = splitPanelContext?.parentDataMapping || [];
|
||||
|
||||
// 부모 데이터 소스
|
||||
const rawParentData =
|
||||
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
|
||||
? splitPanelParentData
|
||||
: splitPanelContext?.selectedLeftData || {};
|
||||
// 🔧 수정: 여러 소스를 병합 (우선순위: splitPanelParentData > selectedLeftData > 기존 formData의 링크 필드)
|
||||
// 예: screen 150→226→227 전환 시:
|
||||
// - splitPanelParentData: item_info 데이터 (screen 226에서 전달)
|
||||
// - selectedLeftData: customer_mng 데이터 (SplitPanel 좌측 선택)
|
||||
// - 기존 formData: 이전 모달에서 설정된 link 필드 (customer_code 등)
|
||||
const contextData = splitPanelContext?.selectedLeftData || {};
|
||||
const eventData = splitPanelParentData && Object.keys(splitPanelParentData).length > 0
|
||||
? splitPanelParentData
|
||||
: {};
|
||||
|
||||
// 🆕 기존 formData에서 link 필드(_code, _id)를 가져와 base로 사용
|
||||
// 모달 체인(226→227)에서 이전 모달의 연결 필드가 유지됨
|
||||
const previousLinkFields: Record<string, any> = {};
|
||||
if (formData && typeof formData === "object" && !Array.isArray(formData)) {
|
||||
const linkFieldPatterns = ["_code", "_id"];
|
||||
const excludeFields = ["id", "created_date", "updated_date", "created_at", "updated_at", "writer"];
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
if (excludeFields.includes(key)) continue;
|
||||
if (value === undefined || value === null) continue;
|
||||
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
|
||||
if (isLinkField) {
|
||||
previousLinkFields[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawParentData = { ...previousLinkFields, ...contextData, ...eventData };
|
||||
|
||||
// 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달
|
||||
const parentData: Record<string, any> = {};
|
||||
|
|
@ -231,6 +278,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
parentData.company_code = rawParentData.company_code;
|
||||
}
|
||||
|
||||
// 🆕 명시적 필드 매핑이 있으면 매핑된 타겟 필드를 모두 보존
|
||||
// (버튼 설정에서 fieldMappings로 지정한 필드는 link 필드가 아니어도 전달)
|
||||
const mappedTargetFields = new Set<string>();
|
||||
if (fieldMappings && Array.isArray(fieldMappings)) {
|
||||
for (const mapping of fieldMappings) {
|
||||
if (mapping.targetField) {
|
||||
mappedTargetFields.add(mapping.targetField);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parentDataMapping에 정의된 필드만 전달
|
||||
for (const mapping of parentDataMapping) {
|
||||
const sourceValue = rawParentData[mapping.sourceColumn];
|
||||
|
|
@ -239,8 +297,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
}
|
||||
}
|
||||
|
||||
// parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
|
||||
if (parentDataMapping.length === 0) {
|
||||
// 🆕 명시적 필드 매핑이 있으면 해당 필드를 모두 전달
|
||||
if (mappedTargetFields.size > 0) {
|
||||
for (const [key, value] of Object.entries(rawParentData)) {
|
||||
if (mappedTargetFields.has(key) && value !== undefined && value !== null) {
|
||||
parentData[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parentDataMapping이 비어있고 명시적 필드 매핑도 없으면 연결 필드 자동 감지
|
||||
if (parentDataMapping.length === 0 && mappedTargetFields.size === 0) {
|
||||
const linkFieldPatterns = ["_code", "_id"];
|
||||
const excludeFields = [
|
||||
"id",
|
||||
|
|
@ -257,6 +324,29 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
if (value === undefined || value === null) continue;
|
||||
|
||||
// 연결 필드 패턴 확인
|
||||
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
|
||||
if (isLinkField) {
|
||||
parentData[key] = value;
|
||||
}
|
||||
}
|
||||
} else if (parentDataMapping.length === 0 && mappedTargetFields.size > 0) {
|
||||
// 🆕 명시적 매핑이 있어도 연결 필드(_code, _id)는 추가로 전달
|
||||
const linkFieldPatterns = ["_code", "_id"];
|
||||
const excludeFields = [
|
||||
"id",
|
||||
"company_code",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"writer",
|
||||
];
|
||||
|
||||
for (const [key, value] of Object.entries(rawParentData)) {
|
||||
if (excludeFields.includes(key)) continue;
|
||||
if (parentData[key] !== undefined) continue; // 이미 매핑된 필드는 스킵
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
|
||||
if (isLinkField) {
|
||||
parentData[key] = value;
|
||||
|
|
@ -317,6 +407,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
if (isContinuousMode) {
|
||||
// 연속 모드: 폼만 초기화하고 모달은 유지
|
||||
formDataChangedRef.current = false;
|
||||
setFormData({});
|
||||
setResetKey((prev) => prev + 1);
|
||||
|
||||
|
|
@ -483,6 +574,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
components,
|
||||
screenInfo: screenInfo,
|
||||
});
|
||||
|
||||
// 🆕 조건부 레이어/존 로드
|
||||
loadConditionalLayersAndZones(screenId);
|
||||
} else {
|
||||
throw new Error("화면 데이터가 없습니다");
|
||||
}
|
||||
|
|
@ -495,14 +589,262 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// 🔧 URL 파라미터 제거 (mode, editId, tableName 등)
|
||||
// 🆕 조건부 레이어 & 존 로드 함수
|
||||
const loadConditionalLayersAndZones = async (screenId: number) => {
|
||||
try {
|
||||
const [layersRes, zonesRes] = await Promise.all([
|
||||
screenApi.getScreenLayers(screenId),
|
||||
screenApi.getScreenZones(screenId),
|
||||
]);
|
||||
|
||||
const loadedLayers = layersRes || [];
|
||||
const loadedZones: ConditionalZone[] = zonesRes || [];
|
||||
|
||||
// 기본 레이어(layer_id=1) 제외
|
||||
const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1);
|
||||
|
||||
if (nonBaseLayers.length === 0) {
|
||||
setConditionalLayers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const layerDefs: (LayerDefinition & { components: ComponentData[] })[] = [];
|
||||
|
||||
for (const layer of nonBaseLayers) {
|
||||
try {
|
||||
const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id);
|
||||
|
||||
let layerComponents: ComponentData[] = [];
|
||||
if (layerLayout && isValidV2Layout(layerLayout)) {
|
||||
const legacyLayout = convertV2ToLegacy(layerLayout);
|
||||
layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[];
|
||||
} else if (layerLayout?.components) {
|
||||
layerComponents = layerLayout.components;
|
||||
}
|
||||
|
||||
// condition_config에서 zone_id, condition_value 추출
|
||||
const cc = layer.condition_config || {};
|
||||
const zone = loadedZones.find((z) => z.zone_id === cc.zone_id);
|
||||
|
||||
layerDefs.push({
|
||||
id: `layer-${layer.layer_id}`,
|
||||
name: layer.layer_name || `레이어 ${layer.layer_id}`,
|
||||
type: "conditional",
|
||||
zIndex: layer.layer_id,
|
||||
isVisible: false,
|
||||
isLocked: false,
|
||||
zoneId: cc.zone_id,
|
||||
conditionValue: cc.condition_value,
|
||||
condition: zone
|
||||
? {
|
||||
targetComponentId: zone.trigger_component_id || "",
|
||||
operator: (zone.trigger_operator || "eq") as any,
|
||||
value: cc.condition_value || "",
|
||||
}
|
||||
: undefined,
|
||||
components: layerComponents,
|
||||
zone: zone || undefined, // 🆕 Zone 위치 정보 포함 (오프셋 계산용)
|
||||
} as any);
|
||||
} catch (err) {
|
||||
console.warn(`[ScreenModal] 레이어 ${layer.layer_id} 로드 실패:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[ScreenModal] 조건부 레이어 로드 완료:", layerDefs.length, "개",
|
||||
layerDefs.map((l) => ({
|
||||
id: l.id, name: l.name, conditionValue: l.conditionValue,
|
||||
componentCount: l.components.length,
|
||||
condition: l.condition,
|
||||
}))
|
||||
);
|
||||
|
||||
setConditionalLayers(layerDefs);
|
||||
} catch (error) {
|
||||
console.error("[ScreenModal] 조건부 레이어 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 조건부 레이어 활성화 평가 (formData 변경 시)
|
||||
const activeConditionalComponents = useMemo(() => {
|
||||
if (conditionalLayers.length === 0) return [];
|
||||
|
||||
const allComponents = screenData?.components || [];
|
||||
const activeComps: ComponentData[] = [];
|
||||
|
||||
conditionalLayers.forEach((layer) => {
|
||||
if (!layer.condition) return;
|
||||
const { targetComponentId, operator, value } = layer.condition;
|
||||
if (!targetComponentId) return;
|
||||
|
||||
// V2 레이아웃: overrides.columnName 우선
|
||||
const comp = allComponents.find((c: any) => c.id === targetComponentId);
|
||||
const fieldKey =
|
||||
(comp as any)?.overrides?.columnName ||
|
||||
(comp as any)?.columnName ||
|
||||
(comp as any)?.componentConfig?.columnName ||
|
||||
targetComponentId;
|
||||
|
||||
const targetValue = formData[fieldKey];
|
||||
|
||||
let isMatch = false;
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
isMatch = String(targetValue ?? "") === String(value ?? "");
|
||||
break;
|
||||
case "neq":
|
||||
isMatch = String(targetValue ?? "") !== String(value ?? "");
|
||||
break;
|
||||
case "in":
|
||||
if (Array.isArray(value)) {
|
||||
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
|
||||
} else if (typeof value === "string" && value.includes(",")) {
|
||||
isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
console.log("[ScreenModal] 레이어 조건 평가:", {
|
||||
layerName: layer.name, fieldKey,
|
||||
targetValue: String(targetValue ?? "(없음)"),
|
||||
conditionValue: String(value), operator, isMatch,
|
||||
});
|
||||
|
||||
if (isMatch) {
|
||||
// Zone 오프셋 적용 (레이어 2 컴포넌트는 Zone 상대 좌표로 저장됨)
|
||||
const zoneX = layer.zone?.x || 0;
|
||||
const zoneY = layer.zone?.y || 0;
|
||||
|
||||
const offsetComponents = layer.components.map((c: any) => ({
|
||||
...c,
|
||||
position: {
|
||||
...c.position,
|
||||
x: parseFloat(c.position?.x?.toString() || "0") + zoneX,
|
||||
y: parseFloat(c.position?.y?.toString() || "0") + zoneY,
|
||||
},
|
||||
}));
|
||||
|
||||
activeComps.push(...offsetComponents);
|
||||
}
|
||||
});
|
||||
|
||||
return activeComps;
|
||||
}, [formData, conditionalLayers, screenData?.components]);
|
||||
|
||||
// 🆕 이전 활성 레이어 ID 추적 (레이어 전환 감지용)
|
||||
const prevActiveLayerIdsRef = useRef<string[]>([]);
|
||||
|
||||
// 🆕 레이어 전환 시 비활성화된 레이어의 필드값을 formData에서 제거
|
||||
// (품목우선 → 공급업체우선 전환 시, 품목우선 레이어의 데이터가 남지 않도록)
|
||||
useEffect(() => {
|
||||
if (conditionalLayers.length === 0) return;
|
||||
|
||||
// 현재 활성 레이어 ID 목록
|
||||
const currentActiveLayerIds = conditionalLayers
|
||||
.filter((layer) => {
|
||||
if (!layer.condition) return false;
|
||||
const { targetComponentId, operator, value } = layer.condition;
|
||||
if (!targetComponentId) return false;
|
||||
|
||||
const allComponents = screenData?.components || [];
|
||||
const comp = allComponents.find((c: any) => c.id === targetComponentId);
|
||||
const fieldKey =
|
||||
(comp as any)?.overrides?.columnName ||
|
||||
(comp as any)?.columnName ||
|
||||
(comp as any)?.componentConfig?.columnName ||
|
||||
targetComponentId;
|
||||
|
||||
const targetValue = formData[fieldKey];
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
return String(targetValue ?? "") === String(value ?? "");
|
||||
case "neq":
|
||||
return String(targetValue ?? "") !== String(value ?? "");
|
||||
case "in":
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((v) => String(v) === String(targetValue ?? ""));
|
||||
} else if (typeof value === "string" && value.includes(",")) {
|
||||
return value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
|
||||
}
|
||||
return false;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.map((l) => l.id);
|
||||
|
||||
const prevIds = prevActiveLayerIdsRef.current;
|
||||
|
||||
// 이전에 활성이었는데 이번에 비활성이 된 레이어 찾기
|
||||
const deactivatedLayerIds = prevIds.filter((id) => !currentActiveLayerIds.includes(id));
|
||||
|
||||
if (deactivatedLayerIds.length > 0) {
|
||||
// 비활성화된 레이어의 컴포넌트 필드명 수집
|
||||
const fieldsToRemove: string[] = [];
|
||||
deactivatedLayerIds.forEach((layerId) => {
|
||||
const layer = conditionalLayers.find((l) => l.id === layerId);
|
||||
if (!layer) return;
|
||||
|
||||
layer.components.forEach((comp: any) => {
|
||||
const fieldName =
|
||||
comp?.overrides?.columnName ||
|
||||
comp?.columnName ||
|
||||
comp?.componentConfig?.columnName;
|
||||
if (fieldName) {
|
||||
fieldsToRemove.push(fieldName);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (fieldsToRemove.length > 0) {
|
||||
console.log("[ScreenModal] 레이어 전환 감지 - 비활성 레이어 필드 제거:", {
|
||||
deactivatedLayerIds,
|
||||
fieldsToRemove,
|
||||
});
|
||||
|
||||
setFormData((prev) => {
|
||||
const cleaned = { ...prev };
|
||||
fieldsToRemove.forEach((field) => {
|
||||
delete cleaned[field];
|
||||
});
|
||||
return cleaned;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 상태 저장
|
||||
prevActiveLayerIdsRef.current = currentActiveLayerIds;
|
||||
}, [formData, conditionalLayers, screenData?.components]);
|
||||
|
||||
// 사용자가 바깥 클릭/ESC/X 버튼으로 닫으려 할 때
|
||||
// 폼 데이터 변경이 있으면 확인 다이얼로그, 없으면 바로 닫기
|
||||
const handleCloseAttempt = useCallback(() => {
|
||||
if (formDataChangedRef.current) {
|
||||
setShowCloseConfirm(true);
|
||||
} else {
|
||||
handleCloseInternal();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 확인 후 실제로 모달을 닫는 함수
|
||||
const handleConfirmClose = useCallback(() => {
|
||||
setShowCloseConfirm(false);
|
||||
handleCloseInternal();
|
||||
}, []);
|
||||
|
||||
// 닫기 취소 (계속 작업)
|
||||
const handleCancelClose = useCallback(() => {
|
||||
setShowCloseConfirm(false);
|
||||
}, []);
|
||||
|
||||
const handleCloseInternal = () => {
|
||||
// 🔧 URL 파라미터 제거 (mode, editId, tableName, groupByColumns, dataSourceId 등)
|
||||
if (typeof window !== "undefined") {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.delete("mode");
|
||||
currentUrl.searchParams.delete("editId");
|
||||
currentUrl.searchParams.delete("tableName");
|
||||
currentUrl.searchParams.delete("groupByColumns");
|
||||
currentUrl.searchParams.delete("dataSourceId");
|
||||
window.history.pushState({}, "", currentUrl.toString());
|
||||
}
|
||||
|
||||
|
|
@ -514,42 +856,35 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
});
|
||||
setScreenData(null);
|
||||
setFormData({}); // 폼 데이터 초기화
|
||||
setOriginalData(null); // 원본 데이터 초기화
|
||||
setSelectedData([]); // 선택된 데이터 초기화
|
||||
setConditionalLayers([]); // 🆕 조건부 레이어 초기화
|
||||
setContinuousMode(false);
|
||||
localStorage.setItem("screenModal_continuousMode", "false");
|
||||
};
|
||||
|
||||
// 기존 handleClose를 유지 (이벤트 핸들러 등에서 사용)
|
||||
const handleClose = handleCloseInternal;
|
||||
|
||||
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터
|
||||
const getModalStyle = () => {
|
||||
if (!screenDimensions) {
|
||||
return {
|
||||
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
|
||||
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
|
||||
needsScroll: false,
|
||||
className: "w-fit min-w-[400px] max-w-4xl overflow-hidden",
|
||||
style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" },
|
||||
};
|
||||
}
|
||||
|
||||
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
|
||||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 + gap + 패딩
|
||||
// 🔧 DialogContent의 gap-4 (16px × 2) + 컨텐츠 pt-6 (24px) 포함
|
||||
const headerHeight = 48; // DialogHeader (타이틀 + border-b + py-3)
|
||||
const footerHeight = 44; // 연속 등록 모드 체크박스 영역
|
||||
const dialogGap = 32; // gap-4 × 2 (header-content, content-footer 사이)
|
||||
const contentTopPadding = 24; // pt-6 (컨텐츠 영역 상단 패딩)
|
||||
const horizontalPadding = 16; // 좌우 패딩 최소화
|
||||
|
||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight + dialogGap + contentTopPadding;
|
||||
const maxAvailableHeight = window.innerHeight * 0.95;
|
||||
|
||||
// 콘텐츠가 화면 높이를 초과할 때만 스크롤 필요
|
||||
const needsScroll = totalHeight > maxAvailableHeight;
|
||||
|
||||
return {
|
||||
className: "overflow-hidden p-0",
|
||||
className: "overflow-hidden",
|
||||
style: {
|
||||
width: `${Math.min(screenDimensions.width + horizontalPadding, window.innerWidth * 0.98)}px`,
|
||||
// 🔧 height 대신 max-height만 설정 - 콘텐츠가 작으면 자동으로 줄어듦
|
||||
maxHeight: `${maxAvailableHeight}px`,
|
||||
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
|
||||
// CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한
|
||||
maxHeight: "calc(100dvh - 8px)",
|
||||
maxWidth: "98vw",
|
||||
padding: 0,
|
||||
gap: 0,
|
||||
},
|
||||
needsScroll,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -615,10 +950,28 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
]);
|
||||
|
||||
return (
|
||||
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
|
||||
<Dialog
|
||||
open={modalState.isOpen}
|
||||
onOpenChange={(open) => {
|
||||
// X 버튼 클릭 시에도 확인 다이얼로그 표시
|
||||
if (!open) {
|
||||
handleCloseAttempt();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={`${modalStyle.className} ${className || ""} max-w-none flex flex-col`}
|
||||
{...(modalStyle.style && { style: modalStyle.style })}
|
||||
style={modalStyle.style}
|
||||
// 바깥 클릭 시 바로 닫히지 않도록 방지
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
handleCloseAttempt();
|
||||
}}
|
||||
// ESC 키 누를 때도 바로 닫히지 않도록 방지
|
||||
onEscapeKeyDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleCloseAttempt();
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -633,7 +986,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</DialogHeader>
|
||||
|
||||
<div
|
||||
className={`flex-1 min-h-0 flex items-start justify-center pt-6 ${modalStyle.needsScroll ? 'overflow-auto' : 'overflow-visible'}`}
|
||||
className="flex-1 min-h-0 flex items-start justify-center overflow-auto"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
@ -649,8 +1002,22 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
className="relative bg-white"
|
||||
style={{
|
||||
width: `${screenDimensions?.width || 800}px`,
|
||||
height: `${screenDimensions?.height || 600}px`,
|
||||
// 🆕 라벨이 위로 튀어나갈 수 있도록 overflow visible 설정
|
||||
// 🆕 조건부 레이어 활성화 시 높이 자동 확장
|
||||
minHeight: `${screenDimensions?.height || 600}px`,
|
||||
height: (() => {
|
||||
const baseHeight = screenDimensions?.height || 600;
|
||||
if (activeConditionalComponents.length > 0) {
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
let maxBottom = 0;
|
||||
activeConditionalComponents.forEach((comp: any) => {
|
||||
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY;
|
||||
const h = parseFloat(comp.size?.height?.toString() || "40");
|
||||
maxBottom = Math.max(maxBottom, y + h);
|
||||
});
|
||||
return `${Math.max(baseHeight, maxBottom + 20)}px`;
|
||||
}
|
||||
return `${baseHeight}px`;
|
||||
})(),
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
|
|
@ -786,6 +1153,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
formData={formData}
|
||||
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
// 사용자가 실제로 데이터를 변경한 것으로 표시
|
||||
formDataChangedRef.current = true;
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
|
|
@ -810,6 +1179,48 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
|
||||
{activeConditionalComponents.map((component: any) => {
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
|
||||
const adjustedComponent = {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={`conditional-${component.id}-${resetKey}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={[...(screenData?.components || []), ...activeConditionalComponents]}
|
||||
formData={formData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
formDataChangedRef.current = true;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
onRefresh={() => {
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData?.screenInfo?.tableName,
|
||||
}}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
</ActiveTabProvider>
|
||||
|
|
@ -838,6 +1249,36 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* 모달 닫기 확인 다이얼로그 */}
|
||||
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
|
||||
<AlertDialogContent className="!z-[1100] max-w-[95vw] sm:max-w-[400px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">
|
||||
화면을 닫으시겠습니까?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
지금 나가시면 진행 중인 데이터가 저장되지 않습니다.
|
||||
<br />
|
||||
계속 작업하시려면 '계속 작업' 버튼을 눌러주세요.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<AlertDialogCancel
|
||||
onClick={handleCancelClose}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
계속 작업
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmClose}
|
||||
className="h-8 flex-1 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
나가기
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* 플로우 에디터 상단 툴바
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -42,6 +42,27 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
|||
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// Ctrl+S 단축키: 플로우 저장
|
||||
const handleSaveRef = useRef<() => void>();
|
||||
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
if (!isSaving) {
|
||||
handleSaveRef.current?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isSaving]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// 검증 수행
|
||||
const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges);
|
||||
|
|
|
|||
|
|
@ -251,6 +251,14 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
||||
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
|
||||
|
||||
// 타겟 조회 설정 (DB 기존값 비교용)
|
||||
const [targetLookup, setTargetLookup] = useState<{
|
||||
tableName: string;
|
||||
tableLabel?: string;
|
||||
lookupKeys: Array<{ sourceField: string; targetField: string; sourceFieldLabel?: string }>;
|
||||
} | undefined>(data.targetLookup);
|
||||
const [targetLookupColumns, setTargetLookupColumns] = useState<ColumnInfo[]>([]);
|
||||
|
||||
// EXISTS 연산자용 상태
|
||||
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
||||
const [tableColumnsCache, setTableColumnsCache] = useState<Record<string, ColumnInfo[]>>({});
|
||||
|
|
@ -262,8 +270,20 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
setDisplayName(data.displayName || "조건 분기");
|
||||
setConditions(data.conditions || []);
|
||||
setLogic(data.logic || "AND");
|
||||
setTargetLookup(data.targetLookup);
|
||||
}, [data]);
|
||||
|
||||
// targetLookup 테이블 변경 시 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (targetLookup?.tableName) {
|
||||
loadTableColumns(targetLookup.tableName).then((cols) => {
|
||||
setTargetLookupColumns(cols);
|
||||
});
|
||||
} else {
|
||||
setTargetLookupColumns([]);
|
||||
}
|
||||
}, [targetLookup?.tableName]);
|
||||
|
||||
// 전체 테이블 목록 로드 (EXISTS 연산자용)
|
||||
useEffect(() => {
|
||||
const loadAllTables = async () => {
|
||||
|
|
@ -559,6 +579,47 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
});
|
||||
};
|
||||
|
||||
// 타겟 조회 테이블 변경
|
||||
const handleTargetLookupTableChange = async (tableName: string) => {
|
||||
await ensureTablesLoaded();
|
||||
const tableInfo = allTables.find((t) => t.tableName === tableName);
|
||||
const newLookup = {
|
||||
tableName,
|
||||
tableLabel: tableInfo?.tableLabel || tableName,
|
||||
lookupKeys: targetLookup?.lookupKeys || [],
|
||||
};
|
||||
setTargetLookup(newLookup);
|
||||
updateNode(nodeId, { targetLookup: newLookup });
|
||||
|
||||
// 컬럼 로드
|
||||
const cols = await loadTableColumns(tableName);
|
||||
setTargetLookupColumns(cols);
|
||||
};
|
||||
|
||||
// 타겟 조회 키 필드 변경
|
||||
const handleTargetLookupKeyChange = (sourceField: string, targetField: string) => {
|
||||
if (!targetLookup) return;
|
||||
const sourceFieldInfo = availableFields.find((f) => f.name === sourceField);
|
||||
const newLookup = {
|
||||
...targetLookup,
|
||||
lookupKeys: [{ sourceField, targetField, sourceFieldLabel: sourceFieldInfo?.label || sourceField }],
|
||||
};
|
||||
setTargetLookup(newLookup);
|
||||
updateNode(nodeId, { targetLookup: newLookup });
|
||||
};
|
||||
|
||||
// 타겟 조회 제거
|
||||
const handleRemoveTargetLookup = () => {
|
||||
setTargetLookup(undefined);
|
||||
updateNode(nodeId, { targetLookup: undefined });
|
||||
// target 타입 조건들을 field로 변경
|
||||
const newConditions = conditions.map((c) =>
|
||||
(c as any).valueType === "target" ? { ...c, valueType: "field" } : c
|
||||
);
|
||||
setConditions(newConditions);
|
||||
updateNode(nodeId, { conditions: newConditions });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
|
|
@ -597,6 +658,119 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타겟 조회 (DB 기존값 비교) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">
|
||||
<Database className="mr-1 inline h-3.5 w-3.5" />
|
||||
타겟 조회 (DB 기존값)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{!targetLookup ? (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-dashed p-3 text-center text-xs text-gray-400">
|
||||
DB의 기존값과 비교하려면 타겟 테이블을 설정하세요.
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 w-full text-xs"
|
||||
onClick={async () => {
|
||||
await ensureTablesLoaded();
|
||||
setTargetLookup({ tableName: "", lookupKeys: [] });
|
||||
}}
|
||||
>
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
타겟 조회 설정
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 rounded border bg-orange-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-orange-700">타겟 테이블</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRemoveTargetLookup}
|
||||
className="h-5 px-1 text-xs text-orange-500 hover:text-orange-700"
|
||||
>
|
||||
제거
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{allTables.length > 0 ? (
|
||||
<TableCombobox
|
||||
tables={allTables}
|
||||
value={targetLookup.tableName}
|
||||
onSelect={handleTargetLookupTableChange}
|
||||
placeholder="비교할 테이블 검색..."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
테이블 로딩 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 키 필드 매핑 */}
|
||||
{targetLookup.tableName && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-orange-600">조회 키 (소스 → 타겟)</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select
|
||||
value={targetLookup.lookupKeys?.[0]?.sourceField || ""}
|
||||
onValueChange={(val) => {
|
||||
const targetField = targetLookup.lookupKeys?.[0]?.targetField || "";
|
||||
handleTargetLookupKeyChange(val, targetField);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue placeholder="소스 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((f) => (
|
||||
<SelectItem key={f.name} value={f.name} className="text-xs">
|
||||
{f.label || f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-gray-400">=</span>
|
||||
{targetLookupColumns.length > 0 ? (
|
||||
<Select
|
||||
value={targetLookup.lookupKeys?.[0]?.targetField || ""}
|
||||
onValueChange={(val) => {
|
||||
const sourceField = targetLookup.lookupKeys?.[0]?.sourceField || "";
|
||||
handleTargetLookupKeyChange(sourceField, val);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue placeholder="타겟 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetLookupColumns.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">
|
||||
{c.columnLabel || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex-1 rounded border border-dashed bg-gray-50 p-1 text-center text-[10px] text-gray-400">
|
||||
컬럼 로딩 중...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded bg-orange-100 p-1.5 text-[10px] text-orange-600">
|
||||
비교 값 타입에서 "타겟 필드 (DB 기존값)"을 선택하면 이 테이블의 기존값과 비교합니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 조건식 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
|
|
@ -738,15 +912,46 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="field">필드 참조</SelectItem>
|
||||
{targetLookup?.tableName && (
|
||||
<SelectItem value="target">타겟 필드 (DB 기존값)</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">
|
||||
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
|
||||
{(condition as any).valueType === "target"
|
||||
? "타겟 필드 (DB 기존값)"
|
||||
: (condition as any).valueType === "field"
|
||||
? "비교 필드"
|
||||
: "비교 값"}
|
||||
</Label>
|
||||
{(condition as any).valueType === "field" ? (
|
||||
{(condition as any).valueType === "target" ? (
|
||||
// 타겟 필드 (DB 기존값): 타겟 테이블 컬럼에서 선택
|
||||
targetLookupColumns.length > 0 ? (
|
||||
<Select
|
||||
value={condition.value as string}
|
||||
onValueChange={(value) => handleConditionChange(index, "value", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="DB 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetLookupColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
<span className="ml-2 text-xs text-gray-400">({col.dataType})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
타겟 조회를 먼저 설정하세요
|
||||
</div>
|
||||
)
|
||||
) : (condition as any).valueType === "field" ? (
|
||||
// 필드 참조: 드롭다운으로 선택
|
||||
availableFields.length > 0 ? (
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -449,6 +449,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
);
|
||||
};
|
||||
|
||||
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (인증 대기 없이 즉시 렌더링)
|
||||
if (isPreviewMode) {
|
||||
return (
|
||||
<div className="h-screen w-full overflow-auto bg-white p-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 사용자 정보가 없으면 로딩 표시
|
||||
if (!user) {
|
||||
return (
|
||||
|
|
@ -461,15 +470,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
);
|
||||
}
|
||||
|
||||
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시
|
||||
if (isPreviewMode) {
|
||||
return (
|
||||
<div className="h-screen w-full overflow-auto bg-white p-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// UI 변환된 메뉴 데이터
|
||||
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -15,6 +15,8 @@ import { ComponentData } from "@/types/screen";
|
|||
import { toast } from "sonner";
|
||||
import { dynamicFormApi } from "@/lib/api/dynamicForm";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
|
||||
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
|
||||
|
||||
interface EditModalState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -111,11 +113,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 폼 데이터 상태 (편집 데이터로 초기화됨)
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [originalData, setOriginalData] = useState<Record<string, any>>({});
|
||||
// INSERT/UPDATE 판단용 플래그 (이벤트에서 명시적으로 전달받음)
|
||||
// true = INSERT (등록/복사), false = UPDATE (수정)
|
||||
// originalData 상태에 의존하지 않고 이벤트의 isCreateMode 값을 직접 사용
|
||||
const [isCreateModeFlag, setIsCreateModeFlag] = useState<boolean>(true);
|
||||
|
||||
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
|
||||
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
|
||||
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
|
||||
|
||||
// 🆕 조건부 레이어 상태 (Zone 기반)
|
||||
const [zones, setZones] = useState<ConditionalZone[]>([]);
|
||||
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
|
||||
|
||||
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
|
||||
const calculateScreenDimensions = (components: ComponentData[]) => {
|
||||
if (components.length === 0) {
|
||||
|
|
@ -265,13 +275,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
// 편집 데이터로 폼 데이터 초기화
|
||||
setFormData(editData || {});
|
||||
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드)
|
||||
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨
|
||||
// originalData: changedData 계산(PATCH)에만 사용
|
||||
// INSERT/UPDATE 판단에는 사용하지 않음
|
||||
setOriginalData(isCreateMode ? {} : editData || {});
|
||||
// INSERT/UPDATE 판단: 이벤트의 isCreateMode 플래그를 직접 저장
|
||||
// isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE
|
||||
setIsCreateModeFlag(!!isCreateMode);
|
||||
|
||||
if (isCreateMode) {
|
||||
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData);
|
||||
}
|
||||
console.log("[EditModal] 모달 열림:", {
|
||||
mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)",
|
||||
hasEditData: !!editData,
|
||||
editDataId: editData?.id,
|
||||
isCreateMode,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseEditModal = () => {
|
||||
|
|
@ -360,16 +376,12 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
try {
|
||||
setLoading(true);
|
||||
|
||||
// console.log("화면 데이터 로딩 시작:", screenId);
|
||||
|
||||
// 화면 정보와 레이아웃 데이터 로딩
|
||||
const [screenInfo, layoutData] = await Promise.all([
|
||||
screenApi.getScreen(screenId),
|
||||
screenApi.getLayout(screenId),
|
||||
]);
|
||||
|
||||
// console.log("API 응답:", { screenInfo, layoutData });
|
||||
|
||||
if (screenInfo && layoutData) {
|
||||
const components = layoutData.components || [];
|
||||
|
||||
|
|
@ -381,11 +393,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
components,
|
||||
screenInfo: screenInfo,
|
||||
});
|
||||
// console.log("화면 데이터 설정 완료:", {
|
||||
// componentsCount: components.length,
|
||||
// dimensions,
|
||||
// screenInfo,
|
||||
// });
|
||||
|
||||
// 🆕 조건부 레이어/존 로드 (await으로 에러 포착)
|
||||
console.log("[EditModal] 화면 데이터 로드 완료, 조건부 레이어 로드 시작:", screenId);
|
||||
try {
|
||||
await loadConditionalLayersAndZones(screenId, components);
|
||||
} catch (layerErr) {
|
||||
console.error("[EditModal] 조건부 레이어 로드 에러:", layerErr);
|
||||
}
|
||||
} else {
|
||||
throw new Error("화면 데이터가 없습니다");
|
||||
}
|
||||
|
|
@ -398,6 +413,165 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 조건부 레이어 & 존 로드 함수
|
||||
const loadConditionalLayersAndZones = async (screenId: number, baseComponents: ComponentData[]) => {
|
||||
console.log("[EditModal] loadConditionalLayersAndZones 호출됨:", screenId);
|
||||
try {
|
||||
// 레이어 목록 & 존 목록 병렬 로드
|
||||
console.log("[EditModal] API 호출 시작: getScreenLayers, getScreenZones");
|
||||
const [layersRes, zonesRes] = await Promise.all([
|
||||
screenApi.getScreenLayers(screenId),
|
||||
screenApi.getScreenZones(screenId),
|
||||
]);
|
||||
console.log("[EditModal] API 응답:", { layers: layersRes?.length, zones: zonesRes?.length });
|
||||
|
||||
const loadedLayers = layersRes || [];
|
||||
const loadedZones: ConditionalZone[] = zonesRes || [];
|
||||
|
||||
setZones(loadedZones);
|
||||
|
||||
// 기본 레이어(layer_id=1) 제외한 조건부 레이어 처리
|
||||
const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1);
|
||||
|
||||
if (nonBaseLayers.length === 0) {
|
||||
setConditionalLayers([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 각 조건부 레이어의 컴포넌트 로드
|
||||
const layerDefinitions: LayerDefinition[] = [];
|
||||
|
||||
for (const layer of nonBaseLayers) {
|
||||
try {
|
||||
const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id);
|
||||
|
||||
let layerComponents: ComponentData[] = [];
|
||||
if (layerLayout && isValidV2Layout(layerLayout)) {
|
||||
const legacyLayout = convertV2ToLegacy(layerLayout);
|
||||
layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[];
|
||||
} else if (layerLayout?.components) {
|
||||
layerComponents = layerLayout.components;
|
||||
}
|
||||
|
||||
// condition_config에서 zone_id, condition_value 추출
|
||||
const conditionConfig = layer.condition_config || {};
|
||||
const layerZoneId = conditionConfig.zone_id;
|
||||
const layerConditionValue = conditionConfig.condition_value;
|
||||
|
||||
// 이 레이어가 속한 Zone 찾기
|
||||
const associatedZone = loadedZones.find(
|
||||
(z) => z.zone_id === layerZoneId
|
||||
);
|
||||
|
||||
layerDefinitions.push({
|
||||
id: `layer-${layer.layer_id}`,
|
||||
name: layer.layer_name || `레이어 ${layer.layer_id}`,
|
||||
type: "conditional",
|
||||
zIndex: layer.layer_id,
|
||||
isVisible: false,
|
||||
isLocked: false,
|
||||
zoneId: layerZoneId,
|
||||
conditionValue: layerConditionValue,
|
||||
condition: associatedZone
|
||||
? {
|
||||
targetComponentId: associatedZone.trigger_component_id || "",
|
||||
operator: (associatedZone.trigger_operator || "eq") as any,
|
||||
value: layerConditionValue || "",
|
||||
}
|
||||
: undefined,
|
||||
components: layerComponents,
|
||||
} as LayerDefinition & { components: ComponentData[] });
|
||||
} catch (layerError) {
|
||||
console.warn(`[EditModal] 레이어 ${layer.layer_id} 로드 실패:`, layerError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[EditModal] 조건부 레이어 로드 완료:", layerDefinitions.length, "개",
|
||||
layerDefinitions.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
conditionValue: l.conditionValue,
|
||||
condition: l.condition,
|
||||
}))
|
||||
);
|
||||
|
||||
setConditionalLayers(layerDefinitions);
|
||||
} catch (error) {
|
||||
console.warn("[EditModal] 조건부 레이어 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 🆕 조건부 레이어 활성화 평가 (formData 변경 시)
|
||||
const activeConditionalLayerIds = useMemo(() => {
|
||||
if (conditionalLayers.length === 0) return [];
|
||||
|
||||
const newActiveIds: string[] = [];
|
||||
const allComponents = screenData?.components || [];
|
||||
|
||||
conditionalLayers.forEach((layer) => {
|
||||
const layerWithComponents = layer as LayerDefinition & { components: ComponentData[] };
|
||||
if (layerWithComponents.condition) {
|
||||
const { targetComponentId, operator, value } = layerWithComponents.condition;
|
||||
if (!targetComponentId) return;
|
||||
|
||||
// 트리거 컴포넌트의 columnName 찾기
|
||||
// V2 레이아웃: overrides.columnName, 레거시: componentConfig.columnName
|
||||
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
|
||||
const fieldKey =
|
||||
(targetComponent as any)?.overrides?.columnName ||
|
||||
(targetComponent as any)?.columnName ||
|
||||
(targetComponent as any)?.componentConfig?.columnName ||
|
||||
targetComponentId;
|
||||
|
||||
const currentFormData = groupData.length > 0 ? groupData[0] : formData;
|
||||
const targetValue = currentFormData[fieldKey];
|
||||
|
||||
let isMatch = false;
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
isMatch = String(targetValue ?? "") === String(value ?? "");
|
||||
break;
|
||||
case "neq":
|
||||
isMatch = String(targetValue ?? "") !== String(value ?? "");
|
||||
break;
|
||||
case "in":
|
||||
if (Array.isArray(value)) {
|
||||
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
|
||||
} else if (typeof value === "string" && value.includes(",")) {
|
||||
isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// 디버그 로깅
|
||||
console.log("[EditModal] 레이어 조건 평가:", {
|
||||
layerId: layer.id,
|
||||
layerName: layer.name,
|
||||
targetComponentId,
|
||||
fieldKey,
|
||||
targetValue: targetValue !== undefined ? String(targetValue) : "(없음)",
|
||||
conditionValue: String(value),
|
||||
operator,
|
||||
isMatch,
|
||||
componentFound: !!targetComponent,
|
||||
});
|
||||
|
||||
if (isMatch) {
|
||||
newActiveIds.push(layer.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return newActiveIds;
|
||||
}, [formData, groupData, conditionalLayers, screenData?.components]);
|
||||
|
||||
// 🆕 활성화된 조건부 레이어의 컴포넌트 가져오기
|
||||
const activeConditionalComponents = useMemo(() => {
|
||||
return conditionalLayers
|
||||
.filter((layer) => activeConditionalLayerIds.includes(layer.id))
|
||||
.flatMap((layer) => (layer as LayerDefinition & { components: ComponentData[] }).components || []);
|
||||
}, [conditionalLayers, activeConditionalLayerIds]);
|
||||
|
||||
const handleClose = () => {
|
||||
setModalState({
|
||||
isOpen: false,
|
||||
|
|
@ -412,7 +586,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
});
|
||||
setScreenData(null);
|
||||
setFormData({});
|
||||
setZones([]);
|
||||
setConditionalLayers([]);
|
||||
setOriginalData({});
|
||||
setIsCreateModeFlag(true); // 기본값은 INSERT (안전 방향)
|
||||
setGroupData([]); // 🆕
|
||||
setOriginalGroupData([]); // 🆕
|
||||
};
|
||||
|
|
@ -776,8 +953,31 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// originalData가 비어있으면 INSERT, 있으면 UPDATE
|
||||
const isCreateMode = Object.keys(originalData).length === 0;
|
||||
// ========================================
|
||||
// INSERT/UPDATE 판단 (재설계)
|
||||
// ========================================
|
||||
// 판단 기준:
|
||||
// 1. isCreateModeFlag === true → 무조건 INSERT (복사/등록 모드 보호)
|
||||
// 2. isCreateModeFlag === false → formData.id 있으면 UPDATE, 없으면 INSERT
|
||||
// originalData는 INSERT/UPDATE 판단에 사용하지 않음 (changedData 계산에만 사용)
|
||||
// ========================================
|
||||
let isCreateMode: boolean;
|
||||
|
||||
if (isCreateModeFlag) {
|
||||
// 이벤트에서 명시적으로 INSERT 모드로 지정됨 (등록/복사)
|
||||
isCreateMode = true;
|
||||
} else {
|
||||
// 수정 모드: formData에 id가 있으면 UPDATE, 없으면 INSERT
|
||||
isCreateMode = !formData.id;
|
||||
}
|
||||
|
||||
console.log("[EditModal] 저장 모드 판단:", {
|
||||
isCreateMode,
|
||||
isCreateModeFlag,
|
||||
formDataId: formData.id,
|
||||
originalDataLength: Object.keys(originalData).length,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
});
|
||||
|
||||
if (isCreateMode) {
|
||||
// INSERT 모드
|
||||
|
|
@ -968,70 +1168,57 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
throw new Error(response.message || "생성에 실패했습니다.");
|
||||
}
|
||||
} else {
|
||||
// UPDATE 모드 - 기존 로직
|
||||
const changedData: Record<string, any> = {};
|
||||
Object.keys(formData).forEach((key) => {
|
||||
if (formData[key] !== originalData[key]) {
|
||||
let value = formData[key];
|
||||
// UPDATE 모드 - PUT (전체 업데이트)
|
||||
// originalData 비교 없이 formData 전체를 보냄
|
||||
const recordId = formData.id;
|
||||
|
||||
// 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외)
|
||||
if (Array.isArray(value)) {
|
||||
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
|
||||
const isRepeaterData = value.length > 0 &&
|
||||
typeof value[0] === "object" &&
|
||||
value[0] !== null &&
|
||||
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
|
||||
|
||||
if (!isRepeaterData) {
|
||||
// 🔧 손상된 값 필터링 헬퍼 (중괄호, 따옴표, 백슬래시 포함 시 무효)
|
||||
const isValidValue = (v: any): boolean => {
|
||||
if (typeof v === "number" && !isNaN(v)) return true;
|
||||
if (typeof v !== "string") return false;
|
||||
if (!v || v.trim() === "") return false;
|
||||
// 손상된 PostgreSQL 배열 형식 감지
|
||||
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 (손상된 값 필터링)
|
||||
const validValues = value
|
||||
.map((v: any) => typeof v === "number" ? String(v) : v)
|
||||
.filter(isValidValue);
|
||||
|
||||
if (validValues.length !== value.length) {
|
||||
console.warn(`⚠️ [EditModal UPDATE] 손상된 값 필터링: ${key}`, {
|
||||
before: value.length,
|
||||
after: validValues.length,
|
||||
removed: value.filter((v: any) => !isValidValue(v))
|
||||
});
|
||||
}
|
||||
|
||||
const stringValue = validValues.join(",");
|
||||
console.log(`🔧 [EditModal UPDATE] 배열→문자열 변환: ${key}`, { original: value.length, valid: validValues.length, converted: stringValue });
|
||||
value = stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
changedData[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(changedData).length === 0) {
|
||||
toast.info("변경된 내용이 없습니다.");
|
||||
handleClose();
|
||||
if (!recordId) {
|
||||
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
|
||||
formDataKeys: Object.keys(formData),
|
||||
});
|
||||
toast.error("수정할 레코드의 ID를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 기본키 확인 (id 또는 첫 번째 키)
|
||||
const recordId = originalData.id || Object.values(originalData)[0];
|
||||
// 배열 값 → 쉼표 구분 문자열 변환 (리피터 데이터 제외)
|
||||
const dataToSave: Record<string, any> = {};
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
const isRepeaterData = value.length > 0 &&
|
||||
typeof value[0] === "object" &&
|
||||
value[0] !== null &&
|
||||
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
|
||||
|
||||
// UPDATE 액션 실행
|
||||
const response = await dynamicFormApi.updateFormDataPartial(
|
||||
if (isRepeaterData) {
|
||||
// 리피터 데이터는 제외 (별도 저장)
|
||||
return;
|
||||
}
|
||||
// 다중 선택 배열 → 쉼표 구분 문자열
|
||||
const validValues = value
|
||||
.map((v: any) => typeof v === "number" ? String(v) : v)
|
||||
.filter((v: any) => {
|
||||
if (typeof v === "number") return true;
|
||||
if (typeof v !== "string") return false;
|
||||
if (!v || v.trim() === "") return false;
|
||||
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
|
||||
return true;
|
||||
});
|
||||
dataToSave[key] = validValues.join(",");
|
||||
} else {
|
||||
dataToSave[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
console.log("[EditModal] UPDATE(PUT) 실행:", {
|
||||
recordId,
|
||||
originalData,
|
||||
changedData,
|
||||
screenData.screenInfo.tableName,
|
||||
);
|
||||
fieldCount: Object.keys(dataToSave).length,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
});
|
||||
|
||||
const response = await dynamicFormApi.updateFormData(recordId, {
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
data: dataToSave,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success("데이터가 수정되었습니다.");
|
||||
|
|
@ -1151,12 +1338,27 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
className="relative bg-white"
|
||||
style={{
|
||||
width: screenDimensions?.width || 800,
|
||||
height: (screenDimensions?.height || 600) + 30, // 라벨 공간 추가
|
||||
// 🆕 조건부 레이어가 활성화되면 높이 자동 확장
|
||||
height: (() => {
|
||||
const baseHeight = (screenDimensions?.height || 600) + 30;
|
||||
if (activeConditionalComponents.length > 0) {
|
||||
// 조건부 레이어 컴포넌트 중 가장 아래 위치 계산
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
let maxBottom = 0;
|
||||
activeConditionalComponents.forEach((comp) => {
|
||||
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30;
|
||||
const h = parseFloat(comp.size?.height?.toString() || "40");
|
||||
maxBottom = Math.max(maxBottom, y + h);
|
||||
});
|
||||
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
|
||||
}
|
||||
return baseHeight;
|
||||
})(),
|
||||
transformOrigin: "center center",
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
}}
|
||||
>
|
||||
{/* 기본 레이어 컴포넌트 렌더링 */}
|
||||
{screenData.components.map((component) => {
|
||||
// 컴포넌트 위치를 offset만큼 조정
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
|
|
@ -1174,49 +1376,37 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
|
||||
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
|
||||
// 최상위 컴포넌트에 universal-form-modal이 있는지 확인
|
||||
// ⚠️ 수정: conditional-container는 제외 (groupData가 있으면 EditModal.handleSave 사용)
|
||||
const hasUniversalFormModal = screenData.components.some(
|
||||
(c) => {
|
||||
// 최상위에 universal-form-modal이 있는 경우만 자체 저장 로직 사용
|
||||
if (c.componentType === "universal-form-modal") return true;
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
// 🆕 _tableSection_ 데이터가 있는지 확인 (TableSectionRenderer 사용 시)
|
||||
// _tableSection_ 데이터가 있으면 buttonActions.ts의 handleUniversalFormModalTableSectionSave가 처리
|
||||
const hasTableSectionData = Object.keys(formData).some(k =>
|
||||
k.startsWith("_tableSection_") || k.startsWith("__tableSection_")
|
||||
);
|
||||
|
||||
// 🆕 그룹 데이터가 있으면 EditModal.handleSave 사용 (일괄 저장)
|
||||
// 단, _tableSection_ 데이터가 있으면 EditModal.handleSave 사용하지 않음 (buttonActions.ts가 처리)
|
||||
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
|
||||
|
||||
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
|
||||
const enrichedFormData = {
|
||||
...(groupData.length > 0 ? groupData[0] : formData),
|
||||
tableName: screenData.screenInfo?.tableName, // 테이블명 추가
|
||||
screenId: modalState.screenId, // 화면 ID 추가
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
screenId: modalState.screenId,
|
||||
};
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={component.id}
|
||||
component={adjustedComponent}
|
||||
allComponents={screenData.components}
|
||||
allComponents={[...screenData.components, ...activeConditionalComponents]}
|
||||
formData={enrichedFormData}
|
||||
originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용)
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
// 🆕 그룹 데이터가 있으면 처리
|
||||
if (groupData.length > 0) {
|
||||
// ModalRepeaterTable의 경우 배열 전체를 받음
|
||||
if (Array.isArray(value)) {
|
||||
setGroupData(value);
|
||||
} else {
|
||||
// 일반 필드는 모든 항목에 동일하게 적용
|
||||
setGroupData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
|
|
@ -1235,19 +1425,74 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
// 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
|
||||
menuObjid={modalState.menuObjid}
|
||||
// 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용
|
||||
// groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용
|
||||
onSave={shouldUseEditModalSave ? handleSave : undefined}
|
||||
isInModal={true}
|
||||
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
|
||||
groupedData={groupedDataProp}
|
||||
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
|
||||
disabledFields={["order_no", "partner_id"]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
|
||||
{activeConditionalComponents.map((component) => {
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
const offsetY = screenDimensions?.offsetY || 0;
|
||||
const labelSpace = 30;
|
||||
|
||||
const adjustedComponent = {
|
||||
...component,
|
||||
position: {
|
||||
...component.position,
|
||||
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
|
||||
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
|
||||
},
|
||||
};
|
||||
|
||||
const enrichedFormData = {
|
||||
...(groupData.length > 0 ? groupData[0] : formData),
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
screenId: modalState.screenId,
|
||||
};
|
||||
|
||||
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={`conditional-${component.id}`}
|
||||
component={adjustedComponent}
|
||||
allComponents={[...screenData.components, ...activeConditionalComponents]}
|
||||
formData={enrichedFormData}
|
||||
originalData={originalData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
if (groupData.length > 0) {
|
||||
if (Array.isArray(value)) {
|
||||
setGroupData(value);
|
||||
} else {
|
||||
setGroupData((prev) =>
|
||||
prev.map((item) => ({
|
||||
...item,
|
||||
[fieldName]: value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}
|
||||
}}
|
||||
screenInfo={{
|
||||
id: modalState.screenId!,
|
||||
tableName: screenData.screenInfo?.tableName,
|
||||
}}
|
||||
menuObjid={modalState.menuObjid}
|
||||
isInModal={true}
|
||||
groupedData={groupedDataProp}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -284,59 +284,38 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
});
|
||||
}, [finalFormData, layers, allComponents, handleLayerAction]);
|
||||
|
||||
// 🆕 모든 조건부 레이어의 displayRegion 정보 (활성/비활성 모두)
|
||||
const conditionalRegionInfos = useMemo(() => {
|
||||
return layers
|
||||
.filter((layer) => layer.type === "conditional" && layer.displayRegion)
|
||||
.map((layer) => ({
|
||||
layerId: layer.id,
|
||||
region: layer.displayRegion!,
|
||||
isActive: activeLayerIds.includes(layer.id),
|
||||
}))
|
||||
.sort((a, b) => a.region.y - b.region.y); // Y 좌표 기준 정렬
|
||||
}, [layers, activeLayerIds]);
|
||||
|
||||
// 🆕 접힌 조건부 영역 (비활성 상태인 것만)
|
||||
const collapsedRegions = useMemo(() => {
|
||||
return conditionalRegionInfos
|
||||
.filter((info) => !info.isActive)
|
||||
.map((info) => info.region);
|
||||
}, [conditionalRegionInfos]);
|
||||
|
||||
// 🆕 Y 오프셋 계산 함수 (다중 조건부 영역 지원)
|
||||
// 컴포넌트의 원래 Y 좌표보다 위에 있는 접힌 영역들의 높이를 누적하여 빼줌
|
||||
// 겹치는 영역은 중복 계산하지 않도록 병합(merge) 처리
|
||||
// 🆕 Zone 기반 Y 오프셋 계산 (단순화)
|
||||
// Zone 단위로 활성 여부만 판단 → merge 로직 불필요
|
||||
const calculateYOffset = useCallback((componentY: number): number => {
|
||||
if (collapsedRegions.length === 0) return 0;
|
||||
// layers에서 Zone 정보 추출 (displayRegion이 있는 레이어들을 zone 단위로 그룹핑)
|
||||
const zoneMap = new Map<number, { y: number; height: number; hasActive: boolean }>();
|
||||
|
||||
// 컴포넌트보다 위에 있는 접힌 영역만 필터링
|
||||
const relevantRegions = collapsedRegions.filter(
|
||||
(region) => region.y + region.height <= componentY
|
||||
);
|
||||
|
||||
if (relevantRegions.length === 0) return 0;
|
||||
|
||||
// 겹치는 영역 병합 (다중 조건부 영역이 겹치는 경우 중복 높이 제거)
|
||||
const mergedRegions: { y: number; bottom: number }[] = [];
|
||||
for (const region of relevantRegions) {
|
||||
const bottom = region.y + region.height;
|
||||
if (mergedRegions.length === 0) {
|
||||
mergedRegions.push({ y: region.y, bottom });
|
||||
} else {
|
||||
const last = mergedRegions[mergedRegions.length - 1];
|
||||
if (region.y <= last.bottom) {
|
||||
// 겹치는 영역 - 병합 (더 큰 하단으로 확장)
|
||||
last.bottom = Math.max(last.bottom, bottom);
|
||||
} else {
|
||||
// 겹치지 않는 영역 - 새로 추가
|
||||
mergedRegions.push({ y: region.y, bottom });
|
||||
}
|
||||
for (const layer of layers) {
|
||||
if (layer.type !== "conditional" || !layer.zoneId || !layer.displayRegion) continue;
|
||||
const zid = layer.zoneId;
|
||||
if (!zoneMap.has(zid)) {
|
||||
zoneMap.set(zid, {
|
||||
y: layer.displayRegion.y,
|
||||
height: layer.displayRegion.height,
|
||||
hasActive: false,
|
||||
});
|
||||
}
|
||||
if (activeLayerIds.includes(layer.id)) {
|
||||
zoneMap.get(zid)!.hasActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 병합된 영역들의 높이 합산
|
||||
return mergedRegions.reduce((offset, merged) => offset + (merged.bottom - merged.y), 0);
|
||||
}, [collapsedRegions]);
|
||||
let totalOffset = 0;
|
||||
for (const [, zone] of zoneMap) {
|
||||
const zoneBottom = zone.y + zone.height;
|
||||
// 컴포넌트가 Zone 하단보다 아래에 있고, Zone에 활성 레이어가 없으면 접힘
|
||||
if (componentY >= zoneBottom && !zone.hasActive) {
|
||||
totalOffset += zone.height;
|
||||
}
|
||||
}
|
||||
|
||||
return totalOffset;
|
||||
}, [layers, activeLayerIds]);
|
||||
|
||||
// 개선된 검증 시스템 (선택적 활성화)
|
||||
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
|
||||
|
|
@ -2378,7 +2357,48 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
// 일반/조건부 레이어 (base, conditional)
|
||||
// 조건부 레이어: Zone 기반 영역 내에 컴포넌트 렌더링
|
||||
if (layer.type === "conditional" && layer.displayRegion) {
|
||||
const region = layer.displayRegion;
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${region.x}px`,
|
||||
top: `${region.y}px`,
|
||||
width: `${region.width}px`,
|
||||
height: `${region.height}px`,
|
||||
zIndex: layer.zIndex,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{layer.components.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="pointer-events-auto absolute"
|
||||
style={{
|
||||
left: `${comp.position.x}px`,
|
||||
top: `${comp.position.y}px`,
|
||||
width: comp.style?.width || `${comp.size.width}px`,
|
||||
height: comp.style?.height || `${comp.size.height}px`,
|
||||
zIndex: comp.position.z || 1,
|
||||
}}
|
||||
>
|
||||
<InteractiveScreenViewer
|
||||
component={comp}
|
||||
allComponents={allLayerComponents}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본/기타 레이어 (base)
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
|
|
@ -2386,7 +2406,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
style={{ zIndex: layer.zIndex }}
|
||||
>
|
||||
{layer.components.map((comp) => {
|
||||
// 기본 레이어 컴포넌트만 Y 오프셋 적용 (조건부 레이어 컴포넌트는 자체 영역 내 표시)
|
||||
const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0;
|
||||
const adjustedY = comp.position.y - yOffset;
|
||||
|
||||
|
|
@ -2414,7 +2433,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
})}
|
||||
</div>
|
||||
);
|
||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents]);
|
||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]);
|
||||
|
||||
return (
|
||||
<SplitPanelProvider>
|
||||
|
|
|
|||
|
|
@ -81,16 +81,18 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
const isTriggerComponent = (comp: ComponentData): boolean => {
|
||||
const componentType = (comp.componentType || "").toLowerCase();
|
||||
const widgetType = ((comp as any).widgetType || "").toLowerCase();
|
||||
const webType = ((comp as any).webType || "").toLowerCase();
|
||||
const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase();
|
||||
const webType = ((comp as any).webType || comp.componentConfig?.webType || "").toLowerCase();
|
||||
const inputType = ((comp as any).inputType || comp.componentConfig?.inputType || "").toLowerCase();
|
||||
const source = ((comp as any).source || comp.componentConfig?.source || "").toLowerCase();
|
||||
|
||||
// 셀렉트, 라디오, 코드 타입 컴포넌트만 허용
|
||||
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity"];
|
||||
// 셀렉트, 라디오, 코드, 카테고리, 엔티티 타입 컴포넌트 허용
|
||||
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity", "category"];
|
||||
return triggerTypes.some((type) =>
|
||||
componentType.includes(type) ||
|
||||
widgetType.includes(type) ||
|
||||
webType.includes(type) ||
|
||||
inputType.includes(type)
|
||||
inputType.includes(type) ||
|
||||
source.includes(type)
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -112,9 +114,21 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
}, [components, baseLayerComponents]);
|
||||
|
||||
// 선택된 컴포넌트 정보
|
||||
// 기본 레이어 + 현재 레이어 통합 컴포넌트 목록 (트리거 컴포넌트 검색용)
|
||||
const allAvailableComponents = useMemo(() => {
|
||||
const merged = [...(baseLayerComponents || []), ...components];
|
||||
// 중복 제거 (id 기준)
|
||||
const seen = new Set<string>();
|
||||
return merged.filter((c) => {
|
||||
if (seen.has(c.id)) return false;
|
||||
seen.add(c.id);
|
||||
return true;
|
||||
});
|
||||
}, [components, baseLayerComponents]);
|
||||
|
||||
const selectedComponent = useMemo(() => {
|
||||
return components.find((c) => c.id === targetComponentId);
|
||||
}, [components, targetComponentId]);
|
||||
return allAvailableComponents.find((c) => c.id === targetComponentId);
|
||||
}, [allAvailableComponents, targetComponentId]);
|
||||
|
||||
// 선택된 컴포넌트의 데이터 소스 정보 추출
|
||||
const dataSourceInfo = useMemo<{
|
||||
|
|
@ -136,8 +150,17 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
const config = comp.componentConfig || comp.webTypeConfig || {};
|
||||
const detailSettings = comp.detailSettings || {};
|
||||
|
||||
// V2 컴포넌트: config.source 확인
|
||||
const source = config.source;
|
||||
// V2 컴포넌트: source 확인 (componentConfig, 상위 레벨, inputType 모두 체크)
|
||||
const source = config.source || comp.source;
|
||||
const inputType = config.inputType || comp.inputType;
|
||||
const webType = config.webType || comp.webType;
|
||||
|
||||
// inputType/webType이 category면 카테고리로 판단
|
||||
if (inputType === "category" || webType === "category") {
|
||||
const categoryTable = config.categoryTable || comp.tableName || config.tableName;
|
||||
const categoryColumn = config.categoryColumn || comp.columnName || config.columnName;
|
||||
return { type: "category", categoryTable, categoryColumn };
|
||||
}
|
||||
|
||||
// 1. 카테고리 소스 (V2: source === "category", category_values 테이블)
|
||||
if (source === "category") {
|
||||
|
|
@ -188,8 +211,17 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
return { type: "none" };
|
||||
}, [selectedComponent]);
|
||||
|
||||
// 의존성 안정화를 위한 직렬화 키
|
||||
const dataSourceKey = useMemo(() => {
|
||||
const { type, categoryTable, categoryColumn, codeCategory, originTable, originColumn, referenceTable, referenceColumn } = dataSourceInfo;
|
||||
return `${type}|${categoryTable || ""}|${categoryColumn || ""}|${codeCategory || ""}|${originTable || ""}|${originColumn || ""}|${referenceTable || ""}|${referenceColumn || ""}`;
|
||||
}, [dataSourceInfo]);
|
||||
|
||||
// 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적)
|
||||
useEffect(() => {
|
||||
// race condition 방지
|
||||
let cancelled = false;
|
||||
|
||||
if (dataSourceInfo.type === "none") {
|
||||
setOptions([]);
|
||||
return;
|
||||
|
|
@ -212,10 +244,13 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
try {
|
||||
if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) {
|
||||
// 카테고리 값에서 옵션 로드 (category_values 테이블)
|
||||
console.log("[LayerCondition] 카테고리 옵션 로드:", dataSourceInfo.categoryTable, dataSourceInfo.categoryColumn);
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${dataSourceInfo.categoryTable}/${dataSourceInfo.categoryColumn}/values`
|
||||
);
|
||||
if (cancelled) return;
|
||||
const data = response.data;
|
||||
console.log("[LayerCondition] 카테고리 API 응답:", data?.success, "항목수:", Array.isArray(data?.data) ? data.data.length : 0);
|
||||
if (data.success && data.data) {
|
||||
// 트리 구조를 평탄화
|
||||
const flattenTree = (items: any[], depth = 0): ConditionOption[] => {
|
||||
|
|
@ -232,22 +267,22 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
}
|
||||
return result;
|
||||
};
|
||||
setOptions(flattenTree(Array.isArray(data.data) ? data.data : []));
|
||||
const loadedOptions = flattenTree(Array.isArray(data.data) ? data.data : []);
|
||||
console.log("[LayerCondition] 카테고리 옵션 설정:", loadedOptions.length, "개");
|
||||
setOptions(loadedOptions);
|
||||
} else {
|
||||
setOptions([]);
|
||||
}
|
||||
} else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) {
|
||||
// 코드 카테고리에서 옵션 로드
|
||||
const codes = await getCodesByCategory(dataSourceInfo.codeCategory);
|
||||
if (cancelled) return;
|
||||
setOptions(codes.map((code) => ({
|
||||
value: code.code,
|
||||
label: code.name,
|
||||
})));
|
||||
} else if (dataSourceInfo.type === "entity") {
|
||||
// 엔티티 참조에서 옵션 로드
|
||||
// 방법 1: 원본 테이블.컬럼으로 entity-reference API 호출
|
||||
// (백엔드에서 table_type_columns를 통해 참조 테이블/컬럼을 자동 매핑)
|
||||
// 방법 2: 직접 참조 테이블로 폴백
|
||||
let entityLoaded = false;
|
||||
|
||||
if (dataSourceInfo.originTable && dataSourceInfo.originColumn) {
|
||||
|
|
@ -257,13 +292,13 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
dataSourceInfo.originColumn,
|
||||
{ limit: 100 }
|
||||
);
|
||||
if (cancelled) return;
|
||||
setOptions(entityData.options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})));
|
||||
entityLoaded = true;
|
||||
} catch {
|
||||
// 원본 테이블.컬럼으로 실패 시 폴백
|
||||
console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백");
|
||||
}
|
||||
}
|
||||
|
|
@ -277,6 +312,7 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
refColumn,
|
||||
{ limit: 100 }
|
||||
);
|
||||
if (cancelled) return;
|
||||
setOptions(entityData.options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
|
|
@ -287,25 +323,32 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 모든 방법 실패 시 빈 옵션으로 설정하고 에러 표시하지 않음
|
||||
if (!entityLoaded) {
|
||||
// 엔티티 소스이지만 테이블 조회 불가 시, 직접 입력 모드로 전환
|
||||
if (!entityLoaded && !cancelled) {
|
||||
setOptions([]);
|
||||
}
|
||||
} else {
|
||||
setOptions([]);
|
||||
if (!cancelled) setOptions([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("옵션 목록 로드 실패:", error);
|
||||
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
|
||||
setOptions([]);
|
||||
if (!cancelled) {
|
||||
console.error("옵션 목록 로드 실패:", error);
|
||||
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
|
||||
setOptions([]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingOptions(false);
|
||||
if (!cancelled) {
|
||||
setIsLoadingOptions(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadOptions();
|
||||
}, [dataSourceInfo]);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSourceKey]);
|
||||
|
||||
// 조건 저장
|
||||
const handleSave = useCallback(() => {
|
||||
|
|
@ -574,11 +617,11 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 현재 조건 요약 */}
|
||||
{targetComponentId && (value || multiValues.length > 0) && (
|
||||
{targetComponentId && selectedComponent && (value || multiValues.length > 0) && (
|
||||
<div className="p-2 bg-muted rounded-md text-xs">
|
||||
<span className="font-medium">요약: </span>
|
||||
<span className="text-muted-foreground">
|
||||
"{getComponentLabel(selectedComponent!)}" 값이{" "}
|
||||
"{getComponentLabel(selectedComponent)}" 값이{" "}
|
||||
{operator === "eq" && `"${options.find(o => o.value === value)?.label || value}"와 같으면`}
|
||||
{operator === "neq" && `"${options.find(o => o.value === value)?.label || value}"와 다르면`}
|
||||
{operator === "in" && `[${multiValues.map(v => options.find(o => o.value === v)?.label || v).join(", ")}] 중 하나이면`}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
|
|
@ -12,13 +14,14 @@ import {
|
|||
ChevronRight,
|
||||
Zap,
|
||||
Loader2,
|
||||
Box,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { convertV2ToLegacy } from "@/lib/utils/layoutV2Converter";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
import { LayerConditionPanel } from "./LayerConditionPanel";
|
||||
import { ComponentData, LayerCondition, DisplayRegion } from "@/types/screen-management";
|
||||
import { ComponentData, ConditionalZone } from "@/types/screen-management";
|
||||
|
||||
// DB 레이어 타입
|
||||
interface DBLayer {
|
||||
|
|
@ -34,6 +37,8 @@ interface LayerManagerPanelProps {
|
|||
activeLayerId: number; // 현재 활성 레이어 ID (DB layer_id)
|
||||
onLayerChange: (layerId: number) => void; // 레이어 전환
|
||||
components?: ComponentData[]; // 현재 활성 레이어의 컴포넌트 (폴백용)
|
||||
zones?: ConditionalZone[]; // Zone 목록 (ScreenDesigner에서 전달)
|
||||
onZonesChange?: (zones: ConditionalZone[]) => void; // Zone 목록 변경 콜백
|
||||
}
|
||||
|
||||
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
||||
|
|
@ -41,13 +46,23 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
|||
activeLayerId,
|
||||
onLayerChange,
|
||||
components = [],
|
||||
zones: externalZones,
|
||||
onZonesChange,
|
||||
}) => {
|
||||
const [layers, setLayers] = useState<DBLayer[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [conditionOpenLayerId, setConditionOpenLayerId] = useState<number | null>(null);
|
||||
// 기본 레이어(layer_id=1)의 컴포넌트 (조건 설정 시 트리거 대상)
|
||||
// 펼침/접힘 상태: zone_id별
|
||||
const [expandedZones, setExpandedZones] = useState<Set<number>>(new Set());
|
||||
// Zone에 레이어 추가 시 조건값 입력 상태
|
||||
const [addingToZoneId, setAddingToZoneId] = useState<number | null>(null);
|
||||
const [newConditionValue, setNewConditionValue] = useState("");
|
||||
// Zone 트리거 설정 열기 상태
|
||||
const [triggerEditZoneId, setTriggerEditZoneId] = useState<number | null>(null);
|
||||
// 기본 레이어 컴포넌트 (트리거 선택용)
|
||||
const [baseLayerComponents, setBaseLayerComponents] = useState<ComponentData[]>([]);
|
||||
|
||||
const zones = externalZones || [];
|
||||
|
||||
// 레이어 목록 로드
|
||||
const loadLayers = useCallback(async () => {
|
||||
if (!screenId) return;
|
||||
|
|
@ -62,60 +77,65 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
|||
}
|
||||
}, [screenId]);
|
||||
|
||||
// 기본 레이어 컴포넌트 로드 (조건 설정 패널에서 트리거 컴포넌트 선택용)
|
||||
// 기본 레이어 컴포넌트 로드
|
||||
const loadBaseLayerComponents = useCallback(async () => {
|
||||
if (!screenId) return;
|
||||
// 현재 활성 레이어가 기본 레이어(1)이면 props의 실시간 컴포넌트 사용
|
||||
if (activeLayerId === 1 && components.length > 0) {
|
||||
setBaseLayerComponents(components);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await screenApi.getLayerLayout(screenId, 1);
|
||||
if (data && data.components) {
|
||||
const legacy = convertV2ToLegacy(data);
|
||||
if (legacy) {
|
||||
setBaseLayerComponents(legacy.components as ComponentData[]);
|
||||
return;
|
||||
}
|
||||
if (data?.components) {
|
||||
setBaseLayerComponents(data.components as ComponentData[]);
|
||||
}
|
||||
setBaseLayerComponents([]);
|
||||
} catch {
|
||||
// 기본 레이어가 없거나 로드 실패 시 현재 컴포넌트 사용
|
||||
setBaseLayerComponents(components);
|
||||
}
|
||||
}, [screenId, components]);
|
||||
}, [screenId, components, activeLayerId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadLayers();
|
||||
}, [loadLayers]);
|
||||
loadBaseLayerComponents();
|
||||
}, [loadLayers, loadBaseLayerComponents]);
|
||||
|
||||
// 조건 설정 패널이 열릴 때 기본 레이어 컴포넌트 로드
|
||||
useEffect(() => {
|
||||
if (conditionOpenLayerId !== null) {
|
||||
loadBaseLayerComponents();
|
||||
}
|
||||
}, [conditionOpenLayerId, loadBaseLayerComponents]);
|
||||
// Zone별 레이어 그룹핑
|
||||
const getLayersForZone = useCallback((zoneId: number): DBLayer[] => {
|
||||
return layers.filter(l => {
|
||||
const cc = l.condition_config;
|
||||
return cc && cc.zone_id === zoneId;
|
||||
});
|
||||
}, [layers]);
|
||||
|
||||
// 새 레이어 추가
|
||||
const handleAddLayer = useCallback(async () => {
|
||||
if (!screenId) return;
|
||||
// 다음 layer_id 계산
|
||||
const maxLayerId = layers.length > 0 ? Math.max(...layers.map((l) => l.layer_id)) : 0;
|
||||
const newLayerId = maxLayerId + 1;
|
||||
// Zone에 속하지 않는 조건부 레이어 (레거시)
|
||||
const orphanLayers = layers.filter(l => {
|
||||
if (l.layer_id === 1) return false;
|
||||
const cc = l.condition_config;
|
||||
return !cc || !cc.zone_id;
|
||||
});
|
||||
|
||||
// 기본 레이어
|
||||
const baseLayer = layers.find(l => l.layer_id === 1);
|
||||
|
||||
// Zone에 레이어 추가
|
||||
const handleAddLayerToZone = useCallback(async (zoneId: number) => {
|
||||
if (!screenId || !newConditionValue.trim()) return;
|
||||
try {
|
||||
// 빈 레이아웃으로 새 레이어 저장
|
||||
await screenApi.saveLayoutV2(screenId, {
|
||||
version: "2.0",
|
||||
components: [],
|
||||
layerId: newLayerId,
|
||||
layerName: `조건부 레이어 ${newLayerId}`,
|
||||
});
|
||||
toast.success(`조건부 레이어 ${newLayerId}가 생성되었습니다.`);
|
||||
const result = await screenApi.addLayerToZone(
|
||||
screenId, zoneId, newConditionValue.trim(),
|
||||
`레이어 (${newConditionValue.trim()})`,
|
||||
);
|
||||
toast.success(`레이어가 Zone에 추가되었습니다. (ID: ${result.layerId})`);
|
||||
setAddingToZoneId(null);
|
||||
setNewConditionValue("");
|
||||
await loadLayers();
|
||||
// 새 레이어로 전환
|
||||
onLayerChange(newLayerId);
|
||||
onLayerChange(result.layerId);
|
||||
} catch (error) {
|
||||
console.error("레이어 추가 실패:", error);
|
||||
console.error("Zone 레이어 추가 실패:", error);
|
||||
toast.error("레이어 추가에 실패했습니다.");
|
||||
}
|
||||
}, [screenId, layers, loadLayers, onLayerChange]);
|
||||
}, [screenId, newConditionValue, loadLayers, onLayerChange]);
|
||||
|
||||
// 레이어 삭제
|
||||
const handleDeleteLayer = useCallback(async (layerId: number) => {
|
||||
|
|
@ -124,28 +144,203 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
|||
await screenApi.deleteLayer(screenId, layerId);
|
||||
toast.success("레이어가 삭제되었습니다.");
|
||||
await loadLayers();
|
||||
// 기본 레이어로 전환
|
||||
if (activeLayerId === layerId) {
|
||||
onLayerChange(1);
|
||||
}
|
||||
if (activeLayerId === layerId) onLayerChange(1);
|
||||
} catch (error) {
|
||||
console.error("레이어 삭제 실패:", error);
|
||||
toast.error("레이어 삭제에 실패했습니다.");
|
||||
}
|
||||
}, [screenId, activeLayerId, loadLayers, onLayerChange]);
|
||||
|
||||
// 조건 업데이트
|
||||
const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => {
|
||||
// Zone 삭제
|
||||
const handleDeleteZone = useCallback(async (zoneId: number) => {
|
||||
if (!screenId) return;
|
||||
try {
|
||||
await screenApi.updateLayerCondition(screenId, layerId, condition || null);
|
||||
toast.success("조건이 저장되었습니다.");
|
||||
await screenApi.deleteZone(zoneId);
|
||||
toast.success("조건부 영역이 삭제되었습니다.");
|
||||
// Zone 목록 새로고침
|
||||
const loadedZones = await screenApi.getScreenZones(screenId);
|
||||
onZonesChange?.(loadedZones);
|
||||
await loadLayers();
|
||||
onLayerChange(1);
|
||||
} catch (error) {
|
||||
console.error("조건 업데이트 실패:", error);
|
||||
toast.error("조건 저장에 실패했습니다.");
|
||||
console.error("Zone 삭제 실패:", error);
|
||||
toast.error("Zone 삭제에 실패했습니다.");
|
||||
}
|
||||
}, [screenId, loadLayers]);
|
||||
}, [screenId, loadLayers, onLayerChange, onZonesChange]);
|
||||
|
||||
// 동적 소스 옵션 캐시 (trigger_component_id → 옵션 배열)
|
||||
const [dynamicOptionsCache, setDynamicOptionsCache] = useState<Record<string, { value: string; label: string }[]>>({});
|
||||
const [loadingDynamicOptions, setLoadingDynamicOptions] = useState<Set<string>>(new Set());
|
||||
// 이미 로드 시도한 키를 추적 (중복 요청 방지)
|
||||
const loadedKeysRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// 동적 소스 옵션 로드 함수
|
||||
const loadDynamicOptions = useCallback(async (triggerCompId: string, comp: ComponentData) => {
|
||||
const cacheKey = triggerCompId;
|
||||
// 이미 로드 완료 또는 로드 중이면 스킵
|
||||
if (loadedKeysRef.current.has(cacheKey)) return;
|
||||
loadedKeysRef.current.add(cacheKey);
|
||||
|
||||
setLoadingDynamicOptions(prev => new Set(prev).add(cacheKey));
|
||||
try {
|
||||
const config = comp.componentConfig || {};
|
||||
const isCategory = (comp as any).inputType === "category" || (comp as any).webType === "category";
|
||||
const source = isCategory ? "category" : config.source;
|
||||
const compTableName = (comp as any).tableName || config.tableName;
|
||||
const compColumnName = (comp as any).columnName || config.columnName;
|
||||
let fetchedOptions: { value: string; label: string }[] = [];
|
||||
|
||||
if (source === "category" || isCategory) {
|
||||
// 카테고리 소스: /table-categories/:tableName/:columnName/values
|
||||
const catTable = config.categoryTable || compTableName;
|
||||
const catColumn = config.categoryColumn || compColumnName;
|
||||
if (catTable && catColumn) {
|
||||
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
// 트리 구조를 평탄화 (valueCode/valueLabel 사용)
|
||||
const flattenTree = (items: any[]): { value: string; label: string }[] => {
|
||||
const result: { value: string; label: string }[] = [];
|
||||
for (const item of items) {
|
||||
result.push({ value: item.valueCode, label: item.valueLabel });
|
||||
if (item.children && item.children.length > 0) {
|
||||
result.push(...flattenTree(item.children));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
fetchedOptions = flattenTree(data.data);
|
||||
}
|
||||
}
|
||||
} else if (source === "code" && config.codeGroup) {
|
||||
// 공통코드 소스
|
||||
const response = await apiClient.get(`/common-codes/categories/${config.codeGroup}/options`);
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
}));
|
||||
}
|
||||
} else if (source === "entity" && config.entityTable) {
|
||||
// 엔티티 소스
|
||||
const valueCol = config.entityValueColumn || "id";
|
||||
const labelCol = config.entityLabelColumn || "name";
|
||||
const response = await apiClient.get(`/entity/${config.entityTable}/options`, {
|
||||
params: { value: valueCol, label: labelCol },
|
||||
});
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
fetchedOptions = data.data;
|
||||
}
|
||||
} else if ((source === "distinct" || source === "select") && compTableName && compColumnName) {
|
||||
// DISTINCT 소스
|
||||
const isValidCol = compColumnName && !compColumnName.startsWith("comp_");
|
||||
if (isValidCol) {
|
||||
const response = await apiClient.get(`/entity/${compTableName}/distinct/${compColumnName}`);
|
||||
const data = response.data;
|
||||
if (data.success && data.data) {
|
||||
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
|
||||
value: String(item.value),
|
||||
label: String(item.label),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDynamicOptionsCache(prev => ({ ...prev, [cacheKey]: fetchedOptions }));
|
||||
} catch (error) {
|
||||
console.error("트리거 옵션 동적 로드 실패:", error);
|
||||
setDynamicOptionsCache(prev => ({ ...prev, [cacheKey]: [] }));
|
||||
} finally {
|
||||
setLoadingDynamicOptions(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(cacheKey);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Zone 트리거 컴포넌트 업데이트
|
||||
const handleUpdateZoneTrigger = useCallback(async (zoneId: number, triggerComponentId: string, operator: string = "eq") => {
|
||||
try {
|
||||
await screenApi.updateZone(zoneId, {
|
||||
trigger_component_id: triggerComponentId,
|
||||
trigger_operator: operator,
|
||||
});
|
||||
const loadedZones = await screenApi.getScreenZones(screenId!);
|
||||
onZonesChange?.(loadedZones);
|
||||
|
||||
// 트리거 변경 시 해당 컴포넌트의 동적 옵션 캐시 초기화 → 새로 로드
|
||||
loadedKeysRef.current.delete(triggerComponentId);
|
||||
const triggerComp = baseLayerComponents.find(c => c.id === triggerComponentId);
|
||||
if (triggerComp) {
|
||||
loadDynamicOptions(triggerComponentId, triggerComp);
|
||||
}
|
||||
|
||||
toast.success("트리거가 설정되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("Zone 트리거 업데이트 실패:", error);
|
||||
toast.error("트리거 설정에 실패했습니다.");
|
||||
}
|
||||
}, [screenId, onZonesChange, baseLayerComponents, loadDynamicOptions]);
|
||||
|
||||
// Zone 접힘/펼침 토글
|
||||
const toggleZone = (zoneId: number) => {
|
||||
setExpandedZones(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(zoneId) ? next.delete(zoneId) : next.add(zoneId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 트리거로 사용 가능한 컴포넌트 (select, combobox 등)
|
||||
const triggerableComponents = baseLayerComponents.filter(c =>
|
||||
["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t))
|
||||
);
|
||||
|
||||
// Zone 트리거가 변경되면 동적 옵션 로드
|
||||
useEffect(() => {
|
||||
for (const zone of zones) {
|
||||
if (!zone.trigger_component_id) continue;
|
||||
const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id);
|
||||
if (!triggerComp) continue;
|
||||
|
||||
const config = triggerComp.componentConfig || {};
|
||||
const source = config.source;
|
||||
const isCategory = (triggerComp as any).inputType === "category" || (triggerComp as any).webType === "category";
|
||||
|
||||
// 정적 옵션이 아닌 경우에만 동적 로드
|
||||
const hasStaticOptions = config.options && Array.isArray(config.options) && config.options.length > 0;
|
||||
if (!hasStaticOptions && (source === "category" || source === "code" || source === "entity" || source === "distinct" || source === "select" || isCategory)) {
|
||||
loadDynamicOptions(zone.trigger_component_id, triggerComp);
|
||||
}
|
||||
}
|
||||
}, [zones, baseLayerComponents, loadDynamicOptions]);
|
||||
|
||||
// Zone의 트리거 컴포넌트에서 옵션 목록 가져오기 (정적 + 동적 지원)
|
||||
const getTriggerOptions = useCallback((zone: ConditionalZone): { value: string; label: string }[] => {
|
||||
if (!zone.trigger_component_id) return [];
|
||||
const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id);
|
||||
if (!triggerComp) return [];
|
||||
|
||||
const config = triggerComp.componentConfig || {};
|
||||
|
||||
// 1. 정적 옵션 우선 확인
|
||||
if (config.options && Array.isArray(config.options) && config.options.length > 0) {
|
||||
return config.options
|
||||
.filter((opt: any) => opt.value)
|
||||
.map((opt: any) => ({ value: opt.value, label: opt.label || opt.value }));
|
||||
}
|
||||
|
||||
// 2. 동적 소스 옵션 (캐시에서 가져오기)
|
||||
const cached = dynamicOptionsCache[zone.trigger_component_id];
|
||||
if (cached && cached.length > 0) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [baseLayerComponents, dynamicOptionsCache]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
|
|
@ -158,19 +353,9 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
|||
{layers.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2"
|
||||
onClick={handleAddLayer}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 레이어 목록 */}
|
||||
{/* 레이어 + Zone 목록 */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 p-2">
|
||||
{isLoading ? (
|
||||
|
|
@ -178,146 +363,343 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
|
|||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">로딩 중...</span>
|
||||
</div>
|
||||
) : layers.length === 0 ? (
|
||||
<div className="space-y-2 py-4 text-center">
|
||||
<p className="text-sm text-muted-foreground">레이어를 로드하는 중...</p>
|
||||
<p className="text-[10px] text-muted-foreground">먼저 화면을 저장하면 기본 레이어가 생성됩니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
layers
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((layer) => {
|
||||
const isActive = activeLayerId === layer.layer_id;
|
||||
const isBase = layer.layer_id === 1;
|
||||
const hasCondition = !!layer.condition_config;
|
||||
const isConditionOpen = conditionOpenLayerId === layer.layer_id;
|
||||
<>
|
||||
{/* 기본 레이어 */}
|
||||
{baseLayer && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
|
||||
activeLayerId === 1
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-transparent hover:bg-muted",
|
||||
)}
|
||||
onClick={() => onLayerChange(1)}
|
||||
>
|
||||
<span className="shrink-0 rounded bg-blue-100 p-1 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
<Layers className="h-3 w-3" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium">{baseLayer.layer_name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]">기본</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">{baseLayer.component_count}개 컴포넌트</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조건부 영역(Zone) 목록 */}
|
||||
{zones.map((zone) => {
|
||||
const zoneLayers = getLayersForZone(zone.zone_id);
|
||||
const isExpanded = expandedZones.has(zone.zone_id);
|
||||
const isTriggerEdit = triggerEditZoneId === zone.zone_id;
|
||||
|
||||
return (
|
||||
<div key={layer.layer_id} className="space-y-0">
|
||||
<div key={`zone-${zone.zone_id}`} className="space-y-0">
|
||||
{/* Zone 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
|
||||
isActive
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-transparent hover:bg-muted",
|
||||
isConditionOpen && "rounded-b-none border-b-0",
|
||||
"border-amber-200 bg-amber-50/50 hover:bg-amber-100/50 dark:border-amber-800 dark:bg-amber-950/20",
|
||||
isExpanded && "rounded-b-none border-b-0",
|
||||
)}
|
||||
onClick={() => onLayerChange(layer.layer_id)}
|
||||
// 조건부 레이어를 캔버스로 드래그 (영역 배치용)
|
||||
draggable={!isBase}
|
||||
onDragStart={(e) => {
|
||||
if (isBase) return;
|
||||
e.dataTransfer.setData("application/json", JSON.stringify({
|
||||
type: "layer-region",
|
||||
layerId: layer.layer_id,
|
||||
layerName: layer.layer_name,
|
||||
}));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}}
|
||||
onClick={() => toggleZone(zone.zone_id)}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-amber-600" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-amber-600" />
|
||||
)}
|
||||
<Box className="h-3.5 w-3.5 shrink-0 text-amber-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium text-amber-800 dark:text-amber-300">{zone.zone_name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"shrink-0 rounded p-1",
|
||||
isBase
|
||||
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
|
||||
: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
|
||||
)}>
|
||||
{isBase ? <Layers className="h-3 w-3" /> : <SplitSquareVertical className="h-3 w-3" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate font-medium">{layer.layer_name}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-2">
|
||||
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]">
|
||||
{isBase ? "기본" : "조건부"}
|
||||
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px] border-amber-300 text-amber-700">
|
||||
Zone
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{layer.component_count}개 컴포넌트
|
||||
{zoneLayers.length}개 레이어 | {zone.width}x{zone.height}
|
||||
</span>
|
||||
{hasCondition && (
|
||||
{zone.trigger_component_id && (
|
||||
<Badge variant="secondary" className="h-4 gap-0.5 px-1 py-0 text-[10px]">
|
||||
<Zap className="h-2.5 w-2.5" />
|
||||
조건
|
||||
<Zap className="h-2.5 w-2.5" /> 트리거
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
{/* Zone 액션 버튼 */}
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{!isBase && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-6 w-6", hasCondition && "text-amber-600")}
|
||||
title="조건 설정"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConditionOpenLayerId(isConditionOpen ? null : layer.layer_id);
|
||||
}}
|
||||
>
|
||||
{isConditionOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{!isBase && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:text-destructive"
|
||||
title="레이어 삭제"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteLayer(layer.layer_id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-6 w-6 text-amber-600 hover:text-amber-800"
|
||||
title="트리거 설정"
|
||||
onClick={(e) => { e.stopPropagation(); setTriggerEditZoneId(isTriggerEdit ? null : zone.zone_id); }}
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-6 w-6 hover:text-destructive"
|
||||
title="Zone 삭제"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteZone(zone.zone_id); }}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건 설정 패널 */}
|
||||
{!isBase && isConditionOpen && (
|
||||
{/* 펼쳐진 Zone 내용 */}
|
||||
{isExpanded && (
|
||||
<div className={cn(
|
||||
"rounded-b-md border border-t-0 bg-muted/30",
|
||||
isActive ? "border-primary" : "border-border",
|
||||
"rounded-b-md border border-t-0 border-amber-200 bg-amber-50/20 p-2 space-y-1",
|
||||
"dark:border-amber-800 dark:bg-amber-950/10",
|
||||
)}>
|
||||
<LayerConditionPanel
|
||||
layer={{
|
||||
id: String(layer.layer_id),
|
||||
name: layer.layer_name,
|
||||
type: "conditional",
|
||||
zIndex: layer.layer_id,
|
||||
isVisible: true,
|
||||
isLocked: false,
|
||||
condition: layer.condition_config || undefined,
|
||||
components: [],
|
||||
}}
|
||||
components={baseLayerComponents}
|
||||
baseLayerComponents={baseLayerComponents}
|
||||
onUpdateCondition={(condition) => handleUpdateCondition(layer.layer_id, condition)}
|
||||
onUpdateDisplayRegion={() => {}}
|
||||
onClose={() => setConditionOpenLayerId(null)}
|
||||
/>
|
||||
{/* 트리거 설정 패널 */}
|
||||
{isTriggerEdit && (
|
||||
<div className="mb-2 rounded border bg-background p-2 space-y-2">
|
||||
<p className="text-[10px] font-medium text-muted-foreground">트리거 컴포넌트 선택</p>
|
||||
{triggerableComponents.length === 0 ? (
|
||||
<p className="text-[10px] text-muted-foreground">기본 레이어에 Select/Combobox/Radio 컴포넌트가 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{triggerableComponents.map(c => (
|
||||
<button
|
||||
key={c.id}
|
||||
className={cn(
|
||||
"w-full text-left rounded px-2 py-1 text-[11px] transition-colors",
|
||||
zone.trigger_component_id === c.id
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
onClick={() => handleUpdateZoneTrigger(zone.zone_id, c.id!)}
|
||||
>
|
||||
{c.componentConfig?.label || c.id} ({c.componentType})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zone 소속 레이어 목록 */}
|
||||
{zoneLayers.map((layer) => {
|
||||
const isActive = activeLayerId === layer.layer_id;
|
||||
const triggerOpts = getTriggerOptions(zone);
|
||||
const currentCondValue = layer.condition_config?.condition_value || "";
|
||||
return (
|
||||
<div
|
||||
key={layer.layer_id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md border p-1.5 text-sm transition-all",
|
||||
isActive
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-transparent hover:bg-background",
|
||||
)}
|
||||
onClick={() => onLayerChange(layer.layer_id)}
|
||||
>
|
||||
<SplitSquareVertical className="h-3 w-3 shrink-0 text-amber-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-xs font-medium truncate">{layer.layer_name}</span>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
{triggerOpts.length > 0 ? (
|
||||
<Select
|
||||
value={currentCondValue || "_none_"}
|
||||
onValueChange={async (val) => {
|
||||
if (!screenId) return;
|
||||
const newVal = val === "_none_" ? "" : val;
|
||||
try {
|
||||
await screenApi.updateLayerCondition(
|
||||
screenId,
|
||||
layer.layer_id,
|
||||
{ ...layer.condition_config, condition_value: newVal },
|
||||
layer.layer_name,
|
||||
);
|
||||
await loadLayers();
|
||||
toast.success("조건값이 변경되었습니다.");
|
||||
} catch {
|
||||
toast.error("조건값 변경에 실패했습니다.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-5 text-[10px] w-auto min-w-[80px] max-w-[140px] px-1.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<SelectValue placeholder="조건값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none_" className="text-xs text-muted-foreground">미설정</SelectItem>
|
||||
{triggerOpts.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
조건값: {currentCondValue || "미설정"}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
| {layer.component_count}개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-5 w-5 hover:text-destructive"
|
||||
title="레이어 삭제"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteLayer(layer.layer_id); }}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 레이어 추가 */}
|
||||
{addingToZoneId === zone.zone_id ? (
|
||||
<div className="flex items-center gap-1 rounded border bg-background p-1.5">
|
||||
{(() => {
|
||||
// 동적 옵션 로딩 중 표시
|
||||
const isLoadingOpts = zone.trigger_component_id ? loadingDynamicOptions.has(zone.trigger_component_id) : false;
|
||||
if (isLoadingOpts) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-1 text-[11px] text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
옵션 로딩 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const triggerOpts = getTriggerOptions(zone);
|
||||
// 이미 사용된 조건값 제외
|
||||
const usedValues = new Set(
|
||||
zoneLayers.map(l => l.condition_config?.condition_value).filter(Boolean)
|
||||
);
|
||||
const availableOpts = triggerOpts.filter(o => !usedValues.has(o.value));
|
||||
|
||||
if (availableOpts.length > 0) {
|
||||
return (
|
||||
<Select value={newConditionValue} onValueChange={setNewConditionValue}>
|
||||
<SelectTrigger className="h-6 text-[11px] flex-1">
|
||||
<SelectValue placeholder="조건값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableOpts.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
value={newConditionValue}
|
||||
onChange={(e) => setNewConditionValue(e.target.value)}
|
||||
placeholder="조건값 입력"
|
||||
className="h-6 text-[11px] flex-1"
|
||||
autoFocus
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<Button
|
||||
variant="default" size="sm"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={() => handleAddLayerToZone(zone.zone_id)}
|
||||
disabled={!newConditionValue.trim()}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
className="h-6 px-1 text-[10px]"
|
||||
onClick={() => { setAddingToZoneId(null); setNewConditionValue(""); }}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline" size="sm"
|
||||
className="h-6 w-full gap-1 text-[10px] border-dashed"
|
||||
onClick={() => setAddingToZoneId(zone.zone_id)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
레이어 추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
})}
|
||||
|
||||
{/* 고아 레이어 (Zone에 소속되지 않은 조건부 레이어) */}
|
||||
{orphanLayers.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-[10px] font-medium text-muted-foreground px-1">Zone 미할당 레이어</p>
|
||||
{orphanLayers.map((layer) => {
|
||||
const isActive = activeLayerId === layer.layer_id;
|
||||
return (
|
||||
<div
|
||||
key={layer.layer_id}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
|
||||
isActive
|
||||
? "border-primary bg-primary/5 shadow-sm"
|
||||
: "border-transparent hover:bg-muted",
|
||||
)}
|
||||
onClick={() => onLayerChange(layer.layer_id)}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="truncate font-medium">{layer.layer_name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]">조건부</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">{layer.component_count}개</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-6 w-6 hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteLayer(layer.layer_id); }}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
|
||||
<p>레이어를 클릭하여 편집 | 조건부 레이어를 캔버스에 드래그하여 영역 설정</p>
|
||||
{/* Zone 생성 드래그 영역 */}
|
||||
<div className="border-t px-3 py-2 space-y-1">
|
||||
<div
|
||||
className="flex cursor-grab items-center gap-2 rounded border border-dashed border-amber-300 bg-amber-50/50 px-2 py-1.5 text-xs text-amber-700 dark:border-amber-700 dark:bg-amber-950/20 dark:text-amber-400"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("application/json", JSON.stringify({ type: "create-zone" }));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
}}
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
<Box className="h-3.5 w-3.5" />
|
||||
<span>조건부 영역 추가 (캔버스로 드래그)</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
기본 레이어에서 Zone을 배치한 후, Zone 내에 레이어를 추가하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -561,9 +561,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
zIndex: position?.z || 1,
|
||||
// right 속성 강제 제거
|
||||
right: undefined,
|
||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
|
||||
transition:
|
||||
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
||||
// 모든 컴포넌트에서 transition 완전 제거 (위치 변경 시 애니메이션 방지)
|
||||
transition: "none",
|
||||
};
|
||||
|
||||
// 선택된 컴포넌트 스타일
|
||||
|
|
@ -594,7 +593,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
return (
|
||||
<div
|
||||
id={`component-${id}`}
|
||||
className="absolute cursor-pointer"
|
||||
className="absolute cursor-pointer !transition-none"
|
||||
style={{ ...componentStyle, ...selectionStyle }}
|
||||
onClick={handleClick}
|
||||
draggable
|
||||
|
|
|
|||
|
|
@ -523,15 +523,15 @@ export default function ScreenDesigner({
|
|||
// 🆕 좌측 패널 탭 상태 관리
|
||||
const [leftPanelTab, setLeftPanelTab] = useState<string>("components");
|
||||
|
||||
// 🆕 레이어 영역 (기본 레이어에서 조건부 레이어들의 displayRegion 표시)
|
||||
const [layerRegions, setLayerRegions] = useState<Record<number, { x: number; y: number; width: number; height: number; layerName: string }>>({});
|
||||
// 🆕 조건부 영역(Zone) 목록 (DB screen_conditional_zones 기반)
|
||||
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
|
||||
|
||||
// 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정)
|
||||
const [regionDrag, setRegionDrag] = useState<{
|
||||
isDrawing: boolean; // 새 영역 그리기 모드
|
||||
isDragging: boolean; // 기존 영역 이동 모드
|
||||
isResizing: boolean; // 기존 영역 리사이즈 모드
|
||||
targetLayerId: string | null; // 대상 레이어 ID
|
||||
targetLayerId: string | null; // 대상 Zone ID (문자열)
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
|
|
@ -551,6 +551,33 @@ export default function ScreenDesigner({
|
|||
originalRegion: null,
|
||||
});
|
||||
|
||||
// 🆕 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
|
||||
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
|
||||
|
||||
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
|
||||
useEffect(() => {
|
||||
if (activeLayerId <= 1 || !selectedScreen?.screenId) {
|
||||
setActiveLayerZone(null);
|
||||
return;
|
||||
}
|
||||
// 레이어의 condition_config에서 zone_id를 가져와서 zones에서 찾기
|
||||
const findZone = async () => {
|
||||
try {
|
||||
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, activeLayerId);
|
||||
const zoneId = layerData?.conditionConfig?.zone_id;
|
||||
if (zoneId) {
|
||||
const zone = zones.find(z => z.zone_id === zoneId);
|
||||
setActiveLayerZone(zone || null);
|
||||
} else {
|
||||
setActiveLayerZone(null);
|
||||
}
|
||||
} catch {
|
||||
setActiveLayerZone(null);
|
||||
}
|
||||
};
|
||||
findZone();
|
||||
}, [activeLayerId, selectedScreen?.screenId, zones]);
|
||||
|
||||
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
|
||||
const visibleComponents = useMemo(() => {
|
||||
return layout.components;
|
||||
|
|
@ -1575,20 +1602,11 @@ export default function ScreenDesigner({
|
|||
// 파일 컴포넌트 데이터 복원 (비동기)
|
||||
restoreFileComponentsData(layoutWithDefaultGrid.components);
|
||||
|
||||
// 🆕 레이어 영역 로드 (조건부 레이어의 displayRegion)
|
||||
// 🆕 조건부 영역(Zone) 로드
|
||||
try {
|
||||
const layers = await screenApi.getScreenLayers(selectedScreen.screenId);
|
||||
const regions: Record<number, any> = {};
|
||||
for (const layer of layers) {
|
||||
if (layer.layer_id > 1 && layer.condition_config?.displayRegion) {
|
||||
regions[layer.layer_id] = {
|
||||
...layer.condition_config.displayRegion,
|
||||
layerName: layer.layer_name,
|
||||
};
|
||||
}
|
||||
}
|
||||
setLayerRegions(regions);
|
||||
} catch { /* 레이어 로드 실패 무시 */ }
|
||||
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
|
||||
setZones(loadedZones);
|
||||
} catch { /* Zone 로드 실패 무시 */ }
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("레이아웃 로드 실패:", error);
|
||||
|
|
@ -1928,17 +1946,33 @@ export default function ScreenDesigner({
|
|||
[groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory]
|
||||
);
|
||||
|
||||
// 라벨 일괄 토글
|
||||
// 라벨 일괄 토글 (선택된 컴포넌트가 있으면 선택된 것만, 없으면 전체)
|
||||
const handleToggleAllLabels = useCallback(() => {
|
||||
saveToHistory(layout);
|
||||
const newComponents = toggleAllLabels(layout.components);
|
||||
|
||||
const selectedIds = groupState.selectedComponents;
|
||||
const isPartial = selectedIds.length > 0;
|
||||
|
||||
// 토글 대상 컴포넌트 필터
|
||||
const targetComponents = layout.components.filter((c) => {
|
||||
if (!c.label || ["group", "datatable"].includes(c.type)) return false;
|
||||
if (isPartial) return selectedIds.includes(c.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const hadHidden = targetComponents.some(
|
||||
(c) => (c.style as any)?.labelDisplay === false
|
||||
);
|
||||
|
||||
const newComponents = toggleAllLabels(layout.components, selectedIds);
|
||||
setLayout((prev) => ({ ...prev, components: newComponents }));
|
||||
|
||||
const hasHidden = layout.components.some(
|
||||
(c) => c.type === "widget" && (c.style as any)?.labelDisplay === false
|
||||
);
|
||||
toast.success(hasHidden ? "모든 라벨 표시" : "모든 라벨 숨기기");
|
||||
}, [layout, saveToHistory]);
|
||||
// 강제 리렌더링 트리거
|
||||
setForceRenderTrigger((prev) => prev + 1);
|
||||
|
||||
const scope = isPartial ? `선택된 ${targetComponents.length}개` : "모든";
|
||||
toast.success(hadHidden ? `${scope} 라벨 표시` : `${scope} 라벨 숨기기`);
|
||||
}, [layout, saveToHistory, groupState.selectedComponents]);
|
||||
|
||||
// Nudge (화살표 키 이동)
|
||||
const handleNudge = useCallback(
|
||||
|
|
@ -2145,7 +2179,12 @@ export default function ScreenDesigner({
|
|||
if (USE_POP_API) {
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||
} else if (USE_V2_API) {
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||
// 현재 활성 레이어 ID 포함 (레이어별 저장)
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
|
||||
}
|
||||
|
|
@ -3049,9 +3088,13 @@ export default function ScreenDesigner({
|
|||
})
|
||||
: null;
|
||||
|
||||
// 캔버스 경계 내로 위치 제한
|
||||
const boundedX = Math.max(0, Math.min(dropX, screenResolution.width - componentWidth));
|
||||
const boundedY = Math.max(0, Math.min(dropY, screenResolution.height - componentHeight));
|
||||
// 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 Zone 크기 기준)
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
const activeLayerRegion = currentLayerId > 1 ? activeLayerZone : null;
|
||||
const canvasBoundW = activeLayerRegion ? activeLayerRegion.width : screenResolution.width;
|
||||
const canvasBoundH = activeLayerRegion ? activeLayerRegion.height : screenResolution.height;
|
||||
const boundedX = Math.max(0, Math.min(dropX, canvasBoundW - componentWidth));
|
||||
const boundedY = Math.max(0, Math.min(dropY, canvasBoundH - componentHeight));
|
||||
|
||||
// 격자 스냅 적용
|
||||
const snappedPosition =
|
||||
|
|
@ -3323,37 +3366,27 @@ export default function ScreenDesigner({
|
|||
return;
|
||||
}
|
||||
|
||||
// 🆕 조건부 레이어 영역 드래그인 경우 → DB condition_config에 displayRegion 저장
|
||||
if (parsedData.type === "layer-region" && parsedData.layerId && selectedScreen?.screenId) {
|
||||
// 🆕 조건부 영역(Zone) 생성 드래그인 경우 → DB screen_conditional_zones에 저장
|
||||
if (parsedData.type === "create-zone" && selectedScreen?.screenId) {
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!canvasRect) return;
|
||||
const dropX = Math.round((e.clientX - canvasRect.left) / zoomLevel);
|
||||
const dropY = Math.round((e.clientY - canvasRect.top) / zoomLevel);
|
||||
const newRegion = {
|
||||
x: Math.max(0, dropX - 400),
|
||||
y: Math.max(0, dropY),
|
||||
width: Math.min(800, screenResolution.width),
|
||||
height: 200,
|
||||
};
|
||||
// DB에 displayRegion 저장 (condition_config에 포함)
|
||||
try {
|
||||
// 기존 condition_config를 가져와서 displayRegion만 추가/업데이트
|
||||
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, parsedData.layerId);
|
||||
const existingCondition = layerData?.conditionConfig || {};
|
||||
await screenApi.updateLayerCondition(
|
||||
selectedScreen.screenId,
|
||||
parsedData.layerId,
|
||||
{ ...existingCondition, displayRegion: newRegion }
|
||||
);
|
||||
// 레이어 영역 state에 반영 (캔버스에 즉시 표시)
|
||||
setLayerRegions((prev) => ({
|
||||
...prev,
|
||||
[parsedData.layerId]: { ...newRegion, layerName: parsedData.layerName },
|
||||
}));
|
||||
toast.success(`"${parsedData.layerName}" 영역이 배치되었습니다.`);
|
||||
await screenApi.createZone(selectedScreen.screenId, {
|
||||
zone_name: "조건부 영역",
|
||||
x: Math.max(0, dropX - 400),
|
||||
y: Math.max(0, dropY),
|
||||
width: Math.min(800, screenResolution.width),
|
||||
height: 200,
|
||||
});
|
||||
// Zone 목록 새로고침
|
||||
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
|
||||
setZones(loadedZones);
|
||||
toast.success("조건부 영역이 생성되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("레이어 영역 저장 실패:", error);
|
||||
toast.error("레이어 영역 저장에 실패했습니다.");
|
||||
console.error("Zone 생성 실패:", error);
|
||||
toast.error("조건부 영역 생성에 실패했습니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -4265,9 +4298,15 @@ export default function ScreenDesigner({
|
|||
const rawX = relativeMouseX - dragState.grabOffset.x;
|
||||
const rawY = relativeMouseY - dragState.grabOffset.y;
|
||||
|
||||
// 조건부 레이어 편집 시 Zone 크기 기준 경계 제한
|
||||
const dragLayerId = activeLayerIdRef.current || 1;
|
||||
const dragLayerRegion = dragLayerId > 1 ? activeLayerZone : null;
|
||||
const dragBoundW = dragLayerRegion ? dragLayerRegion.width : screenResolution.width;
|
||||
const dragBoundH = dragLayerRegion ? dragLayerRegion.height : screenResolution.height;
|
||||
|
||||
const newPosition = {
|
||||
x: Math.max(0, Math.min(rawX, screenResolution.width - componentWidth)),
|
||||
y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)),
|
||||
x: Math.max(0, Math.min(rawX, dragBoundW - componentWidth)),
|
||||
y: Math.max(0, Math.min(rawY, dragBoundH - componentHeight)),
|
||||
z: (dragState.draggedComponent.position as Position).z || 1,
|
||||
};
|
||||
|
||||
|
|
@ -5560,7 +5599,12 @@ export default function ScreenDesigner({
|
|||
if (USE_POP_API) {
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||
} else if (USE_V2_API) {
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||
// 현재 활성 레이어 ID 포함 (레이어별 저장)
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
}
|
||||
|
|
@ -5762,9 +5806,9 @@ export default function ScreenDesigner({
|
|||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const lid = Number(layerId);
|
||||
const region = layerRegions[lid];
|
||||
if (!region) return;
|
||||
const zoneId = Number(layerId); // layerId는 실제로 zoneId
|
||||
const zone = zones.find(z => z.zone_id === zoneId);
|
||||
if (!zone) return;
|
||||
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!canvasRect) return;
|
||||
|
|
@ -5776,15 +5820,15 @@ export default function ScreenDesigner({
|
|||
isDrawing: false,
|
||||
isDragging: mode === "move",
|
||||
isResizing: mode === "resize",
|
||||
targetLayerId: layerId,
|
||||
targetLayerId: String(zoneId),
|
||||
startX: x,
|
||||
startY: y,
|
||||
currentX: x,
|
||||
currentY: y,
|
||||
resizeHandle: handle || null,
|
||||
originalRegion: { x: region.x, y: region.y, width: region.width, height: region.height },
|
||||
originalRegion: { x: zone.x, y: zone.y, width: zone.width, height: zone.height },
|
||||
});
|
||||
}, [layerRegions, zoomLevel]);
|
||||
}, [zones, zoomLevel]);
|
||||
|
||||
// 🆕 캔버스 마우스 이벤트 (영역 이동/리사이즈)
|
||||
const handleRegionCanvasMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
|
|
@ -5806,11 +5850,8 @@ export default function ScreenDesigner({
|
|||
width: regionDrag.originalRegion.width,
|
||||
height: regionDrag.originalRegion.height,
|
||||
};
|
||||
const lid = Number(regionDrag.targetLayerId);
|
||||
setLayerRegions((prev) => ({
|
||||
...prev,
|
||||
[lid]: { ...prev[lid], ...newRegion },
|
||||
}));
|
||||
const zoneId = Number(regionDrag.targetLayerId);
|
||||
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
|
||||
} else if (regionDrag.isResizing && regionDrag.originalRegion) {
|
||||
const dx = x - regionDrag.startX;
|
||||
const dy = y - regionDrag.startY;
|
||||
|
|
@ -5829,29 +5870,23 @@ export default function ScreenDesigner({
|
|||
newRegion.height = Math.max(30, Math.round(orig.height - dy));
|
||||
}
|
||||
|
||||
const lid = Number(regionDrag.targetLayerId);
|
||||
setLayerRegions((prev) => ({
|
||||
...prev,
|
||||
[lid]: { ...prev[lid], ...newRegion },
|
||||
}));
|
||||
const zoneId = Number(regionDrag.targetLayerId);
|
||||
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
|
||||
}
|
||||
}, [regionDrag, zoomLevel]);
|
||||
|
||||
const handleRegionCanvasMouseUp = useCallback(async () => {
|
||||
// 드래그 완료 시 DB에 영역 저장
|
||||
if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId && selectedScreen?.screenId) {
|
||||
const lid = Number(regionDrag.targetLayerId);
|
||||
const region = layerRegions[lid];
|
||||
if (region) {
|
||||
// 드래그 완료 시 DB에 Zone 저장
|
||||
if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId) {
|
||||
const zoneId = Number(regionDrag.targetLayerId);
|
||||
const zone = zones.find(z => z.zone_id === zoneId);
|
||||
if (zone) {
|
||||
try {
|
||||
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, lid);
|
||||
const existingCondition = layerData?.conditionConfig || {};
|
||||
await screenApi.updateLayerCondition(
|
||||
selectedScreen.screenId, lid,
|
||||
{ ...existingCondition, displayRegion: { x: region.x, y: region.y, width: region.width, height: region.height } }
|
||||
);
|
||||
await screenApi.updateZone(zoneId, {
|
||||
x: zone.x, y: zone.y, width: zone.width, height: zone.height,
|
||||
});
|
||||
} catch {
|
||||
console.error("영역 저장 실패");
|
||||
console.error("Zone 저장 실패");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5865,34 +5900,15 @@ export default function ScreenDesigner({
|
|||
resizeHandle: null,
|
||||
originalRegion: null,
|
||||
});
|
||||
}, [regionDrag, layerRegions, selectedScreen]);
|
||||
}, [regionDrag, zones]);
|
||||
|
||||
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
|
||||
// 주의: layout.layers에 직접 설정된 displayRegion 등 메타데이터를 보존
|
||||
// Zone 기반이므로 displayRegion 보존 불필요
|
||||
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
|
||||
setLayout((prevLayout) => {
|
||||
// 기존 layout.layers의 메타데이터(displayRegion 등)를 보존하며 병합
|
||||
const mergedLayers = newLayers.map((newLayer) => {
|
||||
const existingLayer = prevLayout.layers?.find((l) => l.id === newLayer.id);
|
||||
if (!existingLayer) return newLayer;
|
||||
|
||||
// LayerContext에서 온 데이터(condition 등)를 우선하되,
|
||||
// layout.layers에만 있는 데이터(캔버스에서 직접 수정한 displayRegion)도 보존
|
||||
return {
|
||||
...existingLayer, // 기존 메타데이터 보존 (displayRegion 등)
|
||||
...newLayer, // LayerContext 데이터 우선 (condition, name, isVisible 등)
|
||||
// displayRegion: 양쪽 모두 있을 수 있으므로 최신 값 우선
|
||||
displayRegion: newLayer.displayRegion !== undefined
|
||||
? newLayer.displayRegion
|
||||
: existingLayer.displayRegion,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...prevLayout,
|
||||
layers: mergedLayers,
|
||||
};
|
||||
});
|
||||
setLayout((prevLayout) => ({
|
||||
...prevLayout,
|
||||
layers: newLayers,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 🆕 활성 레이어 변경 핸들러
|
||||
|
|
@ -6046,6 +6062,8 @@ export default function ScreenDesigner({
|
|||
}
|
||||
}}
|
||||
components={layout.components}
|
||||
zones={zones}
|
||||
onZonesChange={setZones}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
@ -6623,28 +6641,50 @@ export default function ScreenDesigner({
|
|||
{activeLayerId > 1 && (
|
||||
<div className="sticky top-0 z-30 flex items-center justify-center gap-2 border-b bg-amber-50 px-4 py-1.5 backdrop-blur-sm dark:bg-amber-950/30">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-xs font-medium">레이어 {activeLayerId} 편집 중</span>
|
||||
<span className="text-xs font-medium">
|
||||
레이어 {activeLayerId} 편집 중
|
||||
{activeLayerZone && (
|
||||
<span className="ml-2 text-amber-600">
|
||||
(캔버스: {activeLayerZone.width} x {activeLayerZone.height}px - {activeLayerZone.zone_name})
|
||||
</span>
|
||||
)}
|
||||
{!activeLayerZone && (
|
||||
<span className="ml-2 text-red-500">
|
||||
(조건부 영역 미설정 - 기본 레이어에서 Zone을 먼저 생성하세요)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
|
||||
{(() => {
|
||||
// 🆕 조건부 레이어 편집 시 캔버스 크기를 Zone에 맞춤
|
||||
const activeRegion = activeLayerId > 1 ? activeLayerZone : null;
|
||||
const canvasW = activeRegion ? activeRegion.width : screenResolution.width;
|
||||
const canvasH = activeRegion ? activeRegion.height : screenResolution.height;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-center"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: screenResolution.height * zoomLevel,
|
||||
minHeight: canvasH * zoomLevel,
|
||||
contain: "layout style", // 레이아웃 재계산 범위 제한
|
||||
}}
|
||||
>
|
||||
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
|
||||
{/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */}
|
||||
<div
|
||||
className="bg-background border-border border shadow-lg"
|
||||
className={cn(
|
||||
"bg-background border shadow-lg",
|
||||
activeRegion ? "border-amber-400 border-2" : "border-border"
|
||||
)}
|
||||
style={{
|
||||
width: `${screenResolution.width}px`,
|
||||
height: `${screenResolution.height}px`,
|
||||
minWidth: `${screenResolution.width}px`,
|
||||
maxWidth: `${screenResolution.width}px`,
|
||||
minHeight: `${screenResolution.height}px`,
|
||||
width: `${canvasW}px`,
|
||||
height: `${canvasH}px`,
|
||||
minWidth: `${canvasW}px`,
|
||||
maxWidth: `${canvasW}px`,
|
||||
minHeight: `${canvasH}px`,
|
||||
flexShrink: 0,
|
||||
transform: `scale3d(${zoomLevel}, ${zoomLevel}, 1)`,
|
||||
transformOrigin: "top center", // 중앙 기준으로 스케일
|
||||
|
|
@ -6751,9 +6791,11 @@ export default function ScreenDesigner({
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* 조건부 레이어 영역 (기본 레이어에서만 표시, DB 기반) */}
|
||||
{activeLayerId === 1 && Object.entries(layerRegions).map(([layerIdStr, region]) => {
|
||||
const layerId = Number(layerIdStr);
|
||||
{/* 조건부 영역(Zone) (기본 레이어에서만 표시, DB 기반) */}
|
||||
{/* 내부는 pointerEvents: none으로 아래 컴포넌트 클릭/드래그 통과 */}
|
||||
{activeLayerId === 1 && zones.map((zone) => {
|
||||
const layerId = zone.zone_id; // 렌더링용 ID
|
||||
const region = zone;
|
||||
const resizeHandles = ["nw", "ne", "sw", "se", "n", "s", "e", "w"];
|
||||
const handleCursors: Record<string, string> = {
|
||||
nw: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", se: "nwse-resize",
|
||||
|
|
@ -6767,6 +6809,8 @@ export default function ScreenDesigner({
|
|||
e: { top: "50%", right: -4, transform: "translateY(-50%)" },
|
||||
w: { top: "50%", left: -4, transform: "translateY(-50%)" },
|
||||
};
|
||||
// 테두리 두께 (이동 핸들 영역)
|
||||
const borderWidth = 6;
|
||||
return (
|
||||
<div
|
||||
key={`region-${layerId}`}
|
||||
|
|
@ -6779,41 +6823,64 @@ export default function ScreenDesigner({
|
|||
border: "2px dashed hsl(var(--primary))",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "hsl(var(--primary) / 0.05)",
|
||||
zIndex: 9999,
|
||||
cursor: "move",
|
||||
pointerEvents: "auto",
|
||||
zIndex: 50,
|
||||
pointerEvents: "none", // 내부 클릭은 아래 컴포넌트로 통과
|
||||
}}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 top-1 select-none text-[10px] font-medium text-primary">
|
||||
레이어 {layerId} - {region.layerName}
|
||||
{/* 테두리 이동 핸들: 상/하/좌/우 얇은 영역만 pointerEvents 활성 */}
|
||||
{/* 상단 */}
|
||||
<div
|
||||
className="absolute left-0 right-0 top-0"
|
||||
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
/>
|
||||
{/* 하단 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0"
|
||||
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
/>
|
||||
{/* 좌측 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0"
|
||||
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
/>
|
||||
{/* 우측 */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 top-0"
|
||||
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
/>
|
||||
{/* 라벨 */}
|
||||
<span
|
||||
className="absolute left-2 top-1 select-none text-[10px] font-medium text-primary"
|
||||
style={{ pointerEvents: "auto", cursor: "move" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
>
|
||||
Zone {zone.zone_id} - {zone.zone_name}
|
||||
</span>
|
||||
{/* 리사이즈 핸들 */}
|
||||
{resizeHandles.map((handle) => (
|
||||
<div
|
||||
key={handle}
|
||||
className="absolute z-10 h-2 w-2 rounded-sm border border-primary bg-background"
|
||||
style={{ ...handlePositions[handle], cursor: handleCursors[handle] }}
|
||||
style={{ ...handlePositions[handle], cursor: handleCursors[handle], pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "resize", handle)}
|
||||
/>
|
||||
))}
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
className="absolute -right-1 -top-3 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[8px] text-destructive-foreground hover:bg-destructive/80"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!selectedScreen?.screenId) return;
|
||||
try {
|
||||
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerId);
|
||||
const cond = layerData?.conditionConfig || {};
|
||||
delete cond.displayRegion;
|
||||
await screenApi.updateLayerCondition(selectedScreen.screenId, layerId, Object.keys(cond).length > 0 ? cond : null);
|
||||
setLayerRegions((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[layerId];
|
||||
return next;
|
||||
});
|
||||
} catch { toast.error("영역 삭제 실패"); }
|
||||
await screenApi.deleteZone(zone.zone_id);
|
||||
setZones((prev) => prev.filter(z => z.zone_id !== zone.zone_id));
|
||||
toast.success("조건부 영역이 삭제되었습니다.");
|
||||
} catch { toast.error("Zone 삭제 실패"); }
|
||||
}}
|
||||
title="영역 삭제"
|
||||
>
|
||||
|
|
@ -7363,8 +7430,9 @@ export default function ScreenDesigner({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>{" "}
|
||||
{/* 🔥 줌 래퍼 닫기 */}
|
||||
</div>
|
||||
); /* 🔥 줌 래퍼 닫기 */
|
||||
})()}
|
||||
</div>
|
||||
</div>{" "}
|
||||
{/* 메인 컨테이너 닫기 */}
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ interface ScreenSettingModalProps {
|
|||
fieldMappings?: FieldMappingInfo[];
|
||||
componentCount?: number;
|
||||
onSaveSuccess?: () => void;
|
||||
isPop?: boolean; // POP 화면 여부
|
||||
}
|
||||
|
||||
// 검색 가능한 Select 컴포넌트
|
||||
|
|
@ -240,7 +239,6 @@ export function ScreenSettingModal({
|
|||
fieldMappings = [],
|
||||
componentCount = 0,
|
||||
onSaveSuccess,
|
||||
isPop = false,
|
||||
}: ScreenSettingModalProps) {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -521,7 +519,6 @@ export function ScreenSettingModal({
|
|||
iframeKey={iframeKey}
|
||||
canvasWidth={canvasSize.width}
|
||||
canvasHeight={canvasSize.height}
|
||||
isPop={isPop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4634,10 +4631,9 @@ interface PreviewTabProps {
|
|||
iframeKey?: number; // iframe 새로고침용 키
|
||||
canvasWidth?: number; // 화면 캔버스 너비
|
||||
canvasHeight?: number; // 화면 캔버스 높이
|
||||
isPop?: boolean; // POP 화면 여부
|
||||
}
|
||||
|
||||
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight, isPop = false }: PreviewTabProps) {
|
||||
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight }: PreviewTabProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -4691,18 +4687,12 @@ function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWi
|
|||
if (companyCode) {
|
||||
params.set("company_code", companyCode);
|
||||
}
|
||||
// POP 화면일 경우 디바이스 타입 추가
|
||||
if (isPop) {
|
||||
params.set("device", "tablet");
|
||||
}
|
||||
// POP 화면과 데스크톱 화면 경로 분기
|
||||
const screenPath = isPop ? `/pop/screens/${screenId}` : `/screens/${screenId}`;
|
||||
if (typeof window !== "undefined") {
|
||||
const baseUrl = window.location.origin;
|
||||
return `${baseUrl}${screenPath}?${params.toString()}`;
|
||||
return `${baseUrl}/screens/${screenId}?${params.toString()}`;
|
||||
}
|
||||
return `${screenPath}?${params.toString()}`;
|
||||
}, [screenId, companyCode, isPop]);
|
||||
return `/screens/${screenId}?${params.toString()}`;
|
||||
}, [screenId, companyCode]);
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
setLoading(false);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
onStyleChange(newStyle);
|
||||
};
|
||||
|
||||
// 숫자만 입력했을 때 자동으로 px 붙여주는 핸들러
|
||||
const autoPxProperties: (keyof ComponentStyle)[] = ["fontSize", "borderWidth", "borderRadius"];
|
||||
const handlePxBlur = (property: keyof ComponentStyle) => {
|
||||
const val = localStyle[property];
|
||||
if (val && /^\d+(\.\d+)?$/.test(String(val))) {
|
||||
handleStyleChange(property, `${val}px`);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setOpenSections((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -66,6 +75,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
placeholder="1px"
|
||||
value={localStyle.borderWidth || ""}
|
||||
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
|
||||
onBlur={() => handlePxBlur("borderWidth")}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -121,6 +131,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
placeholder="5px"
|
||||
value={localStyle.borderRadius || ""}
|
||||
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
||||
onBlur={() => handlePxBlur("borderRadius")}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -209,6 +220,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
placeholder="14px"
|
||||
value={localStyle.fontSize || ""}
|
||||
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
|
||||
onBlur={() => handlePxBlur("fontSize")}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3777,7 +3777,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
|
||||
/**
|
||||
* 마스터-디테일 엑셀 업로드 설정 컴포넌트
|
||||
* 분할 패널 + column_labels에서 관계를 자동 감지하고, 사용자는 채번 규칙만 선택
|
||||
* 분할 패널 + column_labels에서 관계를 자동 감지 (채번은 테이블 타입 관리에서 자동 감지)
|
||||
*/
|
||||
const MasterDetailExcelUploadConfig: React.FC<{
|
||||
config: any;
|
||||
|
|
@ -4005,7 +4005,7 @@ const MasterDetailExcelUploadConfig: React.FC<{
|
|||
{/* 마스터 키 자동 생성 안내 */}
|
||||
{relationInfo && (
|
||||
<p className="text-muted-foreground border-t pt-2 text-xs">
|
||||
마스터 테이블의 <strong>{relationInfo.masterKeyColumn}</strong> 값은 위에서 설정한 채번 규칙으로 자동
|
||||
마스터 테이블의 <strong>{relationInfo.masterKeyColumn}</strong> 값은 테이블 타입 관리에서 설정된 채번 규칙으로 자동
|
||||
생성됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -4114,165 +4114,15 @@ const MasterDetailExcelUploadConfig: React.FC<{
|
|||
};
|
||||
|
||||
/**
|
||||
* 엑셀 업로드 채번 규칙 설정 (단일 테이블/마스터-디테일 모두 사용 가능)
|
||||
* 엑셀 업로드 채번 규칙 안내 (테이블 타입 관리에서 자동 감지)
|
||||
*/
|
||||
const ExcelNumberingRuleConfig: React.FC<{
|
||||
config: { numberingRuleId?: string; numberingTargetColumn?: string };
|
||||
updateConfig: (updates: { numberingRuleId?: string; numberingTargetColumn?: string }) => void;
|
||||
tableName?: string; // 단일 테이블인 경우 테이블명
|
||||
hasSplitPanel?: boolean; // 분할 패널 여부 (마스터-디테일)
|
||||
}> = ({ config, updateConfig, tableName, hasSplitPanel }) => {
|
||||
const [numberingRules, setNumberingRules] = useState<any[]>([]);
|
||||
const [ruleSelectOpen, setRuleSelectOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [tableColumns, setTableColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// 채번 규칙 목록 로드
|
||||
useEffect(() => {
|
||||
const loadNumberingRules = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get("/numbering-rules");
|
||||
if (response.data?.success && response.data?.data) {
|
||||
setNumberingRules(response.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("채번 규칙 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadNumberingRules();
|
||||
}, []);
|
||||
|
||||
// 단일 테이블인 경우 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (!tableName || hasSplitPanel) {
|
||||
setTableColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadColumns = async () => {
|
||||
setColumnsLoading(true);
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
if (response.data?.success && response.data?.data?.columns) {
|
||||
const cols = response.data.data.columns.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.displayName || col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
}));
|
||||
setTableColumns(cols);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [tableName, hasSplitPanel]);
|
||||
|
||||
const selectedRule = numberingRules.find((r) => String(r.rule_id || r.ruleId) === String(config.numberingRuleId));
|
||||
|
||||
const ExcelNumberingRuleInfo: React.FC = () => {
|
||||
return (
|
||||
<div className="border-t pt-3">
|
||||
<Label className="text-xs">채번 규칙</Label>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
업로드 시 자동으로 생성할 코드/번호의 채번 규칙을 선택하세요.
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
테이블 타입 관리에서 "채번" 타입으로 설정된 컬럼의 채번 규칙이 업로드 시 자동으로 적용됩니다.
|
||||
</p>
|
||||
|
||||
<Popover open={ruleSelectOpen} onOpenChange={setRuleSelectOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={ruleSelectOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "로딩 중..." : selectedRule?.rule_name || selectedRule?.ruleName || "채번 없음"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="채번 규칙 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">검색 결과 없음</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
updateConfig({ numberingRuleId: undefined, numberingTargetColumn: undefined });
|
||||
setRuleSelectOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", !config.numberingRuleId ? "opacity-100" : "opacity-0")} />
|
||||
채번 없음
|
||||
</CommandItem>
|
||||
{numberingRules.map((rule, idx) => {
|
||||
const ruleId = String(rule.rule_id || rule.ruleId || `rule-${idx}`);
|
||||
const ruleName = rule.rule_name || rule.ruleName || "(이름 없음)";
|
||||
return (
|
||||
<CommandItem
|
||||
key={ruleId}
|
||||
value={ruleName}
|
||||
onSelect={() => {
|
||||
updateConfig({ numberingRuleId: ruleId });
|
||||
setRuleSelectOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
String(config.numberingRuleId) === ruleId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{ruleName}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 단일 테이블이고 채번 규칙이 선택된 경우, 적용할 컬럼 선택 */}
|
||||
{config.numberingRuleId && !hasSplitPanel && tableName && (
|
||||
<div className="mt-2">
|
||||
<Label className="text-xs">채번 적용 컬럼</Label>
|
||||
<Select
|
||||
value={config.numberingTargetColumn || ""}
|
||||
onValueChange={(value) => updateConfig({ numberingTargetColumn: value || undefined })}
|
||||
disabled={columnsLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={columnsLoading ? "로딩 중..." : "컬럼 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel} ({col.columnName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-xs">채번 값이 입력될 컬럼을 선택하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분할 패널인 경우 안내 메시지 */}
|
||||
{config.numberingRuleId && hasSplitPanel && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">마스터-디테일 구조에서는 마스터 키 컬럼에 자동 적용됩니다.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -4440,14 +4290,10 @@ const ExcelUploadConfigSection: React.FC<{
|
|||
allComponents: ComponentData[];
|
||||
currentTableName?: string; // 현재 화면의 테이블명 (ButtonConfigPanel에서 전달)
|
||||
}> = ({ config, onUpdateProperty, allComponents, currentTableName: propTableName }) => {
|
||||
// 엑셀 업로드 설정 상태 관리
|
||||
// 엑셀 업로드 설정 상태 관리 (채번은 테이블 타입 관리에서 자동 감지)
|
||||
const [excelUploadConfig, setExcelUploadConfig] = useState<{
|
||||
numberingRuleId?: string;
|
||||
numberingTargetColumn?: string;
|
||||
afterUploadFlows?: Array<{ flowId: string; order: number }>;
|
||||
}>({
|
||||
numberingRuleId: config.action?.excelNumberingRuleId,
|
||||
numberingTargetColumn: config.action?.excelNumberingTargetColumn,
|
||||
afterUploadFlows: config.action?.excelAfterUploadFlows || [],
|
||||
});
|
||||
|
||||
|
|
@ -4529,17 +4375,11 @@ const ExcelUploadConfigSection: React.FC<{
|
|||
);
|
||||
}, [hasSplitPanel, singleTableName, propTableName]);
|
||||
|
||||
// 설정 업데이트 함수
|
||||
// 설정 업데이트 함수 (채번은 테이블 타입 관리에서 자동 감지되므로 제어 실행만 관리)
|
||||
const updateExcelUploadConfig = (updates: Partial<typeof excelUploadConfig>) => {
|
||||
const newConfig = { ...excelUploadConfig, ...updates };
|
||||
setExcelUploadConfig(newConfig);
|
||||
|
||||
if (updates.numberingRuleId !== undefined) {
|
||||
onUpdateProperty("componentConfig.action.excelNumberingRuleId", updates.numberingRuleId);
|
||||
}
|
||||
if (updates.numberingTargetColumn !== undefined) {
|
||||
onUpdateProperty("componentConfig.action.excelNumberingTargetColumn", updates.numberingTargetColumn);
|
||||
}
|
||||
if (updates.afterUploadFlows !== undefined) {
|
||||
onUpdateProperty("componentConfig.action.excelAfterUploadFlows", updates.afterUploadFlows);
|
||||
}
|
||||
|
|
@ -4548,15 +4388,9 @@ const ExcelUploadConfigSection: React.FC<{
|
|||
// config 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setExcelUploadConfig({
|
||||
numberingRuleId: config.action?.excelNumberingRuleId,
|
||||
numberingTargetColumn: config.action?.excelNumberingTargetColumn,
|
||||
afterUploadFlows: config.action?.excelAfterUploadFlows || [],
|
||||
});
|
||||
}, [
|
||||
config.action?.excelNumberingRuleId,
|
||||
config.action?.excelNumberingTargetColumn,
|
||||
config.action?.excelAfterUploadFlows,
|
||||
]);
|
||||
}, [config.action?.excelAfterUploadFlows]);
|
||||
|
||||
return (
|
||||
<div className="bg-muted/50 mt-4 space-y-4 rounded-lg border p-4">
|
||||
|
|
@ -4595,13 +4429,8 @@ const ExcelUploadConfigSection: React.FC<{
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 채번 규칙 설정 (항상 표시) */}
|
||||
<ExcelNumberingRuleConfig
|
||||
config={excelUploadConfig}
|
||||
updateConfig={updateExcelUploadConfig}
|
||||
tableName={singleTableName}
|
||||
hasSplitPanel={hasSplitPanel}
|
||||
/>
|
||||
{/* 채번 규칙 안내 (테이블 타입 관리에서 자동 감지) */}
|
||||
<ExcelNumberingRuleInfo />
|
||||
|
||||
{/* 업로드 후 제어 실행 (항상 표시) */}
|
||||
<ExcelAfterUploadControlConfig config={excelUploadConfig} updateConfig={updateExcelUploadConfig} />
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ export function ComponentsPanel({
|
|||
() =>
|
||||
[
|
||||
// v2-input: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
|
||||
// v2-select: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
|
||||
// v2-date: 테이블 컬럼 드래그 시 자동 생성되므로 숨김 처리
|
||||
// v2-layout: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
|
||||
// v2-group: 중첩 드래그앤드롭 기능 미구현으로 숨김 처리
|
||||
|
|
@ -57,6 +56,23 @@ export function ComponentsPanel({
|
|||
// v2-media 제거 - 테이블 컬럼의 image/file 입력 타입으로 사용
|
||||
// v2-biz 제거 - 개별 컴포넌트(flow-widget, rack-structure, numbering-rule)로 직접 표시
|
||||
// v2-hierarchy 제거 - 현재 미사용
|
||||
{
|
||||
id: "v2-select",
|
||||
name: "V2 선택",
|
||||
description: "드롭다운, 콤보박스, 라디오, 체크박스 등 다양한 선택 모드 지원",
|
||||
category: "input" as ComponentCategory,
|
||||
tags: ["select", "dropdown", "combobox", "v2"],
|
||||
defaultSize: { width: 300, height: 40 },
|
||||
defaultConfig: {
|
||||
mode: "dropdown",
|
||||
source: "static",
|
||||
multiple: false,
|
||||
searchable: false,
|
||||
placeholder: "선택하세요",
|
||||
options: [],
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "v2-repeater",
|
||||
name: "리피터 그리드",
|
||||
|
|
@ -65,7 +81,7 @@ export function ComponentsPanel({
|
|||
tags: ["repeater", "table", "modal", "button", "v2", "v2"],
|
||||
defaultSize: { width: 600, height: 300 },
|
||||
},
|
||||
] as ComponentDefinition[],
|
||||
] as unknown as ComponentDefinition[],
|
||||
[],
|
||||
);
|
||||
|
||||
|
|
@ -109,8 +125,8 @@ export function ComponentsPanel({
|
|||
"v2-media", // → 테이블 컬럼의 image/file 입력 타입으로 사용
|
||||
// 플로우 위젯 숨김 처리
|
||||
"flow-widget",
|
||||
// 선택 항목 상세입력 - 기존 컴포넌트 조합으로 대체 가능
|
||||
"selected-items-detail-input",
|
||||
// 선택 항목 상세입력 - 거래처 품목 추가 등에서 사용
|
||||
// "selected-items-detail-input",
|
||||
// 연관 데이터 버튼 - v2-repeater로 대체 가능
|
||||
"related-data-buttons",
|
||||
// ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) =====
|
||||
|
|
@ -126,6 +142,7 @@ export function ComponentsPanel({
|
|||
"section-card", // → v2-section-card
|
||||
"location-swap-selector", // → v2-location-swap-selector
|
||||
"rack-structure", // → v2-rack-structure
|
||||
"v2-select", // → v2-select (아래 v2Components에서 별도 처리)
|
||||
"v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리)
|
||||
"repeat-container", // → v2-repeat-container
|
||||
"repeat-screen-modal", // → v2-repeat-screen-modal
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { X, Loader2 } from "lucide-react";
|
||||
|
|
@ -26,6 +26,17 @@ interface TabsWidgetProps {
|
|||
isDesignMode?: boolean;
|
||||
onComponentSelect?: (tabId: string, componentId: string) => void;
|
||||
selectedComponentId?: string;
|
||||
// 테이블 선택된 행 데이터 (버튼 활성화 및 수정/삭제 동작에 필요)
|
||||
selectedRowsData?: any[];
|
||||
onSelectedRowsChange?: (
|
||||
selectedRows: any[],
|
||||
selectedRowsData: any[],
|
||||
sortBy?: string,
|
||||
sortOrder?: "asc" | "desc",
|
||||
columnOrder?: string[],
|
||||
) => void;
|
||||
// 추가 props (부모에서 전달받은 나머지 props)
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function TabsWidget({
|
||||
|
|
@ -38,6 +49,9 @@ export function TabsWidget({
|
|||
isDesignMode = false,
|
||||
onComponentSelect,
|
||||
selectedComponentId,
|
||||
selectedRowsData: _externalSelectedRowsData,
|
||||
onSelectedRowsChange: externalOnSelectedRowsChange,
|
||||
...restProps
|
||||
}: TabsWidgetProps) {
|
||||
const { setActiveTab, removeTabsComponent } = useActiveTab();
|
||||
const {
|
||||
|
|
@ -51,6 +65,30 @@ export function TabsWidget({
|
|||
|
||||
const storageKey = `tabs-${component.id}-selected`;
|
||||
|
||||
// 탭 내부 자체 selectedRowsData 상태 관리 (항상 로컬 상태 사용)
|
||||
// 부모에서 빈 배열 []이 전달되어도 로컬 상태를 우선하여 탭 내부 버튼이 즉시 인식
|
||||
const [localSelectedRowsData, setLocalSelectedRowsData] = useState<any[]>([]);
|
||||
|
||||
// 선택 변경 핸들러: 로컬 상태 업데이트 + 부모 콜백 호출
|
||||
const handleSelectedRowsChange = useCallback(
|
||||
(
|
||||
selectedRows: any[],
|
||||
selectedRowsDataNew: any[],
|
||||
sortBy?: string,
|
||||
sortOrder?: "asc" | "desc",
|
||||
columnOrder?: string[],
|
||||
) => {
|
||||
// 로컬 상태 업데이트 (탭 내부 버튼이 즉시 인식)
|
||||
setLocalSelectedRowsData(selectedRowsDataNew);
|
||||
|
||||
// 부모 콜백 호출 (부모 상태도 업데이트)
|
||||
if (externalOnSelectedRowsChange) {
|
||||
externalOnSelectedRowsChange(selectedRows, selectedRowsDataNew, sortBy, sortOrder, columnOrder);
|
||||
}
|
||||
},
|
||||
[externalOnSelectedRowsChange],
|
||||
);
|
||||
|
||||
// 초기 선택 탭 결정
|
||||
const getInitialTab = () => {
|
||||
if (persistSelection && typeof window !== "undefined") {
|
||||
|
|
@ -97,6 +135,8 @@ export function TabsWidget({
|
|||
const [screenLayouts, setScreenLayouts] = useState<Record<string, ComponentData[]>>({});
|
||||
const [screenLoadingStates, setScreenLoadingStates] = useState<Record<string, boolean>>({});
|
||||
const [screenErrors, setScreenErrors] = useState<Record<string, string>>({});
|
||||
// 탭별 화면 정보 (screenId, tableName) 저장
|
||||
const [screenInfoMap, setScreenInfoMap] = useState<Record<string, { id: number; tableName?: string }>>({});
|
||||
|
||||
// 컴포넌트 탭 목록 변경 시 동기화
|
||||
useEffect(() => {
|
||||
|
|
@ -117,10 +157,21 @@ export function TabsWidget({
|
|||
) {
|
||||
setScreenLoadingStates((prev) => ({ ...prev, [tab.id]: true }));
|
||||
try {
|
||||
const layoutData = await screenApi.getLayout(extTab.screenId);
|
||||
// 레이아웃과 화면 정보를 병렬로 로드
|
||||
const [layoutData, screenDef] = await Promise.all([
|
||||
screenApi.getLayout(extTab.screenId),
|
||||
screenApi.getScreen(extTab.screenId),
|
||||
]);
|
||||
if (layoutData && layoutData.components) {
|
||||
setScreenLayouts((prev) => ({ ...prev, [tab.id]: layoutData.components }));
|
||||
}
|
||||
// 탭의 화면 정보 저장 (tableName 포함)
|
||||
if (screenDef) {
|
||||
setScreenInfoMap((prev) => ({
|
||||
...prev,
|
||||
[tab.id]: { id: extTab.screenId!, tableName: screenDef.tableName },
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`탭 "${tab.label}" 화면 로드 실패:`, error);
|
||||
setScreenErrors((prev) => ({ ...prev, [tab.id]: "화면을 불러올 수 없습니다." }));
|
||||
|
|
@ -134,6 +185,31 @@ export function TabsWidget({
|
|||
loadScreenLayouts();
|
||||
}, [visibleTabs, screenLayouts, screenLoadingStates]);
|
||||
|
||||
// screenInfoMap이 없는 탭의 화면 정보 보충 로드
|
||||
// screenId가 있지만 screenInfoMap에 아직 없는 탭의 화면 정보를 로드
|
||||
useEffect(() => {
|
||||
const loadMissingScreenInfo = async () => {
|
||||
for (const tab of visibleTabs) {
|
||||
const extTab = tab as ExtendedTabItem;
|
||||
// screenId가 있고 screenInfoMap에 아직 없는 경우 로드
|
||||
if (extTab.screenId && !screenInfoMap[tab.id]) {
|
||||
try {
|
||||
const screenDef = await screenApi.getScreen(extTab.screenId);
|
||||
if (screenDef) {
|
||||
setScreenInfoMap((prev) => ({
|
||||
...prev,
|
||||
[tab.id]: { id: extTab.screenId!, tableName: screenDef.tableName },
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`탭 "${tab.label}" 화면 정보 로드 실패:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
loadMissingScreenInfo();
|
||||
}, [visibleTabs, screenInfoMap]);
|
||||
|
||||
// 선택된 탭 변경 시 localStorage에 저장 + ActiveTab Context 업데이트
|
||||
useEffect(() => {
|
||||
if (persistSelection && typeof window !== "undefined") {
|
||||
|
|
@ -218,7 +294,7 @@ export function TabsWidget({
|
|||
// 화면 레이아웃이 로드된 경우
|
||||
const loadedComponents = screenLayouts[tab.id];
|
||||
if (loadedComponents && loadedComponents.length > 0) {
|
||||
return renderScreenComponents(loadedComponents);
|
||||
return renderScreenComponents(tab, loadedComponents);
|
||||
}
|
||||
|
||||
// 아직 로드되지 않은 경우
|
||||
|
|
@ -245,7 +321,7 @@ export function TabsWidget({
|
|||
};
|
||||
|
||||
// screenId로 로드한 화면 컴포넌트 렌더링
|
||||
const renderScreenComponents = (components: ComponentData[]) => {
|
||||
const renderScreenComponents = (tab: ExtendedTabItem, components: ComponentData[]) => {
|
||||
// InteractiveScreenViewerDynamic 동적 로드
|
||||
const InteractiveScreenViewerDynamic =
|
||||
require("@/components/screen/InteractiveScreenViewerDynamic").InteractiveScreenViewerDynamic;
|
||||
|
|
@ -278,7 +354,10 @@ export function TabsWidget({
|
|||
allComponents={components}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfoMap[tab.id]}
|
||||
menuObjid={menuObjid}
|
||||
parentTabId={tab.id}
|
||||
parentTabsComponentId={component.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -287,7 +366,7 @@ export function TabsWidget({
|
|||
};
|
||||
|
||||
// 인라인 컴포넌트 렌더링 (v2 방식)
|
||||
const renderInlineComponents = (tab: TabItem, components: TabInlineComponent[]) => {
|
||||
const renderInlineComponents = (tab: ExtendedTabItem, components: TabInlineComponent[]) => {
|
||||
// 컴포넌트들의 최대 위치를 계산하여 스크롤 가능한 영역 확보
|
||||
const maxBottom = Math.max(
|
||||
...components.map((c) => (c.position?.y || 0) + (c.size?.height || 100)),
|
||||
|
|
@ -331,6 +410,7 @@ export function TabsWidget({
|
|||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
{...restProps}
|
||||
component={{
|
||||
id: comp.id,
|
||||
componentType: comp.componentType,
|
||||
|
|
@ -345,6 +425,17 @@ export function TabsWidget({
|
|||
menuObjid={menuObjid}
|
||||
isDesignMode={isDesignMode}
|
||||
isInteractive={!isDesignMode}
|
||||
selectedRowsData={localSelectedRowsData}
|
||||
onSelectedRowsChange={handleSelectedRowsChange}
|
||||
parentTabId={tab.id}
|
||||
parentTabsComponentId={component.id}
|
||||
// 탭에 screenId가 있으면 해당 화면의 tableName/screenId로 오버라이드
|
||||
{...(screenInfoMap[tab.id]
|
||||
? {
|
||||
tableName: screenInfoMap[tab.id].tableName,
|
||||
screenId: screenInfoMap[tab.id].id,
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] bg-black/80",
|
||||
"fixed inset-0 z-[999] bg-black/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
|||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1000] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] bg-black/60",
|
||||
"fixed inset-0 z-[999] bg-black/60",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"bg-popover text-popover-foreground z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -63,7 +63,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
"bg-popover text-popover-foreground z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-sm transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[2000] w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
"bg-popover text-popover-foreground z-[2000] w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -16,16 +16,14 @@ const SheetClose = SheetPrimitive.Close;
|
|||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
top: "inset-x-0 top-0 border-b",
|
||||
bottom: "inset-x-0 bottom-0 border-t",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
right: "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -60,7 +58,7 @@ const SheetOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"bg-background/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm",
|
||||
"bg-background/80 fixed inset-0 z-50 backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -23,15 +23,26 @@ import { AutoGenerationConfig } from "@/types/screen";
|
|||
import { previewNumberingCode } from "@/lib/api/numberingRule";
|
||||
|
||||
// 형식별 입력 마스크 및 검증 패턴
|
||||
const FORMAT_PATTERNS: Record<V2InputFormat, { pattern: RegExp; placeholder: string }> = {
|
||||
none: { pattern: /.*/, placeholder: "" },
|
||||
email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com" },
|
||||
tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678" },
|
||||
url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com" },
|
||||
currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000" },
|
||||
biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890" },
|
||||
const FORMAT_PATTERNS: Record<V2InputFormat, { pattern: RegExp; placeholder: string; errorMessage: string }> = {
|
||||
none: { pattern: /.*/, placeholder: "", errorMessage: "" },
|
||||
email: { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, placeholder: "example@email.com", errorMessage: "올바른 이메일 형식이 아닙니다" },
|
||||
tel: { pattern: /^\d{2,3}-\d{3,4}-\d{4}$/, placeholder: "010-1234-5678", errorMessage: "올바른 전화번호 형식이 아닙니다" },
|
||||
url: { pattern: /^https?:\/\/.+/, placeholder: "https://example.com", errorMessage: "올바른 URL 형식이 아닙니다 (https://로 시작)" },
|
||||
currency: { pattern: /^[\d,]+$/, placeholder: "1,000,000", errorMessage: "숫자만 입력 가능합니다" },
|
||||
biz_no: { pattern: /^\d{3}-\d{2}-\d{5}$/, placeholder: "123-45-67890", errorMessage: "올바른 사업자번호 형식이 아닙니다" },
|
||||
};
|
||||
|
||||
// 형식 검증 함수 (외부에서도 사용 가능)
|
||||
export function validateInputFormat(value: string, format: V2InputFormat): { isValid: boolean; errorMessage: string } {
|
||||
if (!value || value.trim() === "" || format === "none") {
|
||||
return { isValid: true, errorMessage: "" };
|
||||
}
|
||||
const formatConfig = FORMAT_PATTERNS[format];
|
||||
if (!formatConfig) return { isValid: true, errorMessage: "" };
|
||||
const isValid = formatConfig.pattern.test(value);
|
||||
return { isValid, errorMessage: isValid ? "" : formatConfig.errorMessage };
|
||||
}
|
||||
|
||||
// 통화 형식 변환
|
||||
function formatCurrency(value: string | number): string {
|
||||
const num = typeof value === "string" ? parseFloat(value.replace(/,/g, "")) : value;
|
||||
|
|
@ -70,8 +81,14 @@ const TextInput = forwardRef<
|
|||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
columnName?: string;
|
||||
inputStyle?: React.CSSProperties;
|
||||
}
|
||||
>(({ value, onChange, format = "none", placeholder, readonly, disabled, className }, ref) => {
|
||||
>(({ value, onChange, format = "none", placeholder, readonly, disabled, className, columnName, inputStyle }, ref) => {
|
||||
// 검증 상태
|
||||
const [hasBlurred, setHasBlurred] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string>("");
|
||||
|
||||
// 형식에 따른 값 포맷팅
|
||||
const formatValue = useCallback(
|
||||
(val: string): string => {
|
||||
|
|
@ -104,29 +121,102 @@ const TextInput = forwardRef<
|
|||
newValue = formatTel(newValue);
|
||||
}
|
||||
|
||||
// 입력 중 에러 표시 해제 (입력 중에는 관대하게)
|
||||
if (hasBlurred && validationError) {
|
||||
const { isValid } = validateInputFormat(newValue, format);
|
||||
if (isValid) {
|
||||
setValidationError("");
|
||||
}
|
||||
}
|
||||
|
||||
onChange?.(newValue);
|
||||
},
|
||||
[format, onChange],
|
||||
[format, onChange, hasBlurred, validationError],
|
||||
);
|
||||
|
||||
// blur 시 형식 검증
|
||||
const handleBlur = useCallback(() => {
|
||||
setHasBlurred(true);
|
||||
const currentValue = value !== undefined && value !== null ? String(value) : "";
|
||||
if (currentValue && format !== "none") {
|
||||
const { isValid, errorMessage } = validateInputFormat(currentValue, format);
|
||||
setValidationError(isValid ? "" : errorMessage);
|
||||
} else {
|
||||
setValidationError("");
|
||||
}
|
||||
}, [value, format]);
|
||||
|
||||
// 값 변경 시 검증 상태 업데이트
|
||||
useEffect(() => {
|
||||
if (hasBlurred) {
|
||||
const currentValue = value !== undefined && value !== null ? String(value) : "";
|
||||
if (currentValue && format !== "none") {
|
||||
const { isValid, errorMessage } = validateInputFormat(currentValue, format);
|
||||
setValidationError(isValid ? "" : errorMessage);
|
||||
} else {
|
||||
setValidationError("");
|
||||
}
|
||||
}
|
||||
}, [value, format, hasBlurred]);
|
||||
|
||||
// 글로벌 폼 검증 이벤트 리스너 (저장 시 호출)
|
||||
useEffect(() => {
|
||||
if (format === "none" || !columnName) return;
|
||||
|
||||
const handleValidateForm = (event: CustomEvent) => {
|
||||
const currentValue = value !== undefined && value !== null ? String(value) : "";
|
||||
if (currentValue) {
|
||||
const { isValid, errorMessage } = validateInputFormat(currentValue, format);
|
||||
if (!isValid) {
|
||||
setHasBlurred(true);
|
||||
setValidationError(errorMessage);
|
||||
// 검증 결과를 이벤트에 기록
|
||||
if (event.detail?.errors) {
|
||||
event.detail.errors.push({
|
||||
columnName,
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("validateFormInputs", handleValidateForm as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener("validateFormInputs", handleValidateForm as EventListener);
|
||||
};
|
||||
}, [format, value, columnName]);
|
||||
|
||||
const displayValue = useMemo(() => {
|
||||
if (value === undefined || value === null) return "";
|
||||
return formatValue(String(value));
|
||||
}, [value, formatValue]);
|
||||
|
||||
const inputPlaceholder = placeholder || FORMAT_PATTERNS[format].placeholder;
|
||||
const hasError = hasBlurred && !!validationError;
|
||||
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
placeholder={inputPlaceholder}
|
||||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
className={cn("h-full w-full", className)}
|
||||
/>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<Input
|
||||
ref={ref}
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={inputPlaceholder}
|
||||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-full w-full",
|
||||
hasError && "border-destructive focus-visible:ring-destructive",
|
||||
className,
|
||||
)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
{hasError && (
|
||||
<p className="text-destructive mt-1 text-[11px]">{validationError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TextInput.displayName = "TextInput";
|
||||
|
|
@ -146,8 +236,9 @@ const NumberInput = forwardRef<
|
|||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
inputStyle?: React.CSSProperties;
|
||||
}
|
||||
>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className }, ref) => {
|
||||
>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className, inputStyle }, ref) => {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
|
|
@ -180,6 +271,7 @@ const NumberInput = forwardRef<
|
|||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
className={cn("h-full w-full", className)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -197,8 +289,9 @@ const PasswordInput = forwardRef<
|
|||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
inputStyle?: React.CSSProperties;
|
||||
}
|
||||
>(({ value, onChange, placeholder, readonly, disabled, className }, ref) => {
|
||||
>(({ value, onChange, placeholder, readonly, disabled, className, inputStyle }, ref) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -212,6 +305,7 @@ const PasswordInput = forwardRef<
|
|||
readOnly={readonly}
|
||||
disabled={disabled}
|
||||
className={cn("h-full w-full pr-10", className)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -305,8 +399,9 @@ const TextareaInput = forwardRef<
|
|||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
inputStyle?: React.CSSProperties;
|
||||
}
|
||||
>(({ value = "", onChange, placeholder, rows = 3, readonly, disabled, className }, ref) => {
|
||||
>(({ value = "", onChange, placeholder, rows = 3, readonly, disabled, className, inputStyle }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
|
|
@ -320,6 +415,7 @@ const TextareaInput = forwardRef<
|
|||
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-full w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -678,13 +774,21 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
placeholder={config.placeholder}
|
||||
readonly={readonly || (autoGeneration.enabled && hasGeneratedRef.current)}
|
||||
disabled={disabled}
|
||||
columnName={columnName}
|
||||
inputStyle={inputTextStyle}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
// DB에서 문자열("325")로 반환되는 경우도 숫자로 변환하여 표시
|
||||
const numValue = typeof displayValue === "number"
|
||||
? displayValue
|
||||
: (displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue)))
|
||||
? Number(displayValue)
|
||||
: undefined;
|
||||
return (
|
||||
<NumberInput
|
||||
value={typeof displayValue === "number" ? displayValue : undefined}
|
||||
value={numValue}
|
||||
onChange={(v) => {
|
||||
setAutoGeneratedValue(null);
|
||||
onChange?.(v ?? 0);
|
||||
|
|
@ -695,6 +799,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
placeholder={config.placeholder}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
inputStyle={inputTextStyle}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -709,13 +814,20 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
placeholder={config.placeholder}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
inputStyle={inputTextStyle}
|
||||
/>
|
||||
);
|
||||
|
||||
case "slider":
|
||||
// DB에서 문자열로 반환되는 경우도 숫자로 변환
|
||||
const sliderValue = typeof displayValue === "number"
|
||||
? displayValue
|
||||
: (displayValue !== undefined && displayValue !== null && displayValue !== "" && !isNaN(Number(displayValue)))
|
||||
? Number(displayValue)
|
||||
: (config.min ?? 0);
|
||||
return (
|
||||
<SliderInput
|
||||
value={typeof displayValue === "number" ? displayValue : (config.min ?? 0)}
|
||||
value={sliderValue}
|
||||
onChange={(v) => {
|
||||
setAutoGeneratedValue(null);
|
||||
onChange?.(v);
|
||||
|
|
@ -751,6 +863,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
rows={config.rows}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
inputStyle={inputTextStyle}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -770,6 +883,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"}
|
||||
readonly={true}
|
||||
disabled={disabled || isGeneratingNumbering}
|
||||
inputStyle={inputTextStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -816,6 +930,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
placeholder="입력"
|
||||
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"
|
||||
disabled={disabled || isGeneratingNumbering}
|
||||
style={inputTextStyle}
|
||||
/>
|
||||
{/* 고정 접미어 */}
|
||||
{templateSuffix && (
|
||||
|
|
@ -835,9 +950,12 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
setAutoGeneratedValue(null);
|
||||
onChange?.(v);
|
||||
}}
|
||||
format={config.format}
|
||||
placeholder={config.placeholder}
|
||||
readonly={readonly}
|
||||
disabled={disabled}
|
||||
columnName={columnName}
|
||||
inputStyle={inputTextStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -856,6 +974,23 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2; // 라벨 높이 + 여백
|
||||
|
||||
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
|
||||
// RealtimePreview 래퍼가 외부 div에 스타일을 적용하지만,
|
||||
// 내부 input/textarea가 자체 Tailwind 테두리를 가지므로 이를 제거하여 외부 스타일이 보이도록 함
|
||||
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
|
||||
const hasCustomBackground = !!style?.backgroundColor;
|
||||
const hasCustomRadius = !!style?.borderRadius;
|
||||
|
||||
// 텍스트 스타일 오버라이드 (내부 input/textarea에 직접 전달)
|
||||
const customTextStyle: React.CSSProperties = {};
|
||||
if (style?.color) customTextStyle.color = style.color;
|
||||
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
|
||||
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
|
||||
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
|
||||
const hasCustomText = Object.keys(customTextStyle).length > 0;
|
||||
// 내부 input에 직접 적용할 텍스트 스타일 (fontSize, color, fontWeight, textAlign)
|
||||
const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -884,7 +1019,18 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
|||
{required && <span className="ml-0.5 text-orange-500">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="h-full w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full",
|
||||
// 커스텀 테두리 설정 시, 내부 input/textarea의 기본 테두리 제거 (외부 래퍼 스타일이 보이도록)
|
||||
hasCustomBorder && "[&_input]:border-0! [&_textarea]:border-0! [&_.border]:border-0!",
|
||||
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거 (외부 래퍼가 처리)
|
||||
(hasCustomBorder || hasCustomRadius) && "[&_input]:rounded-none! [&_textarea]:rounded-none! [&_.rounded-md]:rounded-none!",
|
||||
// 커스텀 배경 설정 시, 내부 input을 투명하게 (외부 배경이 보이도록)
|
||||
hasCustomBackground && "[&_input]:bg-transparent! [&_textarea]:bg-transparent!",
|
||||
)}
|
||||
style={hasCustomText ? customTextStyle : undefined}
|
||||
>
|
||||
{renderInput()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -874,9 +874,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
}, [parentId, config.fieldName, data, handleDataChange]);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div className={cn("flex h-full flex-col overflow-hidden", className)}>
|
||||
{/* 헤더 영역 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex shrink-0 items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{data.length > 0 && `${data.length}개 항목`}
|
||||
|
|
@ -896,23 +896,25 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repeater 테이블 */}
|
||||
<RepeaterTable
|
||||
columns={repeaterColumns}
|
||||
data={data}
|
||||
onDataChange={handleDataChange}
|
||||
onRowChange={handleRowChange}
|
||||
onRowDelete={handleRowDelete}
|
||||
activeDataSources={activeDataSources}
|
||||
onDataSourceChange={(field, optionId) => {
|
||||
setActiveDataSources((prev) => ({ ...prev, [field]: optionId }));
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
equalizeWidthsTrigger={autoWidthTrigger}
|
||||
categoryColumns={sourceCategoryColumns}
|
||||
categoryLabelMap={categoryLabelMap}
|
||||
/>
|
||||
{/* Repeater 테이블 - 남은 공간에서 스크롤 */}
|
||||
<div className="min-h-0 flex-1">
|
||||
<RepeaterTable
|
||||
columns={repeaterColumns}
|
||||
data={data}
|
||||
onDataChange={handleDataChange}
|
||||
onRowChange={handleRowChange}
|
||||
onRowDelete={handleRowDelete}
|
||||
activeDataSources={activeDataSources}
|
||||
onDataSourceChange={(field, optionId) => {
|
||||
setActiveDataSources((prev) => ({ ...prev, [field]: optionId }));
|
||||
}}
|
||||
selectedRows={selectedRows}
|
||||
onSelectionChange={setSelectedRows}
|
||||
equalizeWidthsTrigger={autoWidthTrigger}
|
||||
categoryColumns={sourceCategoryColumns}
|
||||
categoryLabelMap={categoryLabelMap}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */}
|
||||
{isModalMode && (
|
||||
|
|
|
|||
|
|
@ -71,11 +71,13 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{options
|
||||
.filter((option) => option.value !== "")
|
||||
.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
|
@ -945,6 +947,19 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const estimatedLabelHeight = labelFontSize + labelMarginBottom + 2;
|
||||
|
||||
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 테두리/배경/텍스트 스타일)
|
||||
const hasCustomBorder = !!(style?.borderWidth || style?.borderColor || style?.borderStyle || style?.border);
|
||||
const hasCustomBackground = !!style?.backgroundColor;
|
||||
const hasCustomRadius = !!style?.borderRadius;
|
||||
|
||||
// 텍스트 스타일 오버라이드 (CSS 상속)
|
||||
const customTextStyle: React.CSSProperties = {};
|
||||
if (style?.color) customTextStyle.color = style.color;
|
||||
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
|
||||
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
|
||||
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
|
||||
const hasCustomText = Object.keys(customTextStyle).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -973,7 +988,18 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>(
|
|||
{required && <span className="text-orange-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
)}
|
||||
<div className="h-full w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full",
|
||||
// 커스텀 테두리 설정 시, 내부 select trigger의 기본 테두리 제거
|
||||
hasCustomBorder && "[&_button]:border-0! **:data-[slot=select-trigger]:border-0! [&_.border]:border-0!",
|
||||
// 커스텀 모서리 설정 시, 내부 요소의 기본 모서리 제거
|
||||
(hasCustomBorder || hasCustomRadius) && "[&_button]:rounded-none! **:data-[slot=select-trigger]:rounded-none! [&_.rounded-md]:rounded-none!",
|
||||
// 커스텀 배경 설정 시, 내부 요소를 투명하게
|
||||
hasCustomBackground && "[&_button]:bg-transparent! **:data-[slot=select-trigger]:bg-transparent!",
|
||||
)}
|
||||
style={hasCustomText ? customTextStyle : undefined}
|
||||
>
|
||||
{renderSelect()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -87,9 +87,9 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
|||
updateConfig("options", newOptions);
|
||||
};
|
||||
|
||||
const updateOption = (index: number, field: "value" | "label", value: string) => {
|
||||
const updateOptionValue = (index: number, value: string) => {
|
||||
const newOptions = [...options];
|
||||
newOptions[index] = { ...newOptions[index], [field]: value };
|
||||
newOptions[index] = { ...newOptions[index], value, label: value };
|
||||
updateConfig("options", newOptions);
|
||||
};
|
||||
|
||||
|
|
@ -139,7 +139,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
|||
</div>
|
||||
|
||||
{/* 정적 옵션 관리 */}
|
||||
{config.source === "static" && (
|
||||
{(config.source || "static") === "static" && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">옵션 목록</Label>
|
||||
|
|
@ -148,19 +148,13 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
|||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
<div className="max-h-40 space-y-1.5 overflow-y-auto">
|
||||
{options.map((option: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div key={index} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
value={option.value || ""}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
placeholder="값"
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={option.label || ""}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
placeholder="표시 텍스트"
|
||||
onChange={(e) => updateOptionValue(index, e.target.value)}
|
||||
placeholder={`옵션 ${index + 1}`}
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
|
|
@ -168,7 +162,7 @@ export const V2SelectConfigPanel: React.FC<V2SelectConfigPanelProps> = ({ config
|
|||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeOption(index)}
|
||||
className="text-destructive h-7 w-7 p-0"
|
||||
className="text-destructive h-7 w-7 shrink-0 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -236,8 +236,9 @@ export const dataApi = {
|
|||
upsertGroupedRecords: async (
|
||||
tableName: string,
|
||||
parentKeys: Record<string, any>,
|
||||
records: Array<Record<string, any>>
|
||||
): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; message?: string; error?: string }> => {
|
||||
records: Array<Record<string, any>>,
|
||||
options?: { deleteOrphans?: boolean }
|
||||
): Promise<{ success: boolean; inserted?: number; updated?: number; deleted?: number; savedIds?: string[]; message?: string; error?: string }> => {
|
||||
try {
|
||||
console.log("📡 [dataApi.upsertGroupedRecords] 요청 데이터:", {
|
||||
tableName,
|
||||
|
|
@ -251,6 +252,7 @@ export const dataApi = {
|
|||
tableName,
|
||||
parentKeys,
|
||||
records,
|
||||
deleteOrphans: options?.deleteOrphans ?? true, // 기본값: true (기존 동작 유지)
|
||||
};
|
||||
console.log("📦 [dataApi.upsertGroupedRecords] 요청 본문 (JSON):", JSON.stringify(requestBody, null, 2));
|
||||
|
||||
|
|
|
|||
|
|
@ -235,6 +235,57 @@ export const screenApi = {
|
|||
await apiClient.put(`/screen-management/screens/${screenId}/layers/${layerId}/condition`, { conditionConfig, layerName });
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// 조건부 영역(Zone) 관리
|
||||
// ========================================
|
||||
|
||||
// Zone 목록 조회
|
||||
getScreenZones: async (screenId: number): Promise<any[]> => {
|
||||
const response = await apiClient.get(`/screen-management/screens/${screenId}/zones`);
|
||||
return response.data.data || [];
|
||||
},
|
||||
|
||||
// Zone 생성
|
||||
createZone: async (screenId: number, zoneData: {
|
||||
zone_name?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
trigger_component_id?: string;
|
||||
trigger_operator?: string;
|
||||
}): Promise<any> => {
|
||||
const response = await apiClient.post(`/screen-management/screens/${screenId}/zones`, zoneData);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Zone 업데이트 (위치/크기/트리거)
|
||||
updateZone: async (zoneId: number, updates: {
|
||||
zone_name?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
trigger_component_id?: string;
|
||||
trigger_operator?: string;
|
||||
}): Promise<void> => {
|
||||
await apiClient.put(`/screen-management/zones/${zoneId}`, updates);
|
||||
},
|
||||
|
||||
// Zone 삭제
|
||||
deleteZone: async (zoneId: number): Promise<void> => {
|
||||
await apiClient.delete(`/screen-management/zones/${zoneId}`);
|
||||
},
|
||||
|
||||
// Zone에 레이어 추가
|
||||
addLayerToZone: async (screenId: number, zoneId: number, conditionValue: string, layerName?: string): Promise<{ layerId: number }> => {
|
||||
const response = await apiClient.post(`/screen-management/screens/${screenId}/zones/${zoneId}/layers`, {
|
||||
conditionValue,
|
||||
layerName,
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// ========================================
|
||||
// POP 레이아웃 관리 (모바일/태블릿)
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ export function ConditionalSectionViewer({
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full transition-all",
|
||||
"relative w-full",
|
||||
isDesignMode && showBorder && "border-muted-foreground/30 bg-muted/20 rounded-lg border-2 border-dashed",
|
||||
!isDesignMode && !isActive && "hidden",
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -275,6 +275,9 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
return ["", ""];
|
||||
}, [webType, rawValue]);
|
||||
|
||||
// 입력 필드에 직접 적용할 폰트 크기
|
||||
const inputFontSize = component.style?.fontSize;
|
||||
|
||||
// daterange 타입 전용 UI
|
||||
if (webType === "daterange") {
|
||||
return (
|
||||
|
|
@ -312,6 +315,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
|
||||
{/* 구분자 */}
|
||||
|
|
@ -341,6 +345,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -385,6 +390,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -421,6 +427,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -643,8 +643,8 @@ export function RepeaterTable({
|
|||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<div ref={containerRef} className="border border-gray-200 bg-white">
|
||||
<div className="max-h-[400px] overflow-x-auto overflow-y-auto">
|
||||
<div ref={containerRef} className="flex h-full flex-col border border-gray-200 bg-white">
|
||||
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto">
|
||||
<table
|
||||
className="border-collapse text-xs"
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
return value.replace(/,/g, "");
|
||||
};
|
||||
|
||||
// 입력 필드에 직접 적용할 폰트 크기
|
||||
const inputFontSize = component.style?.fontSize;
|
||||
|
||||
// Currency 타입 전용 UI
|
||||
if (webType === "currency") {
|
||||
return (
|
||||
|
|
@ -141,6 +144,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-right text-base font-semibold transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-green-600"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -179,6 +183,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
}
|
||||
}}
|
||||
className={`h-full flex-1 rounded-md border px-3 py-2 text-right text-base font-semibold transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-blue-600"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
|
||||
{/* 퍼센트 기호 */}
|
||||
|
|
@ -218,6 +223,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
max={componentConfig.max}
|
||||
step={step}
|
||||
className={`box-border h-full w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} placeholder:text-gray-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -54,6 +54,10 @@ export interface FieldGroup {
|
|||
description?: string;
|
||||
/** 그룹 표시 순서 */
|
||||
order?: number;
|
||||
/** 🆕 최대 항목 수 (1이면 1:1 관계 - 자동 생성, + 추가 버튼 숨김) */
|
||||
maxEntries?: number;
|
||||
/** 🆕 이 그룹의 소스 테이블 (카테고리 옵션 로드 시 사용) */
|
||||
sourceTable?: string;
|
||||
/** 🆕 이 그룹의 항목 표시 설정 (그룹별로 다른 표시 형식 가능) */
|
||||
displayItems?: DisplayItem[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -192,6 +192,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
}
|
||||
|
||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||
// 입력 필드에 직접 적용할 폰트 크기
|
||||
const inputFontSize = component.style?.fontSize;
|
||||
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
|
|
@ -412,6 +415,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
|
||||
{/* @ 구분자 */}
|
||||
|
|
@ -528,6 +532,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
|
||||
<span className="text-muted-foreground text-base font-medium">-</span>
|
||||
|
|
@ -558,6 +563,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
|
||||
<span className="text-muted-foreground text-base font-medium">-</span>
|
||||
|
|
@ -588,6 +594,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -659,6 +666,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -712,6 +720,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -791,6 +800,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
: "bg-background text-foreground",
|
||||
"disabled:cursor-not-allowed",
|
||||
)}
|
||||
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
|
||||
onClick={(e) => {
|
||||
handleClick(e);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
|||
border: "1px solid #d1d5db",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
fontSize: component.style?.fontSize || "14px",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
|
|
|
|||
|
|
@ -1283,13 +1283,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
width: buttonWidth,
|
||||
height: buttonHeight,
|
||||
minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
|
||||
border: "none",
|
||||
borderRadius: "0.5rem",
|
||||
// 🔧 커스텀 테두리 스타일 (StyleEditor에서 설정한 값 우선)
|
||||
border: style?.border || (style?.borderWidth ? undefined : "none"),
|
||||
borderWidth: style?.borderWidth || undefined,
|
||||
borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || undefined,
|
||||
borderColor: style?.borderColor || undefined,
|
||||
borderRadius: style?.borderRadius || "0.5rem",
|
||||
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
|
||||
color: finalDisabled ? "#9ca3af" : buttonTextColor, // 🔧 webTypeConfig.textColor 지원
|
||||
// 🔧 크기 설정 적용 (sm/md/lg)
|
||||
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
|
||||
fontWeight: "600",
|
||||
color: finalDisabled ? "#9ca3af" : (style?.color || buttonTextColor), // 🔧 StyleEditor 텍스트 색상도 지원
|
||||
// 🔧 크기 설정 적용 (sm/md/lg), StyleEditor fontSize 우선
|
||||
fontSize: style?.fontSize || (componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem"),
|
||||
fontWeight: style?.fontWeight || "600",
|
||||
cursor: finalDisabled ? "not-allowed" : "pointer",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
|
|
|
|||
|
|
@ -233,8 +233,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// tableName 확인 (props에서 전달받은 tableName 사용)
|
||||
const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
|
||||
// tableName 확인 (props에서 전달받은 tableName 또는 componentConfig에서 추출)
|
||||
const tableNameToUse = tableName || component.componentConfig?.tableName;
|
||||
|
||||
if (!tableNameToUse) {
|
||||
setLoading(false);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { toast } from "sonner";
|
||||
import { uploadFiles, downloadFile, deleteFile, getComponentFiles, getFileInfoByObjid, getFilePreviewUrl } from "@/lib/api/file";
|
||||
import { GlobalFileManager } from "@/lib/api/globalFile";
|
||||
import { formatFileSize } from "@/lib/utils";
|
||||
import { formatFileSize, cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { FileViewerModal } from "./FileViewerModal";
|
||||
import { FileManagerModal } from "./FileManagerModal";
|
||||
|
|
@ -492,11 +492,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
// 파일 업로드 설정 - componentConfig가 undefined일 수 있으므로 안전하게 처리
|
||||
const safeComponentConfig = componentConfig || {};
|
||||
const fileConfig = {
|
||||
...safeComponentConfig,
|
||||
accept: safeComponentConfig.accept || "*/*",
|
||||
multiple: safeComponentConfig.multiple || false,
|
||||
maxSize: safeComponentConfig.maxSize || 10 * 1024 * 1024, // 10MB
|
||||
maxFiles: safeComponentConfig.maxFiles || 5,
|
||||
...safeComponentConfig,
|
||||
} as FileUploadConfig;
|
||||
|
||||
// 파일 선택 핸들러
|
||||
|
|
@ -513,7 +513,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 파일 업로드 처리
|
||||
// 백엔드 multer 제한에 맞춘 1회 요청당 최대 파일 수
|
||||
const CHUNK_SIZE = 10;
|
||||
|
||||
// 파일 업로드 처리 (10개 초과 시 자동 분할 업로드)
|
||||
const handleFileUpload = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
|
|
@ -548,7 +551,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
|
||||
const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files;
|
||||
setUploadStatus("uploading");
|
||||
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
|
||||
|
||||
// 분할 업로드 여부 판단
|
||||
const totalFiles = filesToUpload.length;
|
||||
const totalChunks = Math.ceil(totalFiles / CHUNK_SIZE);
|
||||
const isChunked = totalChunks > 1;
|
||||
|
||||
if (isChunked) {
|
||||
toast.loading(`파일 업로드 준비 중... (총 ${totalFiles}개, ${totalChunks}회 분할)`, { id: "file-upload" });
|
||||
} else {
|
||||
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
|
||||
}
|
||||
|
||||
try {
|
||||
// 🔑 레코드 모드 우선 사용
|
||||
|
|
@ -585,13 +598,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
|
||||
|
||||
// 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용
|
||||
// formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시
|
||||
const finalLinkedTable = effectiveIsRecordMode
|
||||
? effectiveTableName
|
||||
: (formData?.linkedTable || effectiveTableName);
|
||||
|
||||
const uploadData = {
|
||||
// 🎯 formData에서 백엔드 API 설정 가져오기
|
||||
autoLink: formData?.autoLink || true,
|
||||
linkedTable: finalLinkedTable,
|
||||
recordId: effectiveRecordId || `temp_${component.id}`,
|
||||
|
|
@ -599,143 +610,163 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
|
||||
docType: component.fileConfig?.docType || "DOCUMENT",
|
||||
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
|
||||
companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달
|
||||
// 호환성을 위한 기존 필드들
|
||||
companyCode: userCompanyCode,
|
||||
tableName: effectiveTableName,
|
||||
fieldName: effectiveColumnName,
|
||||
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
|
||||
// 🆕 레코드 모드 플래그
|
||||
targetObjid: targetObjid,
|
||||
isRecordMode: effectiveIsRecordMode,
|
||||
};
|
||||
|
||||
const response = await uploadFiles({
|
||||
files: filesToUpload,
|
||||
...uploadData,
|
||||
});
|
||||
// 🔄 파일을 CHUNK_SIZE(10개)씩 나눠서 순차 업로드
|
||||
const allNewFiles: any[] = [];
|
||||
let failedChunks = 0;
|
||||
|
||||
if (response.success) {
|
||||
// FileUploadResponse 타입에 맞게 files 배열 사용
|
||||
const fileData = response.files || (response as any).data || [];
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
const start = chunkIndex * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, totalFiles);
|
||||
const chunk = filesToUpload.slice(start, end);
|
||||
|
||||
if (fileData.length === 0) {
|
||||
throw new Error("업로드된 파일 데이터를 받지 못했습니다.");
|
||||
// 분할 업로드 시 진행 상태 토스트 업데이트
|
||||
if (isChunked) {
|
||||
toast.loading(
|
||||
`업로드 중... ${chunkIndex + 1}/${totalChunks} 배치 (${start + 1}~${end}번째 파일)`,
|
||||
{ id: "file-upload" }
|
||||
);
|
||||
}
|
||||
|
||||
const newFiles = fileData.map((file: any) => ({
|
||||
objid: file.objid || file.id,
|
||||
savedFileName: file.saved_file_name || file.savedFileName,
|
||||
realFileName: file.real_file_name || file.realFileName || file.name,
|
||||
fileSize: file.file_size || file.fileSize || file.size,
|
||||
fileExt: file.file_ext || file.fileExt || file.extension,
|
||||
filePath: file.file_path || file.filePath || file.path,
|
||||
docType: file.doc_type || file.docType,
|
||||
docTypeName: file.doc_type_name || file.docTypeName,
|
||||
targetObjid: file.target_objid || file.targetObjid,
|
||||
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
|
||||
companyCode: file.company_code || file.companyCode,
|
||||
writer: file.writer,
|
||||
regdate: file.regdate,
|
||||
status: file.status || "ACTIVE",
|
||||
uploadedAt: new Date().toISOString(),
|
||||
...file,
|
||||
}));
|
||||
|
||||
|
||||
const updatedFiles = [...uploadedFiles, ...newFiles];
|
||||
|
||||
setUploadedFiles(updatedFiles);
|
||||
setUploadStatus("success");
|
||||
|
||||
// localStorage 백업 (레코드별 고유 키 사용)
|
||||
try {
|
||||
const backupKey = getUniqueKey();
|
||||
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||
} catch (e) {
|
||||
console.warn("localStorage 백업 실패:", e);
|
||||
const response = await uploadFiles({
|
||||
files: chunk,
|
||||
...uploadData,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
const fileData = response.files || (response as any).data || [];
|
||||
const chunkFiles = fileData.map((file: any) => ({
|
||||
objid: file.objid || file.id,
|
||||
savedFileName: file.saved_file_name || file.savedFileName,
|
||||
realFileName: file.real_file_name || file.realFileName || file.name,
|
||||
fileSize: file.file_size || file.fileSize || file.size,
|
||||
fileExt: file.file_ext || file.fileExt || file.extension,
|
||||
filePath: file.file_path || file.filePath || file.path,
|
||||
docType: file.doc_type || file.docType,
|
||||
docTypeName: file.doc_type_name || file.docTypeName,
|
||||
targetObjid: file.target_objid || file.targetObjid,
|
||||
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
|
||||
companyCode: file.company_code || file.companyCode,
|
||||
writer: file.writer,
|
||||
regdate: file.regdate,
|
||||
status: file.status || "ACTIVE",
|
||||
uploadedAt: new Date().toISOString(),
|
||||
...file,
|
||||
}));
|
||||
allNewFiles.push(...chunkFiles);
|
||||
} else {
|
||||
console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 실패:`, response);
|
||||
failedChunks++;
|
||||
}
|
||||
} catch (chunkError) {
|
||||
console.error(`❌ ${chunkIndex + 1}번째 배치 업로드 오류:`, chunkError);
|
||||
failedChunks++;
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
||||
if (typeof window !== "undefined") {
|
||||
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
|
||||
const globalFileState = (window as any).globalFileState || {};
|
||||
const uniqueKey = getUniqueKey();
|
||||
globalFileState[uniqueKey] = updatedFiles;
|
||||
(window as any).globalFileState = globalFileState;
|
||||
// 모든 배치 처리 완료 후 결과 처리
|
||||
if (allNewFiles.length === 0) {
|
||||
throw new Error("업로드된 파일 데이터를 받지 못했습니다.");
|
||||
}
|
||||
|
||||
// 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용)
|
||||
GlobalFileManager.registerFiles(newFiles, {
|
||||
uploadPage: window.location.pathname,
|
||||
const updatedFiles = [...uploadedFiles, ...allNewFiles];
|
||||
|
||||
setUploadedFiles(updatedFiles);
|
||||
setUploadStatus("success");
|
||||
|
||||
// localStorage 백업 (레코드별 고유 키 사용)
|
||||
try {
|
||||
const backupKey = getUniqueKey();
|
||||
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
|
||||
} catch (e) {
|
||||
console.warn("localStorage 백업 실패:", e);
|
||||
}
|
||||
|
||||
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
|
||||
if (typeof window !== "undefined") {
|
||||
const globalFileState = (window as any).globalFileState || {};
|
||||
const uniqueKey = getUniqueKey();
|
||||
globalFileState[uniqueKey] = updatedFiles;
|
||||
(window as any).globalFileState = globalFileState;
|
||||
|
||||
GlobalFileManager.registerFiles(allNewFiles, {
|
||||
uploadPage: window.location.pathname,
|
||||
componentId: component.id,
|
||||
screenId: formData?.screenId,
|
||||
recordId: recordId,
|
||||
});
|
||||
|
||||
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
screenId: formData?.screenId,
|
||||
recordId: recordId, // 🆕 레코드 ID 추가
|
||||
});
|
||||
eventColumnName: columnName,
|
||||
uniqueKey: uniqueKey,
|
||||
recordId: recordId,
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(syncEvent);
|
||||
}
|
||||
|
||||
// 모든 파일 컴포넌트에 동기화 이벤트 발생
|
||||
// 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
|
||||
const syncEvent = new CustomEvent("globalFileStateChanged", {
|
||||
detail: {
|
||||
componentId: component.id,
|
||||
eventColumnName: columnName, // 🆕 컬럼명 추가
|
||||
uniqueKey: uniqueKey, // 🆕 고유 키 추가
|
||||
recordId: recordId, // 🆕 레코드 ID 추가
|
||||
files: updatedFiles,
|
||||
fileCount: updatedFiles.length,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(syncEvent);
|
||||
}
|
||||
|
||||
// 컴포넌트 업데이트
|
||||
if (onUpdate) {
|
||||
const timestamp = Date.now();
|
||||
onUpdate({
|
||||
uploadedFiles: updatedFiles,
|
||||
lastFileUpdate: timestamp,
|
||||
});
|
||||
} else {
|
||||
console.warn("⚠️ onUpdate 콜백이 없습니다!");
|
||||
}
|
||||
|
||||
// 🆕 이미지/파일 컬럼에 objid 저장 (formData 업데이트)
|
||||
if (onFormDataChange && effectiveColumnName) {
|
||||
// 🎯 이미지/파일 타입 컬럼: 첫 번째 파일의 objid를 저장 (그리드에서 표시용)
|
||||
// 단일 파일인 경우 단일 값, 복수 파일인 경우 콤마 구분 문자열
|
||||
const fileObjids = updatedFiles.map(file => file.objid);
|
||||
const columnValue = fileConfig.multiple
|
||||
? fileObjids.join(',') // 복수 파일: 콤마 구분
|
||||
: (fileObjids[0] || ''); // 단일 파일: 첫 번째 파일 ID
|
||||
|
||||
// onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환)
|
||||
onFormDataChange(effectiveColumnName, columnValue);
|
||||
}
|
||||
|
||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||
if (typeof window !== "undefined") {
|
||||
const refreshEvent = new CustomEvent("refreshFileStatus", {
|
||||
detail: {
|
||||
tableName: effectiveTableName,
|
||||
recordId: effectiveRecordId,
|
||||
columnName: effectiveColumnName,
|
||||
targetObjid: targetObjid,
|
||||
fileCount: updatedFiles.length,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(refreshEvent);
|
||||
}
|
||||
|
||||
// 컴포넌트 설정 콜백
|
||||
if (safeComponentConfig.onFileUpload) {
|
||||
safeComponentConfig.onFileUpload(newFiles);
|
||||
}
|
||||
|
||||
// 성공 시 토스트 처리
|
||||
setUploadStatus("idle");
|
||||
toast.dismiss("file-upload");
|
||||
toast.success(`${newFiles.length}개 파일 업로드 완료`);
|
||||
// 컴포넌트 업데이트
|
||||
if (onUpdate) {
|
||||
const timestamp = Date.now();
|
||||
onUpdate({
|
||||
uploadedFiles: updatedFiles,
|
||||
lastFileUpdate: timestamp,
|
||||
});
|
||||
} else {
|
||||
console.error("❌ 파일 업로드 실패:", response);
|
||||
throw new Error(response.message || (response as any).error || "파일 업로드에 실패했습니다.");
|
||||
console.warn("⚠️ onUpdate 콜백이 없습니다!");
|
||||
}
|
||||
|
||||
// 이미지/파일 컬럼에 objid 저장 (formData 업데이트)
|
||||
if (onFormDataChange && effectiveColumnName) {
|
||||
const fileObjids = updatedFiles.map(file => file.objid);
|
||||
const columnValue = fileConfig.multiple
|
||||
? fileObjids.join(',')
|
||||
: (fileObjids[0] || '');
|
||||
onFormDataChange(effectiveColumnName, columnValue);
|
||||
}
|
||||
|
||||
// 그리드 파일 상태 새로고침 이벤트 발생
|
||||
if (typeof window !== "undefined") {
|
||||
const refreshEvent = new CustomEvent("refreshFileStatus", {
|
||||
detail: {
|
||||
tableName: effectiveTableName,
|
||||
recordId: effectiveRecordId,
|
||||
columnName: effectiveColumnName,
|
||||
targetObjid: targetObjid,
|
||||
fileCount: updatedFiles.length,
|
||||
},
|
||||
});
|
||||
window.dispatchEvent(refreshEvent);
|
||||
}
|
||||
|
||||
// 컴포넌트 설정 콜백
|
||||
if (safeComponentConfig.onFileUpload) {
|
||||
safeComponentConfig.onFileUpload(allNewFiles);
|
||||
}
|
||||
|
||||
// 성공/부분 성공 토스트 처리
|
||||
setUploadStatus("idle");
|
||||
toast.dismiss("file-upload");
|
||||
|
||||
if (failedChunks > 0) {
|
||||
toast.warning(
|
||||
`${allNewFiles.length}개 업로드 완료, 일부 파일 실패`,
|
||||
{ description: "일부 파일이 업로드되지 않았습니다. 다시 시도해주세요." }
|
||||
);
|
||||
} else {
|
||||
toast.success(`${allNewFiles.length}개 파일 업로드 완료`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("파일 업로드 오류:", error);
|
||||
|
|
@ -991,19 +1022,26 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
[safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick],
|
||||
);
|
||||
|
||||
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 값)
|
||||
const customStyle = component.style || {};
|
||||
const hasCustomBorder = !!(customStyle.borderWidth || customStyle.borderColor || customStyle.borderStyle || customStyle.border);
|
||||
const hasCustomBackground = !!customStyle.backgroundColor;
|
||||
const hasCustomRadius = !!customStyle.borderRadius;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...componentStyle,
|
||||
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
|
||||
height: "100%", // 🆕 부모 컨테이너 높이에 맞춤
|
||||
border: "none !important",
|
||||
boxShadow: "none !important",
|
||||
outline: "none !important",
|
||||
backgroundColor: "transparent !important",
|
||||
padding: "0px !important",
|
||||
borderRadius: "0px !important",
|
||||
marginBottom: "8px !important",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
// 🔧 !important 제거 - 커스텀 스타일이 없을 때만 기본값 적용
|
||||
border: hasCustomBorder ? undefined : "none",
|
||||
boxShadow: "none",
|
||||
outline: "none",
|
||||
backgroundColor: hasCustomBackground ? undefined : "transparent",
|
||||
padding: "0px",
|
||||
borderRadius: hasCustomRadius ? undefined : "0px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
className={`${className} file-upload-container`}
|
||||
>
|
||||
|
|
@ -1014,15 +1052,15 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
position: "absolute",
|
||||
top: "-20px",
|
||||
left: "0px",
|
||||
fontSize: "12px",
|
||||
color: "rgb(107, 114, 128)",
|
||||
fontWeight: "400",
|
||||
background: "transparent !important",
|
||||
border: "none !important",
|
||||
boxShadow: "none !important",
|
||||
outline: "none !important",
|
||||
padding: "0px !important",
|
||||
margin: "0px !important"
|
||||
fontSize: customStyle.labelFontSize || "12px",
|
||||
color: customStyle.labelColor || "rgb(107, 114, 128)",
|
||||
fontWeight: customStyle.labelFontWeight || "400",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
boxShadow: "none",
|
||||
outline: "none",
|
||||
padding: "0px",
|
||||
margin: "0px"
|
||||
}}
|
||||
>
|
||||
{component.label}
|
||||
|
|
@ -1033,7 +1071,13 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
)}
|
||||
|
||||
<div
|
||||
className="border-border bg-card relative flex h-full w-full flex-col rounded-lg border overflow-hidden"
|
||||
className={cn(
|
||||
"relative flex h-full w-full flex-col overflow-hidden",
|
||||
// 커스텀 테두리가 없을 때만 기본 테두리 표시
|
||||
!hasCustomBorder && "border-border rounded-lg border",
|
||||
// 커스텀 배경이 없을 때만 기본 배경 표시
|
||||
!hasCustomBackground && "bg-card",
|
||||
)}
|
||||
>
|
||||
{/* 대표 이미지 전체 화면 표시 */}
|
||||
{uploadedFiles.length > 0 ? (() => {
|
||||
|
|
@ -1117,7 +1161,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
|||
onFileDelete={handleFileDelete}
|
||||
onFileView={handleFileView}
|
||||
onSetRepresentative={handleSetRepresentative}
|
||||
config={safeComponentConfig}
|
||||
config={fileConfig}
|
||||
isDesignMode={isDesignMode}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -121,8 +121,8 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
|
|||
onChange={handleChange}
|
||||
config={{
|
||||
mode: config.mode || "dropdown",
|
||||
// 🔧 카테고리 타입이면 source를 "category"로 설정
|
||||
source: config.source || (isCategoryType ? "category" : "distinct"),
|
||||
// 🔧 카테고리 타입이면 source를 무조건 "category"로 강제 (테이블 타입 관리 설정 우선)
|
||||
source: isCategoryType ? "category" : (config.source || "distinct"),
|
||||
multiple: config.multiple || false,
|
||||
searchable: config.searchable ?? true,
|
||||
placeholder: config.placeholder || "선택하세요",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -42,6 +42,8 @@ export interface AdditionalTabConfig {
|
|||
sortable?: boolean;
|
||||
align?: "left" | "center" | "right";
|
||||
bold?: boolean;
|
||||
showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true)
|
||||
showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true)
|
||||
format?: {
|
||||
type?: "number" | "currency" | "date" | "text";
|
||||
thousandSeparator?: boolean;
|
||||
|
|
@ -50,6 +52,14 @@ export interface AdditionalTabConfig {
|
|||
suffix?: string;
|
||||
dateFormat?: string;
|
||||
};
|
||||
// Entity 조인 컬럼 정보
|
||||
isEntityJoin?: boolean;
|
||||
joinInfo?: {
|
||||
sourceTable: string;
|
||||
sourceColumn: string;
|
||||
referenceTable: string;
|
||||
joinAlias: string;
|
||||
};
|
||||
}>;
|
||||
|
||||
addModalColumns?: Array<{
|
||||
|
|
@ -105,6 +115,14 @@ export interface AdditionalTabConfig {
|
|||
groupByColumns?: string[];
|
||||
};
|
||||
|
||||
// 추가 버튼 설정 (모달 화면 연결 지원)
|
||||
addButton?: {
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
|
||||
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
|
||||
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
|
||||
};
|
||||
|
||||
deleteButton?: {
|
||||
enabled: boolean;
|
||||
buttonLabel?: string;
|
||||
|
|
@ -131,6 +149,23 @@ export interface SplitPanelLayoutConfig {
|
|||
showAdd?: boolean;
|
||||
showEdit?: boolean; // 수정 버튼
|
||||
showDelete?: boolean; // 삭제 버튼
|
||||
|
||||
// 수정 버튼 설정 (모달 화면 연결 지원)
|
||||
editButton?: {
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal"; // auto: 내장 편집, modal: 커스텀 모달 화면
|
||||
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
|
||||
buttonLabel?: string; // 버튼 라벨 (기본: "수정")
|
||||
};
|
||||
|
||||
// 추가 버튼 설정 (모달 화면 연결 지원)
|
||||
addButton?: {
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
|
||||
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
|
||||
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
|
||||
};
|
||||
|
||||
columns?: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
|
|
@ -145,6 +180,14 @@ export interface SplitPanelLayoutConfig {
|
|||
suffix?: string; // 접미사 (예: "원", "개")
|
||||
dateFormat?: string; // 날짜 포맷 (type: "date")
|
||||
};
|
||||
// Entity 조인 컬럼 정보
|
||||
isEntityJoin?: boolean;
|
||||
joinInfo?: {
|
||||
sourceTable: string;
|
||||
sourceColumn: string;
|
||||
referenceTable: string;
|
||||
joinAlias: string;
|
||||
};
|
||||
}>;
|
||||
// 추가 모달에서 입력받을 컬럼 설정
|
||||
addModalColumns?: Array<{
|
||||
|
|
@ -209,6 +252,8 @@ export interface SplitPanelLayoutConfig {
|
|||
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
||||
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
|
||||
bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드)
|
||||
showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true)
|
||||
showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true)
|
||||
format?: {
|
||||
type?: "number" | "currency" | "date" | "text"; // 포맷 타입
|
||||
thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency")
|
||||
|
|
@ -217,6 +262,14 @@ export interface SplitPanelLayoutConfig {
|
|||
suffix?: string; // 접미사 (예: "원", "개")
|
||||
dateFormat?: string; // 날짜 포맷 (type: "date")
|
||||
};
|
||||
// Entity 조인 컬럼 정보
|
||||
isEntityJoin?: boolean;
|
||||
joinInfo?: {
|
||||
sourceTable: string;
|
||||
sourceColumn: string;
|
||||
referenceTable: string;
|
||||
joinAlias: string;
|
||||
};
|
||||
}>;
|
||||
// 추가 모달에서 입력받을 컬럼 설정
|
||||
addModalColumns?: Array<{
|
||||
|
|
@ -279,6 +332,14 @@ export interface SplitPanelLayoutConfig {
|
|||
groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"])
|
||||
};
|
||||
|
||||
// 🆕 추가 버튼 설정 (모달 화면 연결 지원)
|
||||
addButton?: {
|
||||
enabled: boolean; // 추가 버튼 표시 여부 (기본: true)
|
||||
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
|
||||
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
|
||||
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
|
||||
};
|
||||
|
||||
// 🆕 삭제 버튼 설정
|
||||
deleteButton?: {
|
||||
enabled: boolean; // 삭제 버튼 표시 여부 (기본: true)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react";
|
||||
import { CardDisplayConfig, ColumnConfig } from "./types";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import { getFilePreviewUrl } from "@/lib/api/file";
|
||||
|
||||
interface CardModeRendererProps {
|
||||
data: Record<string, any>[];
|
||||
|
|
@ -168,12 +170,25 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
|
|||
{imageValue && (
|
||||
<div className="mb-3">
|
||||
<img
|
||||
src={imageValue}
|
||||
src={(() => {
|
||||
const strValue = String(imageValue);
|
||||
const isObjid = /^\d+$/.test(strValue);
|
||||
return isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
|
||||
})()}
|
||||
alt={titleValue}
|
||||
className="h-24 w-full rounded-md bg-gray-100 object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
// 이미지 로드 실패 시 폴백 표시
|
||||
target.style.display = "none";
|
||||
const parent = target.parentElement;
|
||||
if (parent && !parent.querySelector("[data-image-fallback]")) {
|
||||
const fallback = document.createElement("div");
|
||||
fallback.setAttribute("data-image-fallback", "true");
|
||||
fallback.className = "flex items-center justify-center h-24 w-full rounded-md bg-muted text-muted-foreground";
|
||||
fallback.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>`;
|
||||
parent.appendChild(fallback);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ interface SingleTableWithStickyProps {
|
|||
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
|
||||
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
|
||||
renderCheckboxHeader?: () => React.ReactNode;
|
||||
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string;
|
||||
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => React.ReactNode;
|
||||
getColumnWidth: (column: ColumnConfig) => number;
|
||||
containerWidth?: string; // 컨테이너 너비 설정
|
||||
loading?: boolean;
|
||||
|
|
@ -264,25 +264,34 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
currentSearchIndex < highlightArray.length &&
|
||||
highlightArray[currentSearchIndex] === cellKey;
|
||||
|
||||
// formatCellValue 결과 (이미지 등 JSX 반환 가능)
|
||||
const rawCellValue =
|
||||
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
|
||||
// 이미지 등 JSX 반환 여부 확인
|
||||
const isReactElement = typeof rawCellValue === "object" && React.isValidElement(rawCellValue);
|
||||
|
||||
// 셀 값에서 검색어 하이라이트 렌더링
|
||||
const renderCellContent = () => {
|
||||
const cellValue =
|
||||
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
|
||||
|
||||
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
|
||||
return cellValue;
|
||||
// ReactNode(JSX)가 반환된 경우 (이미지 등) 그대로 렌더링
|
||||
if (isReactElement) {
|
||||
return rawCellValue;
|
||||
}
|
||||
|
||||
// 검색어 하이라이트 처리
|
||||
const lowerValue = String(cellValue).toLowerCase();
|
||||
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
|
||||
return rawCellValue;
|
||||
}
|
||||
|
||||
// 검색어 하이라이트 처리 (문자열만)
|
||||
const strValue = String(rawCellValue);
|
||||
const lowerValue = strValue.toLowerCase();
|
||||
const lowerTerm = searchTerm.toLowerCase();
|
||||
const startIndex = lowerValue.indexOf(lowerTerm);
|
||||
|
||||
if (startIndex === -1) return cellValue;
|
||||
if (startIndex === -1) return rawCellValue;
|
||||
|
||||
const before = String(cellValue).slice(0, startIndex);
|
||||
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
|
||||
const after = String(cellValue).slice(startIndex + searchTerm.length);
|
||||
const before = strValue.slice(0, startIndex);
|
||||
const match = strValue.slice(startIndex, startIndex + searchTerm.length);
|
||||
const after = strValue.slice(startIndex + searchTerm.length);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -307,7 +316,9 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
key={`cell-${column.columnName}`}
|
||||
id={isCurrentSearchResult ? "current-search-result" : undefined}
|
||||
className={cn(
|
||||
"text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm",
|
||||
"text-foreground h-10 px-3 py-1.5 align-middle text-xs transition-colors sm:px-4 sm:py-2 sm:text-sm",
|
||||
// 이미지 셀은 overflow/ellipsis 제외 (이미지 잘림 방지)
|
||||
!isReactElement && "whitespace-nowrap",
|
||||
`text-${column.align}`,
|
||||
// 고정 컬럼 스타일
|
||||
column.fixed === "left" &&
|
||||
|
|
@ -322,9 +333,8 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
|
|||
minWidth: "100px", // 최소 너비 보장
|
||||
maxWidth: "300px", // 최대 너비 제한
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
// 이미지 셀은 overflow 허용
|
||||
...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }),
|
||||
// sticky 위치 설정
|
||||
...(column.fixed === "left" && { left: leftFixedWidth }),
|
||||
...(column.fixed === "right" && { right: rightFixedWidth }),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,96 @@ import { getFilePreviewUrl } from "@/lib/api/file";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
|
||||
|
||||
// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트
|
||||
// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용
|
||||
const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
|
||||
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
const strValue = String(value);
|
||||
const isObjid = /^\d+$/.test(strValue);
|
||||
|
||||
if (isObjid) {
|
||||
// objid인 경우: 인증된 API로 blob 다운로드
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const response = await apiClient.get(`/files/preview/${strValue}`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
if (mounted) {
|
||||
const blob = new Blob([response.data]);
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
setImgSrc(url);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (mounted) {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadImage();
|
||||
} else {
|
||||
// 경로인 경우: 직접 URL 사용
|
||||
setImgSrc(getFullImageUrl(strValue));
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
// blob URL 해제
|
||||
if (imgSrc && imgSrc.startsWith("blob:")) {
|
||||
window.URL.revokeObjectURL(imgSrc);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||
<div className="h-8 w-8 animate-pulse rounded bg-muted sm:h-10 sm:w-10" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !imgSrc) {
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||
<div className="bg-muted text-muted-foreground flex h-8 w-8 items-center justify-center rounded sm:h-10 sm:w-10" title="이미지를 불러올 수 없습니다">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt="이미지"
|
||||
className="h-8 w-8 cursor-pointer rounded object-cover transition-opacity hover:opacity-80 sm:h-10 sm:w-10"
|
||||
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// objid인 경우 preview URL로 열기, 아니면 full URL로 열기
|
||||
const strValue = String(value);
|
||||
const isObjid = /^\d+$/.test(strValue);
|
||||
const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
|
||||
window.open(openUrl, "_blank");
|
||||
}}
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
TableCellImage.displayName = "TableCellImage";
|
||||
|
||||
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
|
||||
declare global {
|
||||
interface Window {
|
||||
|
|
@ -4061,35 +4151,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
|
||||
const inputType = meta?.inputType || column.inputType;
|
||||
|
||||
// 🖼️ 이미지 타입: 작은 썸네일 표시
|
||||
// 🖼️ 이미지 타입: 작은 썸네일 표시 (TableCellImage 컴포넌트 사용)
|
||||
if (inputType === "image" && value) {
|
||||
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용
|
||||
// 🔑 download 대신 preview 사용 (공개 접근 허용)
|
||||
const strValue = String(value);
|
||||
const isObjid = /^\d+$/.test(strValue);
|
||||
// 🔑 상대 경로(/api/...) 대신 전체 URL 사용 (Docker 환경에서 Next.js rewrite 의존 방지)
|
||||
const imageUrl = isObjid
|
||||
? getFilePreviewUrl(strValue)
|
||||
: getFullImageUrl(strValue);
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="이미지"
|
||||
className="h-10 w-10 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ maxWidth: "40px", maxHeight: "40px" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 이미지 클릭 시 새 탭에서 크게 보기
|
||||
window.open(imageUrl, "_blank");
|
||||
}}
|
||||
onError={(e) => {
|
||||
// 이미지 로드 실패 시 기본 아이콘 표시
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return <TableCellImage value={String(value)} />;
|
||||
}
|
||||
|
||||
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시
|
||||
|
|
@ -5945,7 +6009,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<td
|
||||
key={column.columnName}
|
||||
className={cn(
|
||||
"text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm",
|
||||
"text-foreground text-xs font-normal sm:text-sm",
|
||||
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
|
||||
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
|
||||
column.columnName === "__checkbox__"
|
||||
? "px-0 py-1"
|
||||
: "px-2 py-1 sm:px-4 sm:py-1.5",
|
||||
|
|
@ -6112,7 +6178,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
data-row={index}
|
||||
data-col={colIndex}
|
||||
className={cn(
|
||||
"text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm",
|
||||
"text-foreground text-xs font-normal sm:text-sm",
|
||||
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
|
||||
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
|
||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
|
||||
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
||||
// 🆕 포커스된 셀 스타일
|
||||
|
|
|
|||
|
|
@ -186,9 +186,10 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// 🆕 customTableName이 설정된 경우 반드시 API에서 가져오기
|
||||
// tableColumns prop은 화면의 기본 테이블 컬럼이므로, customTableName 사용 시 무시
|
||||
const shouldUseTableColumnsProp = !config.useCustomTable && tableColumns && tableColumns.length > 0;
|
||||
// tableColumns prop은 화면의 기본 테이블 컬럼이므로,
|
||||
// 다른 테이블을 선택한 경우 반드시 API에서 가져오기
|
||||
const isUsingDifferentTable = config.selectedTable && screenTableName && config.selectedTable !== screenTableName;
|
||||
const shouldUseTableColumnsProp = !config.useCustomTable && !isUsingDifferentTable && tableColumns && tableColumns.length > 0;
|
||||
|
||||
if (shouldUseTableColumnsProp) {
|
||||
const mappedColumns = tableColumns.map((column: any) => ({
|
||||
|
|
@ -772,11 +773,113 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
handleChange("columns", columns);
|
||||
};
|
||||
|
||||
// 테이블 변경 핸들러 - 테이블 변경 시 컬럼 설정 초기화
|
||||
const handleTableChange = (newTableName: string) => {
|
||||
if (newTableName === targetTableName) return;
|
||||
|
||||
const updatedConfig = {
|
||||
...config,
|
||||
selectedTable: newTableName,
|
||||
// 테이블이 변경되면 컬럼 설정 초기화
|
||||
columns: [],
|
||||
};
|
||||
onChange(updatedConfig);
|
||||
setTableComboboxOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm font-medium">테이블 리스트 설정</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 테이블 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">데이터 소스</h3>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
테이블을 선택하세요. 미선택 시 화면 메인 테이블을 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
<hr className="border-border" />
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">테이블 선택</Label>
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Table2 className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{loadingTables
|
||||
? "테이블 로딩 중..."
|
||||
: targetTableName
|
||||
? availableTables.find((t) => t.tableName === targetTableName)?.displayName ||
|
||||
targetTableName
|
||||
: "테이블 선택"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableName} ${table.displayName}`}
|
||||
onSelect={() => handleTableChange(table.tableName)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
targetTableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{table.displayName}</span>
|
||||
{table.displayName !== table.tableName && (
|
||||
<span className="text-[10px] text-gray-400">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{screenTableName && targetTableName !== screenTableName && (
|
||||
<div className="flex items-center justify-between rounded bg-amber-50 px-2 py-1">
|
||||
<span className="text-[10px] text-amber-700">
|
||||
화면 기본 테이블({screenTableName})과 다른 테이블을 사용 중
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[10px] text-amber-700 hover:text-amber-900"
|
||||
onClick={() => handleTableChange(screenTableName)}
|
||||
>
|
||||
기본으로
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 툴바 버튼 설정 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
|
|
@ -1167,11 +1270,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!screenTableName ? (
|
||||
{!targetTableName ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>테이블이 연결되지 않았습니다.</p>
|
||||
<p className="text-sm">화면에 테이블을 연결한 후 컬럼을 설정할 수 있습니다.</p>
|
||||
<p>테이블이 선택되지 않았습니다.</p>
|
||||
<p className="text-sm">위 데이터 소스에서 테이블을 선택하세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : availableColumns.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -154,15 +154,23 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
|
||||
// 대상 패널의 첫 번째 테이블 자동 선택
|
||||
useEffect(() => {
|
||||
if (!autoSelectFirstTable || tableList.length === 0) {
|
||||
if (!autoSelectFirstTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 탭 전환 감지: 활성 탭이 변경되었는지 확인
|
||||
const tabChanged = prevActiveTabIdsRef.current !== activeTabIdsStr;
|
||||
if (tabChanged) {
|
||||
// 탭이 변경되면 항상 ref를 갱신 (tableList가 비어 있어도)
|
||||
// 이렇게 해야 비동기로 tableList가 나중에 채워질 때 중복 감지하지 않음
|
||||
prevActiveTabIdsRef.current = activeTabIdsStr;
|
||||
|
||||
if (tableList.length === 0) {
|
||||
// 테이블이 아직 등록되지 않은 상태 (비동기 로드 중)
|
||||
// tableList가 나중에 채워지면 아래 폴백 로직에서 처리됨
|
||||
return;
|
||||
}
|
||||
|
||||
// 탭 전환 시: 해당 탭에 속한 테이블 중 첫 번째 강제 선택
|
||||
const activeTabTable = tableList.find((t) => t.parentTabId && activeTabIds.includes(t.parentTabId));
|
||||
const targetTable = activeTabTable || tableList[0];
|
||||
|
|
@ -173,11 +181,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
return; // 탭 전환 시에는 여기서 종료
|
||||
}
|
||||
|
||||
// 현재 선택된 테이블이 대상 패널에 있는지 확인
|
||||
const isCurrentTableInTarget = selectedTableId && tableList.some((t) => t.tableId === selectedTableId);
|
||||
// tableList가 비어있으면 아래 로직 스킵
|
||||
if (tableList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 현재 선택된 테이블이 대상 패널에 없으면 첫 번째 테이블 선택
|
||||
if (!selectedTableId || !isCurrentTableInTarget) {
|
||||
// 현재 선택된 테이블이 활성 탭에 속하는지 확인
|
||||
const isCurrentTableInActiveTab = selectedTableId && tableList.some((t) => {
|
||||
if (t.tableId !== selectedTableId) return false;
|
||||
// parentTabId가 있는 테이블이면 활성 탭에 속하는지 확인
|
||||
if (t.parentTabId) return activeTabIds.includes(t.parentTabId);
|
||||
return true; // parentTabId 없는 전역 테이블은 항상 유효
|
||||
});
|
||||
|
||||
// 현재 선택된 테이블이 활성 탭에 없거나 미선택이면 첫 번째 테이블 선택
|
||||
if (!selectedTableId || !isCurrentTableInActiveTab) {
|
||||
const activeTabTable = tableList.find((t) => t.parentTabId && activeTabIds.includes(t.parentTabId));
|
||||
const targetTable = activeTabTable || tableList[0];
|
||||
|
||||
|
|
@ -223,6 +241,102 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
}
|
||||
}, [currentTableTabId, currentTable?.tableName]);
|
||||
|
||||
// 탭 전환 플래그 (탭 복귀 시 필터 재적용을 위해)
|
||||
const needsFilterReapplyRef = useRef(false);
|
||||
const prevActiveTabIdsForReapplyRef = useRef<string>(activeTabIdsStr);
|
||||
|
||||
// 탭 전환 감지: 플래그만 설정 (실제 적용은 currentTable이 준비된 후)
|
||||
useEffect(() => {
|
||||
if (prevActiveTabIdsForReapplyRef.current !== activeTabIdsStr) {
|
||||
prevActiveTabIdsForReapplyRef.current = activeTabIdsStr;
|
||||
needsFilterReapplyRef.current = true;
|
||||
}
|
||||
}, [activeTabIdsStr]);
|
||||
|
||||
// 탭 복귀 시 기존 필터값 재적용
|
||||
// currentTable이 준비되고 필터값이 있을 때 실행
|
||||
useEffect(() => {
|
||||
if (!needsFilterReapplyRef.current) return;
|
||||
if (!currentTable?.onFilterChange) return;
|
||||
|
||||
// 플래그 즉시 해제 (중복 실행 방지)
|
||||
needsFilterReapplyRef.current = false;
|
||||
|
||||
// activeFilters와 filterValues가 있으면 직접 onFilterChange 호출
|
||||
// applyFilters 클로저 의존성을 피하고 직접 계산
|
||||
if (activeFilters.length === 0) return;
|
||||
|
||||
const hasValues = Object.values(filterValues).some(
|
||||
(v) => v !== "" && v !== undefined && v !== null,
|
||||
);
|
||||
if (!hasValues) return;
|
||||
|
||||
const filtersWithValues = activeFilters
|
||||
.map((filter) => {
|
||||
let filterValue = filterValues[filter.columnName];
|
||||
|
||||
// 날짜 범위 객체 처리
|
||||
if (
|
||||
filter.filterType === "date" &&
|
||||
filterValue &&
|
||||
typeof filterValue === "object" &&
|
||||
(filterValue.from || filterValue.to)
|
||||
) {
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
const fromStr = filterValue.from ? formatDate(filterValue.from) : "";
|
||||
const toStr = filterValue.to ? formatDate(filterValue.to) : "";
|
||||
if (fromStr && toStr) filterValue = `${fromStr}|${toStr}`;
|
||||
else if (fromStr) filterValue = `${fromStr}|`;
|
||||
else if (toStr) filterValue = `|${toStr}`;
|
||||
else filterValue = "";
|
||||
}
|
||||
|
||||
// 배열 처리
|
||||
if (Array.isArray(filterValue)) {
|
||||
filterValue = filterValue.join("|");
|
||||
}
|
||||
|
||||
let operator = "contains";
|
||||
if (filter.filterType === "select") operator = "equals";
|
||||
else if (filter.filterType === "number") operator = "equals";
|
||||
|
||||
return {
|
||||
...filter,
|
||||
value: filterValue || "",
|
||||
operator,
|
||||
};
|
||||
})
|
||||
.filter((f) => {
|
||||
if (!f.value) return false;
|
||||
if (typeof f.value === "string" && f.value === "") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 직접 onFilterChange 호출 (applyFilters 클로저 우회)
|
||||
currentTable.onFilterChange(filtersWithValues);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentTable?.onFilterChange, currentTable?.tableName, activeFilters, filterValues]);
|
||||
|
||||
// 필터 적용을 다음 렌더 사이클로 지연 (activeFilters 업데이트 후 적용 보장)
|
||||
const pendingFilterApplyRef = useRef<{ values: Record<string, any>; tableName: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingFilterApplyRef.current) {
|
||||
const { values, tableName } = pendingFilterApplyRef.current;
|
||||
// 현재 테이블이 요청된 테이블과 일치하는지 확인 (탭이 빠르게 전환된 경우 방지)
|
||||
if (currentTable?.tableName === tableName) {
|
||||
applyFilters(values);
|
||||
}
|
||||
pendingFilterApplyRef.current = null;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeFilters, currentTable?.tableName]);
|
||||
|
||||
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
||||
useEffect(() => {
|
||||
if (!currentTable?.tableName) return;
|
||||
|
|
@ -246,8 +360,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
try {
|
||||
const parsedValues = JSON.parse(savedValues);
|
||||
setFilterValues(parsedValues);
|
||||
// 즉시 필터 적용
|
||||
setTimeout(() => applyFilters(parsedValues), 100);
|
||||
// 다음 렌더 사이클에서 필터 적용 (activeFilters 업데이트 후)
|
||||
pendingFilterApplyRef.current = { values: parsedValues, tableName: currentTable.tableName };
|
||||
} catch {
|
||||
setFilterValues({});
|
||||
}
|
||||
|
|
@ -297,8 +411,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
try {
|
||||
const parsedValues = JSON.parse(savedValues);
|
||||
setFilterValues(parsedValues);
|
||||
// 즉시 필터 적용
|
||||
setTimeout(() => applyFilters(parsedValues), 100);
|
||||
// 다음 렌더 사이클에서 필터 적용 (activeFilters 업데이트 후)
|
||||
pendingFilterApplyRef.current = { values: parsedValues, tableName: currentTable.tableName };
|
||||
} catch {
|
||||
setFilterValues({});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ const TabsDesignEditor: React.FC<{
|
|||
[activeTabId, component, onUpdateComponent, tabs]
|
||||
);
|
||||
|
||||
// 10px 단위 스냅 함수
|
||||
const snapTo10 = useCallback((value: number) => Math.round(value / 10) * 10, []);
|
||||
|
||||
// 컴포넌트 드래그 시작
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.MouseEvent, comp: TabInlineComponent) => {
|
||||
|
|
@ -104,9 +107,9 @@ const TabsDesignEditor: React.FC<{
|
|||
const deltaX = moveEvent.clientX - startMouseX;
|
||||
const deltaY = moveEvent.clientY - startMouseY;
|
||||
|
||||
// 새 위치 = 시작 위치 + 이동량
|
||||
const newX = Math.max(0, startLeft + deltaX);
|
||||
const newY = Math.max(0, startTop + deltaY);
|
||||
// 새 위치 = 시작 위치 + 이동량 (10px 단위 스냅 적용)
|
||||
const newX = snapTo10(Math.max(0, startLeft + deltaX));
|
||||
const newY = snapTo10(Math.max(0, startTop + deltaY));
|
||||
|
||||
// React 상태로 위치 업데이트 (리렌더링 트리거)
|
||||
setDragPosition({ x: newX, y: newY });
|
||||
|
|
@ -126,9 +129,9 @@ const TabsDesignEditor: React.FC<{
|
|||
const deltaX = upEvent.clientX - startMouseX;
|
||||
const deltaY = upEvent.clientY - startMouseY;
|
||||
|
||||
// 새 위치 = 시작 위치 + 이동량
|
||||
const newX = Math.max(0, startLeft + deltaX);
|
||||
const newY = Math.max(0, startTop + deltaY);
|
||||
// 새 위치 = 시작 위치 + 이동량 (10px 단위 스냅 적용)
|
||||
const newX = snapTo10(Math.max(0, startLeft + deltaX));
|
||||
const newY = snapTo10(Math.max(0, startTop + deltaY));
|
||||
|
||||
setDraggingCompId(null);
|
||||
setDragPosition(null);
|
||||
|
|
@ -144,8 +147,8 @@ const TabsDesignEditor: React.FC<{
|
|||
? {
|
||||
...c,
|
||||
position: {
|
||||
x: Math.max(0, Math.round(newX)),
|
||||
y: Math.max(0, Math.round(newY)),
|
||||
x: newX,
|
||||
y: newY,
|
||||
},
|
||||
}
|
||||
: c
|
||||
|
|
@ -172,12 +175,9 @@ const TabsDesignEditor: React.FC<{
|
|||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[activeTabId, component, onUpdateComponent, tabs]
|
||||
[activeTabId, component, onUpdateComponent, tabs, snapTo10]
|
||||
);
|
||||
|
||||
// 10px 단위 스냅 함수
|
||||
const snapTo10 = useCallback((value: number) => Math.round(value / 10) * 10, []);
|
||||
|
||||
// 리사이즈 시작 핸들러
|
||||
const handleResizeStart = useCallback(
|
||||
(e: React.MouseEvent, comp: TabInlineComponent, direction: "e" | "s" | "se") => {
|
||||
|
|
|
|||
|
|
@ -56,16 +56,19 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
|||
// DOM props 필터링 (React 관련 props 제거)
|
||||
const domProps = filterDOMProps(props);
|
||||
|
||||
// 🔧 StyleEditor(component.style) 값 우선, 없으면 componentConfig 폴백
|
||||
const customStyle = component.style || {};
|
||||
|
||||
// 텍스트 스타일 계산
|
||||
const textStyle: React.CSSProperties = {
|
||||
fontSize: componentConfig.fontSize || "14px",
|
||||
fontWeight: componentConfig.fontWeight || "normal",
|
||||
color: componentConfig.color || "#212121",
|
||||
textAlign: componentConfig.textAlign || "left",
|
||||
backgroundColor: componentConfig.backgroundColor || "transparent",
|
||||
fontSize: customStyle.fontSize || componentConfig.fontSize || "14px",
|
||||
fontWeight: customStyle.fontWeight || componentConfig.fontWeight || "normal",
|
||||
color: customStyle.color || componentConfig.color || "#212121",
|
||||
textAlign: (customStyle.textAlign || componentConfig.textAlign || "left") as React.CSSProperties["textAlign"],
|
||||
backgroundColor: customStyle.backgroundColor || componentConfig.backgroundColor || "transparent",
|
||||
padding: componentConfig.padding || "0",
|
||||
borderRadius: componentConfig.borderRadius || "0",
|
||||
border: componentConfig.border || "none",
|
||||
borderRadius: customStyle.borderRadius || componentConfig.borderRadius || "0",
|
||||
border: customStyle.border || (customStyle.borderWidth ? `${customStyle.borderWidth} ${customStyle.borderStyle || "solid"} ${customStyle.borderColor || "transparent"}` : componentConfig.border || "none"),
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
|
|
|
|||
|
|
@ -214,19 +214,53 @@ export function matchComponentSize(
|
|||
* 모든 컴포넌트의 라벨 표시/숨기기를 토글합니다.
|
||||
* 숨겨진 라벨이 하나라도 있으면 모두 표시, 모두 표시되어 있으면 모두 숨기기
|
||||
*/
|
||||
export function toggleAllLabels(components: ComponentData[], forceShow?: boolean): ComponentData[] {
|
||||
// 현재 라벨이 숨겨진(labelDisplay === false) 컴포넌트가 있는지 확인
|
||||
const hasHiddenLabel = components.some(
|
||||
(c) => c.type === "widget" && (c.style as any)?.labelDisplay === false
|
||||
/**
|
||||
* 라벨 토글 대상 타입 판별
|
||||
* label 속성이 있고, style.labelDisplay를 지원하는 컴포넌트인지 확인
|
||||
*/
|
||||
function hasLabelSupport(component: ComponentData): boolean {
|
||||
// 라벨이 없는 컴포넌트는 제외
|
||||
if (!component.label) return false;
|
||||
|
||||
// 그룹, datatable 등은 라벨 토글 대상에서 제외
|
||||
const excludedTypes = ["group", "datatable"];
|
||||
if (excludedTypes.includes(component.type)) return false;
|
||||
|
||||
// 나머지 (widget, component, container, file, flow 등)는 대상
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param components - 전체 컴포넌트 배열
|
||||
* @param selectedIds - 선택된 컴포넌트 ID 목록 (빈 배열이면 전체 대상)
|
||||
* @param forceShow - 강제 표시/숨기기 (지정하지 않으면 자동 토글)
|
||||
*/
|
||||
export function toggleAllLabels(
|
||||
components: ComponentData[],
|
||||
selectedIds: string[] = [],
|
||||
forceShow?: boolean
|
||||
): ComponentData[] {
|
||||
// 대상 컴포넌트 필터: selectedIds가 있으면 선택된 것만, 없으면 전체
|
||||
const targetComponents = components.filter((c) => {
|
||||
if (!hasLabelSupport(c)) return false;
|
||||
if (selectedIds.length > 0) return selectedIds.includes(c.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 대상 중 라벨이 숨겨진 컴포넌트가 있는지 확인
|
||||
const hasHiddenLabel = targetComponents.some(
|
||||
(c) => (c.style as any)?.labelDisplay === false
|
||||
);
|
||||
|
||||
// forceShow가 지정되면 그 값 사용, 아니면 자동 판단
|
||||
// 숨겨진 라벨이 있으면 모두 표시, 아니면 모두 숨기기
|
||||
const shouldShow = forceShow !== undefined ? forceShow : hasHiddenLabel;
|
||||
|
||||
// 대상 ID Set (빠른 조회용)
|
||||
const targetIdSet = new Set(targetComponents.map((c) => c.id));
|
||||
|
||||
return components.map((c) => {
|
||||
// 위젯 타입만 라벨 토글 대상
|
||||
if (c.type !== "widget") return c;
|
||||
// 대상이 아닌 컴포넌트는 건드리지 않음
|
||||
if (!targetIdSet.has(c.id)) return c;
|
||||
|
||||
return {
|
||||
...c,
|
||||
|
|
|
|||
|
|
@ -541,6 +541,23 @@ export class ButtonActionExecutor {
|
|||
return false;
|
||||
}
|
||||
|
||||
// ✅ 입력 형식 검증 (이메일, 전화번호, URL 등)
|
||||
const formatValidationDetail = { errors: [] as Array<{ columnName: string; message: string }> };
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("validateFormInputs", {
|
||||
detail: formatValidationDetail,
|
||||
}),
|
||||
);
|
||||
// 약간의 대기 (이벤트 핸들러가 동기적으로 실행되지만 안전을 위해)
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
if (formatValidationDetail.errors.length > 0) {
|
||||
const errorMessages = formatValidationDetail.errors.map((e) => e.message);
|
||||
console.log("❌ [handleSave] 입력 형식 검증 실패:", formatValidationDetail.errors);
|
||||
toast.error(`입력 형식을 확인해주세요: ${errorMessages.join(", ")}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||||
if (onSave) {
|
||||
try {
|
||||
|
|
@ -987,13 +1004,25 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
|
||||
const primaryKeys = primaryKeyResult.data || [];
|
||||
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
|
||||
let primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
|
||||
|
||||
// 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리
|
||||
// originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨
|
||||
// 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인
|
||||
const hasRealOriginalData = originalData && Object.keys(originalData).length > 0;
|
||||
|
||||
// 🆕 폴백: formData에 PK가 없으면 originalData에서 PK 추출
|
||||
// 수정 모달에서 id 입력 필드가 없는 경우 formData에 id가 포함되지 않음
|
||||
if (!primaryKeyValue && hasRealOriginalData) {
|
||||
primaryKeyValue = this.extractPrimaryKeyValueFromDB(originalData, primaryKeys);
|
||||
if (primaryKeyValue) {
|
||||
// formData에도 PK 값을 주입하여 UPDATE 쿼리에서 사용 가능하게 함
|
||||
const pkColumn = primaryKeys[0];
|
||||
formData[pkColumn] = primaryKeyValue;
|
||||
console.log(`🔑 [handleSave] originalData에서 PK 복원: ${pkColumn} = ${primaryKeyValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 폴백 로직: originalData가 없어도 formData에 id가 있으면 UPDATE로 판단
|
||||
// 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리
|
||||
const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== "";
|
||||
|
|
@ -3144,6 +3173,8 @@ export class ButtonActionExecutor {
|
|||
editData: useAsEditData && isPassDataMode ? parentData : undefined,
|
||||
splitPanelParentData: isPassDataMode ? parentData : undefined,
|
||||
urlParams: dataSourceId ? { dataSourceId } : undefined,
|
||||
// 🆕 필드 매핑 정보 전달 - ScreenModal에서 매핑된 필드를 필터링하지 않도록
|
||||
fieldMappings: config.fieldMappings,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -4151,6 +4182,8 @@ export class ButtonActionExecutor {
|
|||
dataSourceType: controlDataSource,
|
||||
sourceData,
|
||||
context: extendedContext,
|
||||
// 저장 전 원본 데이터 전달 (after 타이밍에서 DB 기존값 비교용)
|
||||
originalData: context.originalData || null,
|
||||
});
|
||||
|
||||
results.push({
|
||||
|
|
@ -4965,7 +4998,7 @@ export class ButtonActionExecutor {
|
|||
// visible이 true인 컬럼만 추출
|
||||
visibleColumns = columns.filter((col: any) => col.visible !== false).map((col: any) => col.columnName);
|
||||
|
||||
// 🎯 column_labels 테이블에서 실제 라벨 가져오기
|
||||
// column_labels 테이블에서 실제 라벨 가져오기
|
||||
try {
|
||||
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
|
||||
params: { page: 1, size: 9999 },
|
||||
|
|
@ -5002,19 +5035,77 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 화면 레이아웃 조회 실패:", error);
|
||||
}
|
||||
|
||||
// 🎨 카테고리 값들 조회 (한 번만)
|
||||
// Fallback: 레이아웃에서 컬럼 정보를 못 가져온 경우, table_type_columns에서 직접 조회
|
||||
// 시스템 컬럼 제외 + 라벨 적용으로 raw 컬럼명 노출 방지
|
||||
const SYSTEM_COLUMNS = ["id", "company_code", "created_date", "updated_date", "writer"];
|
||||
if ((!visibleColumns || visibleColumns.length === 0) && context.tableName && dataToExport.length > 0) {
|
||||
console.log("⚠️ 레이아웃에서 컬럼 설정을 찾지 못함 → table_type_columns에서 fallback 조회");
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
|
||||
params: { page: 1, size: 9999 },
|
||||
});
|
||||
|
||||
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||||
let columnData = columnsResponse.data.data;
|
||||
if (columnData.columns && Array.isArray(columnData.columns)) {
|
||||
columnData = columnData.columns;
|
||||
}
|
||||
|
||||
if (Array.isArray(columnData) && columnData.length > 0) {
|
||||
// visible이 false가 아닌 컬럼만 + 시스템 컬럼 제외
|
||||
const filteredCols = columnData.filter((col: any) => {
|
||||
const colName = (col.column_name || col.columnName || "").toLowerCase();
|
||||
if (SYSTEM_COLUMNS.includes(colName)) return false;
|
||||
if (col.isVisible === false || col.is_visible === false) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
visibleColumns = filteredCols.map((col: any) => col.column_name || col.columnName);
|
||||
columnLabels = {};
|
||||
filteredCols.forEach((col: any) => {
|
||||
const colName = col.column_name || col.columnName;
|
||||
const labelValue = col.column_label || col.label || col.displayName || colName;
|
||||
if (colName) {
|
||||
columnLabels![colName] = labelValue;
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ Fallback 컬럼 ${visibleColumns.length}개 로드 완료`);
|
||||
}
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
console.error("❌ Fallback 컬럼 조회 실패:", fallbackError);
|
||||
}
|
||||
}
|
||||
|
||||
// 최종 안전장치: 여전히 컬럼 정보가 없으면 데이터의 키에서 시스템 컬럼만 제외
|
||||
if ((!visibleColumns || visibleColumns.length === 0) && dataToExport.length > 0) {
|
||||
console.log("⚠️ 최종 fallback: 데이터 키에서 시스템 컬럼 제외");
|
||||
const allKeys = Object.keys(dataToExport[0]);
|
||||
visibleColumns = allKeys.filter((key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
// 시스템 컬럼 제외
|
||||
if (SYSTEM_COLUMNS.includes(lowerKey)) return false;
|
||||
// _name, _label 등 조인된 보조 필드 제외
|
||||
if (lowerKey.endsWith("_name") || lowerKey.endsWith("_label") || lowerKey.endsWith("_value_label")) return false;
|
||||
return true;
|
||||
});
|
||||
// 라벨이 없으므로 최소한 column_labels 비워두지 않음 (컬럼명 그대로 표시되지만 시스템 컬럼은 제외됨)
|
||||
if (!columnLabels) {
|
||||
columnLabels = {};
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 값들 조회 (한 번만)
|
||||
const categoryMap: Record<string, Record<string, string>> = {};
|
||||
let categoryColumns: string[] = [];
|
||||
|
||||
// 백엔드에서 카테고리 컬럼 정보 가져오기
|
||||
if (context.tableName) {
|
||||
try {
|
||||
const { getCategoryColumns, getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
||||
|
|
@ -5053,7 +5144,7 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
// 🎨 컬럼 필터링 및 라벨 적용 (항상 실행)
|
||||
// 컬럼 필터링 및 라벨 적용
|
||||
if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) {
|
||||
dataToExport = dataToExport.map((row: any) => {
|
||||
const filteredRow: Record<string, any> = {};
|
||||
|
|
@ -5146,6 +5237,8 @@ export class ButtonActionExecutor {
|
|||
? config.excelAfterUploadFlows
|
||||
: config.masterDetailExcel?.afterUploadFlows;
|
||||
|
||||
// masterDetailExcel 설정이 명시적으로 있을 때만 간단 모드 (디테일만 업로드)
|
||||
// 설정이 없으면 기본 모드 (마스터+디테일 둘 다 업로드)
|
||||
if (config.masterDetailExcel) {
|
||||
masterDetailExcelConfig = {
|
||||
...config.masterDetailExcel,
|
||||
|
|
@ -5154,25 +5247,13 @@ export class ButtonActionExecutor {
|
|||
detailTable: relationResponse.data.detailTable,
|
||||
masterKeyColumn: relationResponse.data.masterKeyColumn,
|
||||
detailFkColumn: relationResponse.data.detailFkColumn,
|
||||
// 채번 규칙 ID 추가 (excelNumberingRuleId를 numberingRuleId로 매핑)
|
||||
numberingRuleId: config.masterDetailExcel.numberingRuleId || config.excelNumberingRuleId,
|
||||
// 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선)
|
||||
afterUploadFlows,
|
||||
};
|
||||
} else {
|
||||
// 버튼 설정이 없으면 분할 패널 정보만 사용
|
||||
masterDetailExcelConfig = {
|
||||
masterTable: relationResponse.data.masterTable,
|
||||
detailTable: relationResponse.data.detailTable,
|
||||
masterKeyColumn: relationResponse.data.masterKeyColumn,
|
||||
detailFkColumn: relationResponse.data.detailFkColumn,
|
||||
simpleMode: true, // 기본값으로 간단 모드 사용
|
||||
// 채번 규칙 ID 추가 (excelNumberingRuleId 사용)
|
||||
numberingRuleId: config.excelNumberingRuleId,
|
||||
// 채번은 ExcelUploadModal에서 마스터 테이블 기반 자동 감지
|
||||
// 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선)
|
||||
afterUploadFlows,
|
||||
};
|
||||
}
|
||||
// masterDetailExcel 설정 없으면 masterDetailExcelConfig는 undefined 유지
|
||||
// → ExcelUploadModal에서 기본 모드로 동작 (마스터+디테일 둘 다 매핑/업로드)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5214,9 +5295,7 @@ export class ButtonActionExecutor {
|
|||
isMasterDetail,
|
||||
masterDetailRelation,
|
||||
masterDetailExcelConfig,
|
||||
// 🆕 단일 테이블 채번 설정
|
||||
numberingRuleId: config.excelNumberingRuleId,
|
||||
numberingTargetColumn: config.excelNumberingTargetColumn,
|
||||
// 채번은 ExcelUploadModal에서 테이블 타입 관리 기반 자동 감지
|
||||
// 🆕 업로드 후 제어 실행 설정
|
||||
afterUploadFlows: config.excelAfterUploadFlows,
|
||||
onSuccess: () => {
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export interface ConditionNodeData {
|
|||
field: string;
|
||||
operator: ConditionOperator;
|
||||
value: any;
|
||||
valueType?: "static" | "field"; // 비교 값 타입
|
||||
valueType?: "static" | "field" | "target"; // 비교 값 타입 (target: DB 기존값 비교)
|
||||
// EXISTS_IN / NOT_EXISTS_IN 전용 필드
|
||||
lookupTable?: string; // 조회할 테이블명
|
||||
lookupTableLabel?: string; // 조회할 테이블 라벨
|
||||
|
|
@ -127,6 +127,16 @@ export interface ConditionNodeData {
|
|||
}>;
|
||||
logic: "AND" | "OR";
|
||||
displayName?: string;
|
||||
// 타겟 테이블 조회 (DB 기존값과 비교할 때 사용)
|
||||
targetLookup?: {
|
||||
tableName: string;
|
||||
tableLabel?: string;
|
||||
lookupKeys: Array<{
|
||||
sourceField: string; // 소스(폼) 데이터의 키 필드
|
||||
targetField: string; // 타겟(DB) 테이블의 키 필드
|
||||
sourceFieldLabel?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// 필드 매핑 노드
|
||||
|
|
|
|||
|
|
@ -878,7 +878,7 @@ export interface LayerOverlayConfig {
|
|||
|
||||
/**
|
||||
* 조건부 레이어 표시 영역
|
||||
* 조건 미충족 시 이 영역이 사라지고, 아래 컴포넌트들이 위로 이동
|
||||
* @deprecated Zone 기반으로 전환 - ConditionalZone.x/y/width/height 사용
|
||||
*/
|
||||
export interface DisplayRegion {
|
||||
x: number;
|
||||
|
|
@ -887,6 +887,27 @@ export interface DisplayRegion {
|
|||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 영역(Zone)
|
||||
* - 기본 레이어 캔버스에서 영역을 정의하고, 여러 레이어를 할당
|
||||
* - Zone 내에서는 항상 1개 레이어만 활성 (exclusive)
|
||||
* - Zone 단위로 접힘/펼침 판단 (Y 오프셋 계산 단순화)
|
||||
*/
|
||||
export interface ConditionalZone {
|
||||
zone_id: number;
|
||||
screen_id: number;
|
||||
company_code: string;
|
||||
zone_name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
trigger_component_id: string | null; // 기본 레이어의 트리거 컴포넌트 ID
|
||||
trigger_operator: string; // eq, neq, in
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이어 정의
|
||||
*/
|
||||
|
|
@ -898,10 +919,14 @@ export interface LayerDefinition {
|
|||
isVisible: boolean; // 초기 표시 여부
|
||||
isLocked: boolean; // 편집 잠금 여부
|
||||
|
||||
// 조건부 표시 로직
|
||||
// 조건부 표시 로직 (레거시 - Zone 미사용 레이어용)
|
||||
condition?: LayerCondition;
|
||||
|
||||
// 조건부 레이어 표시 영역 (조건 미충족 시 이 영역이 사라짐)
|
||||
// Zone 기반 조건부 설정 (신규)
|
||||
zoneId?: number; // 소속 조건부 영역 ID
|
||||
conditionValue?: string; // Zone 트리거 매칭 값
|
||||
|
||||
// 조건부 레이어 표시 영역 (레거시 호환 - Zone으로 대체됨)
|
||||
displayRegion?: DisplayRegion;
|
||||
|
||||
// 모달/드로어 전용 설정
|
||||
|
|
|
|||
|
|
@ -16,15 +16,12 @@ import {
|
|||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { spawn } from "child_process";
|
||||
import { platform } from "os";
|
||||
import { AGENT_CONFIGS } from "./agents/prompts.js";
|
||||
import { AgentType, ParallelResult } from "./agents/types.js";
|
||||
import { logger } from "./utils/logger.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// OS 감지
|
||||
const isWindows = platform() === "win32";
|
||||
logger.info(`Platform detected: ${platform()} (isWindows: ${isWindows})`);
|
||||
|
|
@ -43,12 +40,97 @@ const server = new Server(
|
|||
);
|
||||
|
||||
/**
|
||||
* Cursor Agent CLI를 통해 에이전트 호출
|
||||
* Cursor Team Plan 사용 - API 키 불필요!
|
||||
* 유틸: ms만큼 대기
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor Agent CLI 단일 호출 (내부용)
|
||||
* spawn + stdin 직접 전달
|
||||
*/
|
||||
function spawnAgentOnce(
|
||||
agentType: AgentType,
|
||||
fullPrompt: string,
|
||||
model: string
|
||||
): Promise<string> {
|
||||
const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`;
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const child = spawn(agentPath, ['--model', model, '--print'], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}`,
|
||||
},
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('error', (err: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
||||
if (stderr) {
|
||||
const significantStderr = stderr
|
||||
.split('\n')
|
||||
.filter((line: string) => line && !line.includes('warning') && !line.includes('info') && !line.includes('debug'))
|
||||
.join('\n');
|
||||
if (significantStderr) {
|
||||
logger.warn(`${agentType} agent stderr`, { stderr: significantStderr.substring(0, 500) });
|
||||
}
|
||||
}
|
||||
|
||||
if (code === 0 || stdout.trim().length > 0) {
|
||||
resolve(stdout.trim());
|
||||
} else {
|
||||
reject(new Error(
|
||||
`Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}`
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// 타임아웃 (5분)
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error(`${agentType} agent timed out after 5 minutes`));
|
||||
}
|
||||
}, 300000);
|
||||
|
||||
child.on('close', () => clearTimeout(timeout));
|
||||
|
||||
// stdin으로 프롬프트 직접 전달
|
||||
child.stdin.write(fullPrompt);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor Agent CLI를 통해 에이전트 호출 (재시도 포함)
|
||||
*
|
||||
* 크로스 플랫폼 지원:
|
||||
* - Windows: cmd /c "echo. | agent ..." (stdin 닫기 위해)
|
||||
* - Mac/Linux: ~/.local/bin/agent 사용
|
||||
* - 최대 2회 재시도 (총 3회 시도)
|
||||
* - 재시도 간 2초 대기 (Cursor CLI 동시 실행 제한 대응)
|
||||
*/
|
||||
async function callAgentCLI(
|
||||
agentType: AgentType,
|
||||
|
|
@ -56,60 +138,43 @@ async function callAgentCLI(
|
|||
context?: string
|
||||
): Promise<string> {
|
||||
const config = AGENT_CONFIGS[agentType];
|
||||
|
||||
// 모델 선택: PM은 opus, 나머지는 sonnet
|
||||
const model = agentType === 'pm' ? 'opus-4.5' : 'sonnet-4.5';
|
||||
const maxRetries = 2;
|
||||
|
||||
logger.info(`Calling ${agentType} agent via CLI`, { model, task: task.substring(0, 100) });
|
||||
logger.info(`Calling ${agentType} agent via CLI (spawn+retry)`, {
|
||||
model,
|
||||
task: task.substring(0, 100),
|
||||
});
|
||||
|
||||
try {
|
||||
const userMessage = context
|
||||
? `${task}\n\n배경 정보:\n${context}`
|
||||
: task;
|
||||
const userMessage = context
|
||||
? `${task}\n\n배경 정보:\n${context}`
|
||||
: task;
|
||||
const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`;
|
||||
|
||||
// 프롬프트를 임시 파일에 저장하여 쉘 이스케이프 문제 회피
|
||||
const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
// Base64 인코딩으로 특수문자 문제 해결
|
||||
const encodedPrompt = Buffer.from(fullPrompt).toString('base64');
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
const delay = attempt * 2000; // 2초, 4초
|
||||
logger.info(`${agentType} agent retry ${attempt}/${maxRetries} (waiting ${delay}ms)`);
|
||||
await sleep(delay);
|
||||
}
|
||||
|
||||
let cmd: string;
|
||||
let shell: string;
|
||||
const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`;
|
||||
|
||||
if (isWindows) {
|
||||
// Windows: PowerShell을 통해 Base64 디코딩 후 실행
|
||||
cmd = `powershell -Command "$prompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encodedPrompt}')); echo $prompt | ${agentPath} --model ${model} --print"`;
|
||||
shell = 'powershell.exe';
|
||||
} else {
|
||||
// Mac/Linux: echo로 base64 디코딩 후 파이프
|
||||
cmd = `echo "${encodedPrompt}" | base64 -d | ${agentPath} --model ${model} --print`;
|
||||
shell = '/bin/bash';
|
||||
const result = await spawnAgentOnce(agentType, fullPrompt, model);
|
||||
logger.info(`${agentType} agent completed (attempt ${attempt + 1})`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
logger.warn(`${agentType} agent attempt ${attempt + 1} failed`, {
|
||||
error: lastError.message.substring(0, 200),
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug(`Executing: ${agentPath} --model ${model} --print`);
|
||||
|
||||
const { stdout, stderr } = await execAsync(cmd, {
|
||||
cwd: process.cwd(),
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
|
||||
timeout: 300000, // 5분 타임아웃
|
||||
shell,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (stderr && !stderr.includes('warning') && !stderr.includes('info')) {
|
||||
logger.warn(`${agentType} agent stderr`, { stderr: stderr.substring(0, 500) });
|
||||
}
|
||||
|
||||
logger.info(`${agentType} agent completed via CLI`);
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
logger.error(`${agentType} agent CLI error`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 모든 재시도 실패
|
||||
logger.error(`${agentType} agent failed after ${maxRetries + 1} attempts`);
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -277,12 +342,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||
}>;
|
||||
};
|
||||
|
||||
logger.info(`Parallel ask to ${requests.length} agents (TRUE PARALLEL!)`);
|
||||
logger.info(`Parallel ask to ${requests.length} agents (STAGGERED PARALLEL)`);
|
||||
|
||||
// 시차 병렬 실행: 각 에이전트를 500ms 간격으로 시작
|
||||
// Cursor Agent CLI 동시 실행 제한 대응
|
||||
const STAGGER_DELAY = 500; // ms
|
||||
|
||||
// 진짜 병렬 실행! 모든 에이전트가 동시에 작업
|
||||
const results: ParallelResult[] = await Promise.all(
|
||||
requests.map(async (req) => {
|
||||
requests.map(async (req, index) => {
|
||||
try {
|
||||
// 시차 적용 (첫 번째는 즉시, 이후 500ms 간격)
|
||||
if (index > 0) {
|
||||
await sleep(index * STAGGER_DELAY);
|
||||
}
|
||||
const result = await callAgentCLI(req.agent, req.task, req.context);
|
||||
return { agent: req.agent, result };
|
||||
} catch (error) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue