chpark-sync #425
File diff suppressed because it is too large
Load Diff
|
|
@ -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 정보 (참고용으로만 사용, 필터링하지 않음)
|
||||
|
|
|
|||
|
|
@ -30,10 +30,13 @@ export class EntityReferenceController {
|
|||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { limit = 100, search } = req.query;
|
||||
// 멀티테넌시: 인증된 사용자의 회사 코드
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`엔티티 참조 데이터 조회 요청: ${tableName}.${columnName}`, {
|
||||
limit,
|
||||
search,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 컬럼 정보 조회 (table_type_columns에서)
|
||||
|
|
@ -89,16 +92,34 @@ export class EntityReferenceController {
|
|||
});
|
||||
}
|
||||
|
||||
// 동적 쿼리로 참조 데이터 조회
|
||||
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
||||
// 참조 테이블에 company_code 컬럼이 있는지 확인
|
||||
const hasCompanyCode = await queryOne<any>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code' AND table_schema = 'public'`,
|
||||
[referenceTable]
|
||||
);
|
||||
|
||||
// 동적 쿼리로 참조 데이터 조회 (멀티테넌시 필터 적용)
|
||||
const whereConditions: string[] = [];
|
||||
const queryParams: any[] = [];
|
||||
|
||||
// 멀티테넌시: company_code 필터링 (참조 테이블에 company_code가 있는 경우)
|
||||
if (hasCompanyCode && companyCode && companyCode !== "*") {
|
||||
queryParams.push(companyCode);
|
||||
whereConditions.push(`company_code = $${queryParams.length}`);
|
||||
logger.info(`멀티테넌시 필터 적용: company_code = ${companyCode}`, { referenceTable });
|
||||
}
|
||||
|
||||
// 검색 조건 추가
|
||||
if (search) {
|
||||
sqlQuery += ` WHERE ${displayColumn} ILIKE $1`;
|
||||
queryParams.push(`%${search}%`);
|
||||
whereConditions.push(`${displayColumn} ILIKE $${queryParams.length}`);
|
||||
}
|
||||
|
||||
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
||||
if (whereConditions.length > 0) {
|
||||
sqlQuery += ` WHERE ${whereConditions.join(" AND ")}`;
|
||||
}
|
||||
sqlQuery += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
|
||||
queryParams.push(Number(limit));
|
||||
|
||||
|
|
@ -107,6 +128,7 @@ export class EntityReferenceController {
|
|||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const referenceData = await query<any>(sqlQuery, queryParams);
|
||||
|
|
@ -119,7 +141,7 @@ export class EntityReferenceController {
|
|||
})
|
||||
);
|
||||
|
||||
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`);
|
||||
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -149,13 +171,16 @@ export class EntityReferenceController {
|
|||
try {
|
||||
const { codeCategory } = req.params;
|
||||
const { limit = 100, search } = req.query;
|
||||
// 멀티테넌시: 인증된 사용자의 회사 코드
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`공통 코드 데이터 조회 요청: ${codeCategory}`, {
|
||||
limit,
|
||||
search,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// code_info 테이블에서 코드 데이터 조회
|
||||
// code_info 테이블에서 코드 데이터 조회 (멀티테넌시 필터 적용)
|
||||
const queryParams: any[] = [codeCategory, 'Y'];
|
||||
let sqlQuery = `
|
||||
SELECT code_value, code_name
|
||||
|
|
@ -163,9 +188,16 @@ export class EntityReferenceController {
|
|||
WHERE code_category = $1 AND is_active = $2
|
||||
`;
|
||||
|
||||
// 멀티테넌시: company_code 필터링
|
||||
if (companyCode && companyCode !== "*") {
|
||||
queryParams.push(companyCode);
|
||||
sqlQuery += ` AND company_code = $${queryParams.length}`;
|
||||
logger.info(`공통 코드 멀티테넌시 필터 적용: company_code = ${companyCode}`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
sqlQuery += ` AND code_name ILIKE $3`;
|
||||
queryParams.push(`%${search}%`);
|
||||
sqlQuery += ` AND code_name ILIKE $${queryParams.length}`;
|
||||
}
|
||||
|
||||
sqlQuery += ` ORDER BY code_name ASC LIMIT $${queryParams.length + 1}`;
|
||||
|
|
@ -174,12 +206,12 @@ export class EntityReferenceController {
|
|||
const codeData = await query<any>(sqlQuery, queryParams);
|
||||
|
||||
// 옵션 형태로 변환
|
||||
const options: EntityReferenceOption[] = codeData.map((code) => ({
|
||||
const options: EntityReferenceOption[] = codeData.map((code: any) => ({
|
||||
value: code.code_value,
|
||||
label: code.code_name,
|
||||
}));
|
||||
|
||||
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`);
|
||||
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -2563,3 +2563,280 @@ export const getMenuTreeFromScreenGroups = async (req: AuthenticatedRequest, res
|
|||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// POP 전용 화면 그룹 API
|
||||
// hierarchy_path LIKE 'POP/%' 필터로 POP 카테고리만 조회
|
||||
// ============================================================
|
||||
|
||||
// POP 화면 그룹 목록 조회 (카테고리 트리용)
|
||||
export const getPopScreenGroups = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { searchTerm } = req.query;
|
||||
|
||||
let whereClause = "WHERE hierarchy_path LIKE 'POP/%' OR hierarchy_path = 'POP'";
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 회사 코드 필터링 (멀티테넌시)
|
||||
if (companyCode !== "*") {
|
||||
whereClause += ` AND company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// 검색어 필터링
|
||||
if (searchTerm) {
|
||||
whereClause += ` AND (group_name ILIKE $${paramIndex} OR group_code ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`;
|
||||
params.push(`%${searchTerm}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// POP 그룹 조회 (계층 구조를 위해 전체 조회)
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
sg.*,
|
||||
(SELECT COUNT(*) FROM screen_group_screens sgs WHERE sgs.group_id = sg.id) as screen_count,
|
||||
(SELECT json_agg(
|
||||
json_build_object(
|
||||
'id', sgs.id,
|
||||
'screen_id', sgs.screen_id,
|
||||
'screen_name', sd.screen_name,
|
||||
'screen_role', sgs.screen_role,
|
||||
'display_order', sgs.display_order,
|
||||
'is_default', sgs.is_default,
|
||||
'table_name', sd.table_name
|
||||
) ORDER BY sgs.display_order
|
||||
) FROM screen_group_screens sgs
|
||||
LEFT JOIN screen_definitions sd ON sgs.screen_id = sd.screen_id
|
||||
WHERE sgs.group_id = sg.id
|
||||
) as screens
|
||||
FROM screen_groups sg
|
||||
${whereClause}
|
||||
ORDER BY sg.display_order ASC, sg.hierarchy_path ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(dataQuery, params);
|
||||
|
||||
logger.info("POP 화면 그룹 목록 조회", { companyCode, count: result.rows.length });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("POP 화면 그룹 목록 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "POP 화면 그룹 목록 조회에 실패했습니다.", error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 화면 그룹 생성 (hierarchy_path 자동 설정)
|
||||
export const createPopScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId || "";
|
||||
const { group_name, group_code, description, icon, display_order, parent_group_id, target_company_code } = req.body;
|
||||
|
||||
if (!group_name || !group_code) {
|
||||
return res.status(400).json({ success: false, message: "그룹명과 그룹코드는 필수입니다." });
|
||||
}
|
||||
|
||||
// 회사 코드 결정
|
||||
const effectiveCompanyCode = target_company_code || userCompanyCode;
|
||||
if (userCompanyCode !== "*" && effectiveCompanyCode !== userCompanyCode) {
|
||||
return res.status(403).json({ success: false, message: "다른 회사의 그룹을 생성할 권한이 없습니다." });
|
||||
}
|
||||
|
||||
// hierarchy_path 계산 - POP 하위로 설정
|
||||
let hierarchyPath = "POP";
|
||||
if (parent_group_id) {
|
||||
// 부모 그룹의 hierarchy_path 조회
|
||||
const parentResult = await pool.query(
|
||||
`SELECT hierarchy_path FROM screen_groups WHERE id = $1`,
|
||||
[parent_group_id]
|
||||
);
|
||||
if (parentResult.rows.length > 0) {
|
||||
hierarchyPath = `${parentResult.rows[0].hierarchy_path}/${group_code}`;
|
||||
}
|
||||
} else {
|
||||
// 최상위 POP 카테고리
|
||||
hierarchyPath = `POP/${group_code}`;
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
const duplicateCheck = await pool.query(
|
||||
`SELECT id FROM screen_groups WHERE group_code = $1 AND company_code = $2`,
|
||||
[group_code, effectiveCompanyCode]
|
||||
);
|
||||
if (duplicateCheck.rows.length > 0) {
|
||||
return res.status(400).json({ success: false, message: "동일한 그룹코드가 이미 존재합니다." });
|
||||
}
|
||||
|
||||
// 그룹 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤)
|
||||
const insertQuery = `
|
||||
INSERT INTO screen_groups (
|
||||
group_name, group_code, description, icon, display_order,
|
||||
parent_group_id, hierarchy_path, company_code, writer, is_active
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'Y')
|
||||
RETURNING *
|
||||
`;
|
||||
const insertParams = [
|
||||
group_name,
|
||||
group_code,
|
||||
description || null,
|
||||
icon || null,
|
||||
display_order || 0,
|
||||
parent_group_id || null,
|
||||
hierarchyPath,
|
||||
effectiveCompanyCode,
|
||||
userId,
|
||||
];
|
||||
|
||||
const result = await pool.query(insertQuery, insertParams);
|
||||
|
||||
logger.info("POP 화면 그룹 생성", { groupId: result.rows[0].id, groupCode: group_code, companyCode: effectiveCompanyCode });
|
||||
|
||||
res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 생성되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("POP 화면 그룹 생성 실패:", error);
|
||||
res.status(500).json({ success: false, message: "POP 화면 그룹 생성에 실패했습니다.", error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 화면 그룹 수정
|
||||
export const updatePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const { group_name, description, icon, display_order, is_active } = req.body;
|
||||
|
||||
// 기존 그룹 확인
|
||||
let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`;
|
||||
const checkParams: any[] = [id];
|
||||
if (companyCode !== "*") {
|
||||
checkQuery += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await pool.query(checkQuery, checkParams);
|
||||
if (existing.rows.length === 0) {
|
||||
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
// POP 그룹인지 확인
|
||||
if (!existing.rows[0].hierarchy_path?.startsWith("POP")) {
|
||||
return res.status(400).json({ success: false, message: "POP 그룹만 수정할 수 있습니다." });
|
||||
}
|
||||
|
||||
// 업데이트
|
||||
const updateQuery = `
|
||||
UPDATE screen_groups
|
||||
SET group_name = COALESCE($1, group_name),
|
||||
description = COALESCE($2, description),
|
||||
icon = COALESCE($3, icon),
|
||||
display_order = COALESCE($4, display_order),
|
||||
is_active = COALESCE($5, is_active),
|
||||
updated_date = NOW()
|
||||
WHERE id = $6
|
||||
RETURNING *
|
||||
`;
|
||||
const updateParams = [group_name, description, icon, display_order, is_active, id];
|
||||
const result = await pool.query(updateQuery, updateParams);
|
||||
|
||||
logger.info("POP 화면 그룹 수정", { groupId: id, companyCode });
|
||||
|
||||
res.json({ success: true, data: result.rows[0], message: "POP 화면 그룹이 수정되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("POP 화면 그룹 수정 실패:", error);
|
||||
res.status(500).json({ success: false, message: "POP 화면 그룹 수정에 실패했습니다.", error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 화면 그룹 삭제
|
||||
export const deletePopScreenGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 기존 그룹 확인
|
||||
let checkQuery = `SELECT * FROM screen_groups WHERE id = $1`;
|
||||
const checkParams: any[] = [id];
|
||||
if (companyCode !== "*") {
|
||||
checkQuery += ` AND company_code = $2`;
|
||||
checkParams.push(companyCode);
|
||||
}
|
||||
|
||||
const existing = await pool.query(checkQuery, checkParams);
|
||||
if (existing.rows.length === 0) {
|
||||
return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
// POP 그룹인지 확인
|
||||
if (!existing.rows[0].hierarchy_path?.startsWith("POP")) {
|
||||
return res.status(400).json({ success: false, message: "POP 그룹만 삭제할 수 있습니다." });
|
||||
}
|
||||
|
||||
// 하위 그룹 확인
|
||||
const childCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM screen_groups WHERE parent_group_id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (parseInt(childCheck.rows[0].count) > 0) {
|
||||
return res.status(400).json({ success: false, message: "하위 그룹이 있어 삭제할 수 없습니다." });
|
||||
}
|
||||
|
||||
// 연결된 화면 확인
|
||||
const screenCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM screen_group_screens WHERE group_id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (parseInt(screenCheck.rows[0].count) > 0) {
|
||||
return res.status(400).json({ success: false, message: "그룹에 연결된 화면이 있어 삭제할 수 없습니다." });
|
||||
}
|
||||
|
||||
// 삭제
|
||||
await pool.query(`DELETE FROM screen_groups WHERE id = $1`, [id]);
|
||||
|
||||
logger.info("POP 화면 그룹 삭제", { groupId: id, companyCode });
|
||||
|
||||
res.json({ success: true, message: "POP 화면 그룹이 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("POP 화면 그룹 삭제 실패:", error);
|
||||
res.status(500).json({ success: false, message: "POP 화면 그룹 삭제에 실패했습니다.", error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 루트 그룹 확보 (없으면 자동 생성)
|
||||
export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// POP 루트 그룹 확인
|
||||
const checkQuery = `
|
||||
SELECT * FROM screen_groups
|
||||
WHERE hierarchy_path = 'POP' AND company_code = $1
|
||||
`;
|
||||
const existing = await pool.query(checkQuery, [companyCode]);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
return res.json({ success: true, data: existing.rows[0], message: "POP 루트 그룹이 이미 존재합니다." });
|
||||
}
|
||||
|
||||
// 없으면 생성 (writer 컬럼 사용, is_active는 'Y' - 기존 스키마에 맞춤)
|
||||
const insertQuery = `
|
||||
INSERT INTO screen_groups (
|
||||
group_name, group_code, hierarchy_path, company_code,
|
||||
description, display_order, is_active, writer
|
||||
) VALUES ('POP 화면', 'POP', 'POP', $1, 'POP 화면 관리 루트', 0, 'Y', $2)
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await pool.query(insertQuery, [companyCode, req.user?.userId || ""]);
|
||||
|
||||
logger.info("POP 루트 그룹 생성", { groupId: result.rows[0].id, companyCode });
|
||||
|
||||
res.json({ success: true, data: result.rows[0], message: "POP 루트 그룹이 생성되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("POP 루트 그룹 확보 실패:", error);
|
||||
res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message });
|
||||
}
|
||||
};
|
||||
|
|
@ -732,6 +732,217 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) =>
|
|||
}
|
||||
};
|
||||
|
||||
// 레이어 목록 조회
|
||||
export const getScreenLayers = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const layers = await screenManagementService.getScreenLayers(parseInt(screenId), companyCode);
|
||||
res.json({ success: true, data: layers });
|
||||
} catch (error) {
|
||||
console.error("레이어 목록 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "레이어 목록 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 레이어 레이아웃 조회
|
||||
export const getLayerLayout = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, layerId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const layout = await screenManagementService.getLayerLayout(parseInt(screenId), parseInt(layerId), companyCode);
|
||||
res.json({ success: true, data: layout });
|
||||
} catch (error) {
|
||||
console.error("레이어 레이아웃 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "레이어 레이아웃 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 레이어 삭제
|
||||
export const deleteLayer = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, layerId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
await screenManagementService.deleteLayer(parseInt(screenId), parseInt(layerId), companyCode);
|
||||
res.json({ success: true, message: "레이어가 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
console.error("레이어 삭제 실패:", error);
|
||||
res.status(400).json({ success: false, message: error.message || "레이어 삭제에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 레이어 조건 설정 업데이트
|
||||
export const updateLayerCondition = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, layerId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { conditionConfig, layerName } = req.body;
|
||||
await screenManagementService.updateLayerCondition(
|
||||
parseInt(screenId), parseInt(layerId), companyCode, conditionConfig, layerName
|
||||
);
|
||||
res.json({ success: true, message: "레이어 조건이 업데이트되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("레이어 조건 업데이트 실패:", error);
|
||||
res.status(500).json({ success: false, message: "레이어 조건 업데이트에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 조건부 영역(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 레이아웃 관리 (모바일/태블릿)
|
||||
// ========================================
|
||||
|
||||
// POP 레이아웃 조회
|
||||
export const getLayoutPop = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode, userType } = req.user as any;
|
||||
const layout = await screenManagementService.getLayoutPop(
|
||||
parseInt(screenId),
|
||||
companyCode,
|
||||
userType
|
||||
);
|
||||
res.json({ success: true, data: layout });
|
||||
} catch (error) {
|
||||
console.error("POP 레이아웃 조회 실패:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, message: "POP 레이아웃 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 레이아웃 저장
|
||||
export const saveLayoutPop = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const layoutData = req.body;
|
||||
|
||||
await screenManagementService.saveLayoutPop(
|
||||
parseInt(screenId),
|
||||
layoutData,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
res.json({ success: true, message: "POP 레이아웃이 저장되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("POP 레이아웃 저장 실패:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, message: "POP 레이아웃 저장에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 레이아웃 삭제
|
||||
export const deleteLayoutPop = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
|
||||
await screenManagementService.deleteLayoutPop(
|
||||
parseInt(screenId),
|
||||
companyCode
|
||||
);
|
||||
res.json({ success: true, message: "POP 레이아웃이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("POP 레이아웃 삭제 실패:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, message: "POP 레이아웃 삭제에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// POP 레이아웃 존재하는 화면 ID 목록 조회
|
||||
export const getScreenIdsWithPopLayout = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { companyCode } = req.user as any;
|
||||
|
||||
const screenIds = await screenManagementService.getScreenIdsWithPopLayout(companyCode);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: screenIds,
|
||||
count: screenIds.length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("POP 레이아웃 화면 ID 목록 조회 실패:", error);
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, message: "POP 레이아웃 화면 ID 목록 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 코드 자동 생성
|
||||
export const generateScreenCode = async (
|
||||
req: AuthenticatedRequest,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@ import {
|
|||
syncMenuToScreenGroupsController,
|
||||
getSyncStatusController,
|
||||
syncAllCompaniesController,
|
||||
// POP 전용 화면 그룹
|
||||
getPopScreenGroups,
|
||||
createPopScreenGroup,
|
||||
updatePopScreenGroup,
|
||||
deletePopScreenGroup,
|
||||
ensurePopRootGroup,
|
||||
} from "../controllers/screenGroupController";
|
||||
|
||||
const router = Router();
|
||||
|
|
@ -106,6 +112,15 @@ router.post("/sync/menu-to-screen", syncMenuToScreenGroupsController);
|
|||
// 전체 회사 동기화 (최고 관리자만)
|
||||
router.post("/sync/all", syncAllCompaniesController);
|
||||
|
||||
// ============================================================
|
||||
// POP 전용 화면 그룹 (hierarchy_path LIKE 'POP/%')
|
||||
// ============================================================
|
||||
router.get("/pop/groups", getPopScreenGroups);
|
||||
router.post("/pop/groups", createPopScreenGroup);
|
||||
router.put("/pop/groups/:id", updatePopScreenGroup);
|
||||
router.delete("/pop/groups/:id", deletePopScreenGroup);
|
||||
router.post("/pop/ensure-root", ensurePopRootGroup);
|
||||
|
||||
export default router;
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ import {
|
|||
getLayoutV1,
|
||||
getLayoutV2,
|
||||
saveLayoutV2,
|
||||
getLayoutPop,
|
||||
saveLayoutPop,
|
||||
deleteLayoutPop,
|
||||
getScreenIdsWithPopLayout,
|
||||
generateScreenCode,
|
||||
generateMultipleScreenCodes,
|
||||
assignScreenToMenu,
|
||||
|
|
@ -38,6 +42,15 @@ import {
|
|||
copyCategoryMapping,
|
||||
copyTableTypeColumns,
|
||||
copyCascadingRelation,
|
||||
getScreenLayers,
|
||||
getLayerLayout,
|
||||
deleteLayer,
|
||||
updateLayerCondition,
|
||||
getScreenZones,
|
||||
createZone,
|
||||
updateZone,
|
||||
deleteZone,
|
||||
addLayerToZone,
|
||||
} from "../controllers/screenManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -84,6 +97,25 @@ router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url +
|
|||
router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
|
||||
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
|
||||
|
||||
// 레이어 관리
|
||||
router.get("/screens/:screenId/layers", getScreenLayers); // 레이어 목록
|
||||
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: 모바일/태블릿용 레이아웃 저장
|
||||
router.delete("/screens/:screenId/layout-pop", deleteLayoutPop); // POP: 레이아웃 삭제
|
||||
router.get("/pop-layout-screen-ids", getScreenIdsWithPopLayout); // POP: 레이아웃 존재하는 화면 ID 목록
|
||||
|
||||
// 메뉴-화면 할당 관리
|
||||
router.post("/screens/:screenId/assign-menu", assignScreenToMenu);
|
||||
router.get("/menus/:menuObjid/screens", getScreensByMenu);
|
||||
|
|
|
|||
|
|
@ -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 조건 평가
|
||||
* 다른 테이블에 값이 존재하는지 확인
|
||||
|
|
|
|||
|
|
@ -885,9 +885,9 @@ class NumberingRuleService {
|
|||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
const parts = rule.parts
|
||||
const parts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
// 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리)
|
||||
// placeholder 텍스트는 프론트엔드에서 별도로 표시
|
||||
|
|
@ -982,17 +982,52 @@ class NumberingRuleService {
|
|||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
||||
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
|
||||
const selectedValueStr = String(selectedValue);
|
||||
const mapping = categoryMappings.find((m: any) => {
|
||||
// ID로 매칭
|
||||
let mapping = categoryMappings.find((m: any) => {
|
||||
// ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우)
|
||||
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||
return true;
|
||||
// 라벨로 매칭
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
// valueCode로 매칭 (라벨과 동일할 수 있음)
|
||||
// valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우)
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr)
|
||||
return true;
|
||||
// 라벨로 매칭 (폴백)
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도
|
||||
if (!mapping) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [catTableName, catColumnName] = categoryKey.includes(".")
|
||||
? categoryKey.split(".")
|
||||
: [categoryKey, categoryKey];
|
||||
const cvResult = await pool.query(
|
||||
`SELECT value_id, value_code, value_label FROM category_values
|
||||
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[catTableName, catColumnName, selectedValueStr]
|
||||
);
|
||||
if (cvResult.rows.length > 0) {
|
||||
const resolvedId = cvResult.rows[0].value_id;
|
||||
const resolvedLabel = cvResult.rows[0].value_label;
|
||||
mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
|
||||
if (m.categoryValueLabel === resolvedLabel) return true;
|
||||
return false;
|
||||
});
|
||||
if (mapping) {
|
||||
logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", {
|
||||
valueCode: selectedValueStr,
|
||||
resolvedId,
|
||||
resolvedLabel,
|
||||
format: mapping.format,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (lookupError: any) {
|
||||
logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping) {
|
||||
logger.info("카테고리 매핑 적용", {
|
||||
selectedValue,
|
||||
|
|
@ -1016,7 +1051,7 @@ class NumberingRuleService {
|
|||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
const previewCode = parts.join(rule.separator || "");
|
||||
logger.info("코드 미리보기 생성", {
|
||||
|
|
@ -1059,9 +1094,9 @@ class NumberingRuleService {
|
|||
if (manualParts.length > 0 && userInputCode) {
|
||||
// 프리뷰 코드를 생성해서 ____ 위치 파악
|
||||
// 🔧 category 파트도 처리하여 올바른 템플릿 생성
|
||||
const previewParts = rule.parts
|
||||
const previewParts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return "____";
|
||||
}
|
||||
|
|
@ -1077,36 +1112,57 @@ class NumberingRuleService {
|
|||
return "DATEPART"; // 날짜 자리 표시
|
||||
case "category": {
|
||||
// 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용
|
||||
const categoryKey = autoConfig.categoryKey;
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
const catKey2 = autoConfig.categoryKey;
|
||||
const catMappings2 = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!categoryKey || !formData) {
|
||||
if (!catKey2 || !formData) {
|
||||
return "CATEGORY"; // 폴백
|
||||
}
|
||||
|
||||
const columnName = categoryKey.includes(".")
|
||||
? categoryKey.split(".")[1]
|
||||
: categoryKey;
|
||||
const selectedValue = formData[columnName];
|
||||
const colName2 = catKey2.includes(".")
|
||||
? catKey2.split(".")[1]
|
||||
: catKey2;
|
||||
const selVal2 = formData[colName2];
|
||||
|
||||
if (!selectedValue) {
|
||||
if (!selVal2) {
|
||||
return "CATEGORY"; // 폴백
|
||||
}
|
||||
|
||||
const selectedValueStr = String(selectedValue);
|
||||
const mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||
return true;
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
const selValStr2 = String(selVal2);
|
||||
let catMapping2 = catMappings2.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selValStr2) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true;
|
||||
if (m.categoryValueLabel === selValStr2) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return mapping?.format || "CATEGORY";
|
||||
// valueCode → valueId 역변환 시도
|
||||
if (!catMapping2) {
|
||||
try {
|
||||
const pool2 = getPool();
|
||||
const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2];
|
||||
const cvr2 = await pool2.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[ct2, cc2, selValStr2]
|
||||
);
|
||||
if (cvr2.rows.length > 0) {
|
||||
const rid2 = cvr2.rows[0].value_id;
|
||||
const rlabel2 = cvr2.rows[0].value_label;
|
||||
catMapping2 = catMappings2.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(rid2)) return true;
|
||||
if (m.categoryValueLabel === rlabel2) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return catMapping2?.format || "CATEGORY";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
const separator = rule.separator || "";
|
||||
const previewTemplate = previewParts.join(separator);
|
||||
|
|
@ -1150,9 +1206,9 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
let manualPartIndex = 0;
|
||||
const parts = rule.parts
|
||||
const parts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
// 추출된 수동 입력 값 사용, 없으면 기본값 사용
|
||||
const manualValue =
|
||||
|
|
@ -1267,28 +1323,53 @@ class NumberingRuleService {
|
|||
|
||||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
||||
const selectedValueStr = String(selectedValue);
|
||||
const mapping = categoryMappings.find((m: any) => {
|
||||
// ID로 매칭
|
||||
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||
return true;
|
||||
// 라벨로 매칭
|
||||
let allocMapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (mapping) {
|
||||
// valueCode → valueId 역변환 시도
|
||||
if (!allocMapping) {
|
||||
try {
|
||||
const pool3 = getPool();
|
||||
const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
|
||||
const cvr3 = await pool3.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[ct3, cc3, selectedValueStr]
|
||||
);
|
||||
if (cvr3.rows.length > 0) {
|
||||
const rid3 = cvr3.rows[0].value_id;
|
||||
const rlabel3 = cvr3.rows[0].value_label;
|
||||
allocMapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(rid3)) return true;
|
||||
if (m.categoryValueLabel === rlabel3) return true;
|
||||
return false;
|
||||
});
|
||||
if (allocMapping) {
|
||||
logger.info("allocateCode: 카테고리 매핑 역변환 성공", {
|
||||
valueCode: selectedValueStr, resolvedId: rid3, format: allocMapping.format,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (allocMapping) {
|
||||
logger.info("allocateCode: 카테고리 매핑 적용", {
|
||||
selectedValue,
|
||||
format: mapping.format,
|
||||
categoryValueLabel: mapping.categoryValueLabel,
|
||||
format: allocMapping.format,
|
||||
categoryValueLabel: allocMapping.categoryValueLabel,
|
||||
});
|
||||
return mapping.format || "";
|
||||
return allocMapping.format || "";
|
||||
}
|
||||
|
||||
logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", {
|
||||
selectedValue,
|
||||
availableMappings: categoryMappings.map((m: any) => ({
|
||||
id: m.categoryValueId,
|
||||
code: m.categoryValueCode,
|
||||
label: m.categoryValueLabel,
|
||||
})),
|
||||
});
|
||||
|
|
@ -1299,7 +1380,7 @@ class NumberingRuleService {
|
|||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
const allocatedCode = parts.join(rule.separator || "");
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -4245,16 +4245,16 @@ export class ScreenManagementService {
|
|||
},
|
||||
);
|
||||
|
||||
// V2 레이아웃 저장 (UPSERT)
|
||||
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, 1, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[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";
|
||||
|
|
@ -5073,67 +5072,94 @@ export class ScreenManagementService {
|
|||
|
||||
let layout: { layout_data: any } | null = null;
|
||||
|
||||
// 🆕 기본 레이어(layer_id=1)를 우선 로드
|
||||
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
|
||||
if (isSuperAdmin) {
|
||||
// 1. 화면 정의의 회사 코드로 레이아웃 조회
|
||||
// 1. 화면 정의의 회사 코드 + 기본 레이어
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||
[screenId, existingScreen.company_code],
|
||||
);
|
||||
|
||||
// 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회
|
||||
// 2. 기본 레이어 없으면 layer_id 조건 없이 조회 (하위 호환)
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId, existingScreen.company_code],
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1
|
||||
ORDER BY updated_at DESC
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 일반 사용자: 기존 로직 (회사별 우선, 없으면 공통(*) 조회)
|
||||
// 일반 사용자: 회사별 우선 + 기본 레이어
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 기본 레이어 없으면 layer_id 조건 없이 (하위 호환)
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
|
||||
if (!layout && companyCode !== "*") {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
WHERE screen_id = $1 AND company_code = '*'
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!layout) {
|
||||
console.log(`V2 레이아웃 없음: screen_id=${screenId}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
|
||||
);
|
||||
|
||||
return layout.layout_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃 저장 (1 레코드 방식)
|
||||
* - screen_layouts_v2 테이블에 화면당 1개 레코드 저장
|
||||
* - layout_data JSON에 모든 컴포넌트 포함
|
||||
* V2 레이아웃 저장 (레이어별 저장)
|
||||
* - screen_layouts_v2 테이블에 화면당 레이어별 1개 레코드 저장
|
||||
* - layout_data JSON에 해당 레이어의 컴포넌트 포함
|
||||
*/
|
||||
async saveLayoutV2(
|
||||
screenId: number,
|
||||
layoutData: any,
|
||||
companyCode: string,
|
||||
): Promise<void> {
|
||||
console.log(`=== V2 레이아웃 저장 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
|
||||
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;
|
||||
|
||||
|
||||
|
||||
// 권한 확인
|
||||
const screens = await query<{ company_code: string | null }>(
|
||||
|
|
@ -5151,22 +5177,666 @@ export class ScreenManagementService {
|
|||
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// 버전 정보 추가 (updatedAt은 DB 컬럼 updated_at으로 관리)
|
||||
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
|
||||
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, ...pureLayoutData } = layoutData;
|
||||
const dataToSave = {
|
||||
version: "2.0",
|
||||
...layoutData
|
||||
...pureLayoutData,
|
||||
};
|
||||
|
||||
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)],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면의 모든 레이어 목록 조회
|
||||
* 레이어가 없으면 기본 레이어를 자동 생성
|
||||
*/
|
||||
async getScreenLayers(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
): Promise<any[]> {
|
||||
let layers;
|
||||
|
||||
if (companyCode === "*") {
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1
|
||||
ORDER BY layer_id`,
|
||||
[screenId],
|
||||
);
|
||||
} else {
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 레이어가 없으면 공통(*) 레이어 조회
|
||||
if (layers.length === 0 && companyCode !== "*") {
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'
|
||||
ORDER BY layer_id`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 레이어가 없으면 기본 레이어 자동 생성
|
||||
if (layers.length === 0) {
|
||||
const defaultLayout = JSON.stringify({ version: "2.0", components: [] });
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, 1, '기본 레이어', $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id) DO NOTHING`,
|
||||
[screenId, companyCode, defaultLayout],
|
||||
);
|
||||
console.log(`기본 레이어 자동 생성: screen_id=${screenId}, company_code=${companyCode}`);
|
||||
|
||||
// 다시 조회
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 레이어의 레이아웃 조회
|
||||
*/
|
||||
async getLayerLayout(
|
||||
screenId: number,
|
||||
layerId: number,
|
||||
companyCode: string,
|
||||
): Promise<any> {
|
||||
let layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
[screenId, companyCode, layerId],
|
||||
);
|
||||
|
||||
// 회사별 레이어가 없으면 공통(*) 조회
|
||||
if (!layout && companyCode !== "*") {
|
||||
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*' AND layer_id = $2`,
|
||||
[screenId, layerId],
|
||||
);
|
||||
}
|
||||
|
||||
if (!layout) return null;
|
||||
|
||||
return {
|
||||
...layout.layout_data,
|
||||
layerId,
|
||||
layerName: layout.layer_name,
|
||||
conditionConfig: layout.condition_config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이어 삭제
|
||||
*/
|
||||
async deleteLayer(
|
||||
screenId: number,
|
||||
layerId: number,
|
||||
companyCode: string,
|
||||
): Promise<void> {
|
||||
if (layerId === 1) {
|
||||
throw new Error("기본 레이어는 삭제할 수 없습니다.");
|
||||
}
|
||||
|
||||
await query(
|
||||
`DELETE FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
[screenId, companyCode, layerId],
|
||||
);
|
||||
|
||||
console.log(`레이어 삭제 완료: screen_id=${screenId}, layer_id=${layerId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이어 조건 설정 업데이트
|
||||
*/
|
||||
async updateLayerCondition(
|
||||
screenId: number,
|
||||
layerId: number,
|
||||
companyCode: string,
|
||||
conditionConfig: any,
|
||||
layerName?: string,
|
||||
): Promise<void> {
|
||||
const setClauses = ['condition_config = $4', 'updated_at = NOW()'];
|
||||
const params: any[] = [screenId, companyCode, layerId, conditionConfig ? JSON.stringify(conditionConfig) : null];
|
||||
|
||||
if (layerName) {
|
||||
setClauses.push(`layer_name = $${params.length + 1}`);
|
||||
params.push(layerName);
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE screen_layouts_v2 SET ${setClauses.join(', ')}
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 조건부 영역(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모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* POP v1 → v2 마이그레이션 (백엔드)
|
||||
* - 단일 sections 배열 → 4모드별 layouts + 공유 sections/components
|
||||
*/
|
||||
private migratePopV1ToV2(v1Data: any): any {
|
||||
console.log("POP v1 → v2 마이그레이션 시작");
|
||||
|
||||
// 기본 v2 구조
|
||||
const v2Data: any = {
|
||||
version: "pop-2.0",
|
||||
layouts: {
|
||||
tablet_landscape: { sectionPositions: {}, componentPositions: {} },
|
||||
tablet_portrait: { sectionPositions: {}, componentPositions: {} },
|
||||
mobile_landscape: { sectionPositions: {}, componentPositions: {} },
|
||||
mobile_portrait: { sectionPositions: {}, componentPositions: {} },
|
||||
},
|
||||
sections: {},
|
||||
components: {},
|
||||
dataFlow: {
|
||||
sectionConnections: [],
|
||||
},
|
||||
settings: {
|
||||
touchTargetMin: 48,
|
||||
mode: "normal",
|
||||
canvasGrid: v1Data.canvasGrid || { columns: 24, rowHeight: 20, gap: 4 },
|
||||
},
|
||||
metadata: v1Data.metadata,
|
||||
};
|
||||
|
||||
// v1 섹션 배열 처리
|
||||
const sections = v1Data.sections || [];
|
||||
const modeKeys = ["tablet_landscape", "tablet_portrait", "mobile_landscape", "mobile_portrait"];
|
||||
|
||||
for (const section of sections) {
|
||||
// 섹션 정의 생성
|
||||
v2Data.sections[section.id] = {
|
||||
id: section.id,
|
||||
label: section.label,
|
||||
componentIds: (section.components || []).map((c: any) => c.id),
|
||||
innerGrid: section.innerGrid || { columns: 3, rows: 3, gap: 4 },
|
||||
style: section.style,
|
||||
};
|
||||
|
||||
// 섹션 위치 복사 (4모드 모두 동일)
|
||||
const sectionPos = section.grid || { col: 1, row: 1, colSpan: 3, rowSpan: 4 };
|
||||
for (const mode of modeKeys) {
|
||||
v2Data.layouts[mode].sectionPositions[section.id] = { ...sectionPos };
|
||||
}
|
||||
|
||||
// 컴포넌트별 처리
|
||||
for (const comp of section.components || []) {
|
||||
// 컴포넌트 정의 생성
|
||||
v2Data.components[comp.id] = {
|
||||
id: comp.id,
|
||||
type: comp.type,
|
||||
label: comp.label,
|
||||
dataBinding: comp.dataBinding,
|
||||
style: comp.style,
|
||||
config: comp.config,
|
||||
};
|
||||
|
||||
// 컴포넌트 위치 복사 (4모드 모두 동일)
|
||||
const compPos = comp.grid || { col: 1, row: 1, colSpan: 1, rowSpan: 1 };
|
||||
for (const mode of modeKeys) {
|
||||
v2Data.layouts[mode].componentPositions[comp.id] = { ...compPos };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sectionCount = Object.keys(v2Data.sections).length;
|
||||
const componentCount = Object.keys(v2Data.components).length;
|
||||
console.log(`POP v1 → v2 마이그레이션 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||
|
||||
return v2Data;
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 레이아웃 조회
|
||||
* - screen_layouts_pop 테이블에서 화면당 1개 레코드 조회
|
||||
* - v1 데이터는 자동으로 v2로 마이그레이션하여 반환
|
||||
*/
|
||||
async getLayoutPop(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
userType?: string,
|
||||
): Promise<any | null> {
|
||||
console.log(`=== POP 레이아웃 로드 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`);
|
||||
|
||||
// SUPER_ADMIN 여부 확인
|
||||
const isSuperAdmin = userType === "SUPER_ADMIN";
|
||||
|
||||
// 권한 확인
|
||||
const screens = await query<{
|
||||
company_code: string | null;
|
||||
table_name: string | null;
|
||||
}>(
|
||||
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
if (screens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingScreen = screens[0];
|
||||
|
||||
// SUPER_ADMIN이 아니고 회사 코드가 다르면 권한 없음
|
||||
if (!isSuperAdmin && companyCode !== "*" && existingScreen.company_code !== companyCode) {
|
||||
throw new Error("이 화면의 POP 레이아웃을 조회할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
let layout: { layout_data: any } | null = null;
|
||||
|
||||
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
|
||||
if (isSuperAdmin) {
|
||||
// 1. 화면 정의의 회사 코드로 레이아웃 조회
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, existingScreen.company_code],
|
||||
);
|
||||
|
||||
// 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 일반 사용자: 회사별 우선, 없으면 공통(*) 조회
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
|
||||
if (!layout && companyCode !== "*") {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_pop
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!layout) {
|
||||
console.log(`POP 레이아웃 없음: screen_id=${screenId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const layoutData = layout.layout_data;
|
||||
|
||||
// v1 → v2 자동 마이그레이션
|
||||
if (layoutData && layoutData.version === "pop-1.0") {
|
||||
console.log("POP v1 레이아웃 감지, v2로 마이그레이션");
|
||||
return this.migratePopV1ToV2(layoutData);
|
||||
}
|
||||
|
||||
// v2 또는 버전 태그 없는 경우 (버전 태그 없으면 sections 구조 확인)
|
||||
if (layoutData && !layoutData.version && layoutData.sections && Array.isArray(layoutData.sections)) {
|
||||
console.log("버전 태그 없는 v1 레이아웃 감지, v2로 마이그레이션");
|
||||
return this.migratePopV1ToV2({ ...layoutData, version: "pop-1.0" });
|
||||
}
|
||||
|
||||
// v2 레이아웃 그대로 반환
|
||||
const sectionCount = layoutData?.sections ? Object.keys(layoutData.sections).length : 0;
|
||||
const componentCount = layoutData?.components ? Object.keys(layoutData.components).length : 0;
|
||||
console.log(`POP v2 레이아웃 로드 완료: ${sectionCount}개 섹션, ${componentCount}개 컴포넌트`);
|
||||
|
||||
return layoutData;
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 레이아웃 저장
|
||||
* - screen_layouts_pop 테이블에 화면당 1개 레코드 저장
|
||||
* - v3 형식 지원 (version: "pop-3.0", 섹션 제거)
|
||||
* - v2/v1 하위 호환
|
||||
*/
|
||||
async saveLayoutPop(
|
||||
screenId: number,
|
||||
layoutData: any,
|
||||
companyCode: string,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||
|
||||
// v5 그리드 레이아웃만 지원
|
||||
const componentCount = Object.keys(layoutData.components || {}).length;
|
||||
console.log(`컴포넌트: ${componentCount}개`);
|
||||
|
||||
// v5 형식 검증
|
||||
if (layoutData.version && layoutData.version !== "pop-5.0") {
|
||||
console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`);
|
||||
}
|
||||
|
||||
// 권한 확인
|
||||
const screens = await query<{ company_code: string | null }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
if (screens.length === 0) {
|
||||
throw new Error("화면을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const existingScreen = screens[0];
|
||||
|
||||
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
|
||||
throw new Error("이 화면의 POP 레이아웃을 저장할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// SUPER_ADMIN인 경우: 화면 정의의 company_code로 저장 (로드와 동일하게)
|
||||
const targetCompanyCode = companyCode === "*"
|
||||
? (existingScreen.company_code || "*")
|
||||
: companyCode;
|
||||
|
||||
console.log(`저장 대상 company_code: ${targetCompanyCode} (사용자: ${companyCode}, 화면: ${existingScreen.company_code})`);
|
||||
|
||||
// v5 그리드 레이아웃으로 저장 (단일 버전)
|
||||
const dataToSave = {
|
||||
...layoutData,
|
||||
version: "pop-5.0",
|
||||
};
|
||||
console.log(`저장: gridConfig=${JSON.stringify(dataToSave.gridConfig || 'default')}`)
|
||||
|
||||
// UPSERT (있으면 업데이트, 없으면 삽입)
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
`INSERT INTO screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
|
||||
VALUES ($1, $2, $3, NOW(), NOW(), $4, $4)
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[screenId, companyCode, JSON.stringify(dataToSave)],
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW(), updated_by = $4`,
|
||||
[screenId, targetCompanyCode, JSON.stringify(dataToSave), userId || null],
|
||||
);
|
||||
|
||||
console.log(`V2 레이아웃 저장 완료`);
|
||||
console.log(`POP 레이아웃 저장 완료 (version: ${dataToSave.version}, company: ${targetCompanyCode})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 레이아웃이 존재하는 화면 ID 목록 조회
|
||||
* - 옵션 B: POP 레이아웃 존재 여부로 화면 구분
|
||||
*/
|
||||
async getScreenIdsWithPopLayout(
|
||||
companyCode: string,
|
||||
): Promise<number[]> {
|
||||
console.log(`=== POP 레이아웃 존재 화면 ID 조회 ===`);
|
||||
console.log(`회사 코드: ${companyCode}`);
|
||||
|
||||
let result: { screen_id: number }[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 POP 레이아웃 조회
|
||||
result = await query<{ screen_id: number }>(
|
||||
`SELECT DISTINCT screen_id FROM screen_layouts_pop`,
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
// 일반 회사: 해당 회사 또는 공통(*) 레이아웃 조회
|
||||
result = await query<{ screen_id: number }>(
|
||||
`SELECT DISTINCT screen_id FROM screen_layouts_pop
|
||||
WHERE company_code = $1 OR company_code = '*'`,
|
||||
[companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
const screenIds = result.map((r) => r.screen_id);
|
||||
console.log(`POP 레이아웃 존재 화면 수: ${screenIds.length}개`);
|
||||
return screenIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* POP 레이아웃 삭제
|
||||
*/
|
||||
async deleteLayoutPop(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
): Promise<boolean> {
|
||||
console.log(`=== POP 레이아웃 삭제 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||
|
||||
// 권한 확인
|
||||
const screens = await query<{ company_code: string | null }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
|
||||
if (screens.length === 0) {
|
||||
throw new Error("화면을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const existingScreen = screens[0];
|
||||
|
||||
if (companyCode !== "*" && existingScreen.company_code !== companyCode) {
|
||||
throw new Error("이 화면의 POP 레이아웃을 삭제할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`DELETE FROM screen_layouts_pop WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
console.log(`POP 레이아웃 삭제 완료`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -0,0 +1,390 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Smartphone,
|
||||
Eye,
|
||||
Settings,
|
||||
LayoutGrid,
|
||||
GitBranch,
|
||||
} from "lucide-react";
|
||||
import { PopDesigner } from "@/components/pop/designer";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import CreateScreenModal from "@/components/screen/CreateScreenModal";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PopCategoryTree,
|
||||
PopScreenPreview,
|
||||
PopScreenFlowView,
|
||||
PopScreenSettingModal,
|
||||
} from "@/components/pop/management";
|
||||
import { PopScreenGroup } from "@/lib/api/popScreenGroup";
|
||||
|
||||
// ============================================================
|
||||
// 타입 정의
|
||||
// ============================================================
|
||||
|
||||
type Step = "list" | "design";
|
||||
type DevicePreview = "mobile" | "tablet";
|
||||
type RightPanelView = "preview" | "flow";
|
||||
|
||||
// ============================================================
|
||||
// 메인 컴포넌트
|
||||
// ============================================================
|
||||
|
||||
export default function PopScreenManagementPage() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// 단계 및 화면 상태
|
||||
const [currentStep, setCurrentStep] = useState<Step>("list");
|
||||
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [selectedGroup, setSelectedGroup] = useState<PopScreenGroup | null>(null);
|
||||
const [stepHistory, setStepHistory] = useState<Step[]>(["list"]);
|
||||
|
||||
// 화면 데이터
|
||||
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
|
||||
// POP 레이아웃 존재 화면 ID
|
||||
const [popLayoutScreenIds, setPopLayoutScreenIds] = useState<Set<number>>(new Set());
|
||||
|
||||
// UI 상태
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
|
||||
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
|
||||
const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview");
|
||||
|
||||
// ============================================================
|
||||
// 데이터 로드
|
||||
// ============================================================
|
||||
|
||||
const loadScreens = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [result, popScreenIds] = await Promise.all([
|
||||
screenApi.getScreens({ page: 1, size: 1000, searchTerm: "" }),
|
||||
screenApi.getScreenIdsWithPopLayout(),
|
||||
]);
|
||||
|
||||
if (result.data && result.data.length > 0) {
|
||||
setScreens(result.data);
|
||||
}
|
||||
setPopLayoutScreenIds(new Set(popScreenIds));
|
||||
} catch (error) {
|
||||
console.error("POP 화면 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadScreens();
|
||||
}, [loadScreens]);
|
||||
|
||||
// 화면 목록 새로고침 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleScreenListRefresh = () => {
|
||||
console.log("POP 화면 목록 새로고침 이벤트 수신");
|
||||
loadScreens();
|
||||
};
|
||||
|
||||
window.addEventListener("screen-list-refresh", handleScreenListRefresh);
|
||||
return () => {
|
||||
window.removeEventListener("screen-list-refresh", handleScreenListRefresh);
|
||||
};
|
||||
}, [loadScreens]);
|
||||
|
||||
// URL 쿼리 파라미터로 화면 디자이너 자동 열기
|
||||
useEffect(() => {
|
||||
const openDesignerId = searchParams.get("openDesigner");
|
||||
if (openDesignerId && screens.length > 0) {
|
||||
const screenId = parseInt(openDesignerId, 10);
|
||||
const targetScreen = screens.find((s) => s.screenId === screenId);
|
||||
if (targetScreen) {
|
||||
setSelectedScreen(targetScreen);
|
||||
setCurrentStep("design");
|
||||
setStepHistory(["list", "design"]);
|
||||
}
|
||||
}
|
||||
}, [searchParams, screens]);
|
||||
|
||||
// ============================================================
|
||||
// 핸들러
|
||||
// ============================================================
|
||||
|
||||
const goToNextStep = (nextStep: Step) => {
|
||||
setStepHistory((prev) => [...prev, nextStep]);
|
||||
setCurrentStep(nextStep);
|
||||
};
|
||||
|
||||
const goToStep = (step: Step) => {
|
||||
setCurrentStep(step);
|
||||
const stepIndex = stepHistory.findIndex((s) => s === step);
|
||||
if (stepIndex !== -1) {
|
||||
setStepHistory(stepHistory.slice(0, stepIndex + 1));
|
||||
}
|
||||
};
|
||||
|
||||
// 화면 선택
|
||||
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||
setSelectedScreen(screen);
|
||||
setSelectedGroup(null);
|
||||
};
|
||||
|
||||
// 그룹 선택
|
||||
const handleGroupSelect = (group: PopScreenGroup | null) => {
|
||||
setSelectedGroup(group);
|
||||
// 그룹 선택 시 화면 선택 해제하지 않음 (미리보기 유지)
|
||||
};
|
||||
|
||||
// 화면 디자인 모드 진입
|
||||
const handleDesignScreen = (screen: ScreenDefinition) => {
|
||||
setSelectedScreen(screen);
|
||||
goToNextStep("design");
|
||||
};
|
||||
|
||||
// POP 화면 미리보기 (새 탭에서 열기)
|
||||
const handlePreviewScreen = (screen: ScreenDefinition) => {
|
||||
const previewUrl = `/pop/screens/${screen.screenId}?preview=true&device=${devicePreview}`;
|
||||
window.open(previewUrl, "_blank", "width=800,height=900");
|
||||
};
|
||||
|
||||
// 화면 설정 모달 열기
|
||||
const handleOpenSettings = () => {
|
||||
if (selectedScreen) {
|
||||
setIsSettingModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 필터링된 데이터
|
||||
// ============================================================
|
||||
|
||||
// POP 레이아웃이 있는 화면만 필터링
|
||||
const popScreens = screens.filter((screen) => popLayoutScreenIds.has(screen.screenId));
|
||||
|
||||
// 검색어 필터링
|
||||
const filteredScreens = popScreens.filter((screen) => {
|
||||
if (!searchTerm) return true;
|
||||
return (
|
||||
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const popScreenCount = popLayoutScreenIds.size;
|
||||
|
||||
// ============================================================
|
||||
// 디자인 모드
|
||||
// ============================================================
|
||||
|
||||
const isDesignMode = currentStep === "design";
|
||||
|
||||
if (isDesignMode && selectedScreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-background">
|
||||
<PopDesigner
|
||||
selectedScreen={selectedScreen}
|
||||
onBackToList={() => goToStep("list")}
|
||||
onScreenUpdate={(updatedFields) => {
|
||||
setSelectedScreen({
|
||||
...selectedScreen,
|
||||
...updatedFields,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 목록 모드 렌더링
|
||||
// ============================================================
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background overflow-hidden">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="shrink-0 border-b bg-background px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-bold tracking-tight">POP 화면 관리</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
모바일/태블릿
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
POP 화면을 카테고리별로 관리하고 모바일/태블릿에 최적화된 화면을 설계합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" onClick={loadScreens}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
새 POP 화면
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
{popScreenCount === 0 ? (
|
||||
// POP 화면이 없을 때 빈 상태 표시
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center p-8">
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
<Smartphone className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">POP 화면이 없습니다</h3>
|
||||
<p className="text-sm text-muted-foreground mb-6 max-w-md">
|
||||
아직 생성된 POP 화면이 없습니다.
|
||||
<br />
|
||||
"새 POP 화면" 버튼을 클릭하여 모바일/태블릿용 화면을 만들어보세요.
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateOpen(true)} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
새 POP 화면 만들기
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* 왼쪽: 카테고리 트리 + 화면 목록 */}
|
||||
<div className="w-[320px] min-w-[280px] max-w-[400px] flex flex-col border-r bg-background">
|
||||
{/* 검색 */}
|
||||
<div className="shrink-0 p-3 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="POP 화면 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-muted-foreground">POP 화면</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{popScreenCount}개
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 트리 */}
|
||||
<PopCategoryTree
|
||||
screens={filteredScreens}
|
||||
selectedScreen={selectedScreen}
|
||||
onScreenSelect={handleScreenSelect}
|
||||
onScreenDesign={handleDesignScreen}
|
||||
onGroupSelect={handleGroupSelect}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 미리보기 / 화면 흐름 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 오른쪽 패널 헤더 */}
|
||||
<div className="shrink-0 px-4 py-2 border-b bg-background flex items-center justify-between">
|
||||
<Tabs value={rightPanelView} onValueChange={(v) => setRightPanelView(v as RightPanelView)}>
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="preview" className="h-7 px-3 text-xs gap-1.5">
|
||||
<LayoutGrid className="h-3.5 w-3.5" />
|
||||
미리보기
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="flow" className="h-7 px-3 text-xs gap-1.5">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
화면 흐름
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{selectedScreen && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => handlePreviewScreen(selectedScreen)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||
새 탭
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={handleOpenSettings}
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5 mr-1" />
|
||||
설정
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7 px-3 text-xs"
|
||||
onClick={() => handleDesignScreen(selectedScreen)}
|
||||
>
|
||||
설계
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 패널 콘텐츠 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{rightPanelView === "preview" ? (
|
||||
<PopScreenPreview screen={selectedScreen} className="h-full" />
|
||||
) : (
|
||||
<PopScreenFlowView screen={selectedScreen} className="h-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 화면 생성 모달 */}
|
||||
<CreateScreenModal
|
||||
open={isCreateOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsCreateOpen(open);
|
||||
if (!open) loadScreens();
|
||||
}}
|
||||
onCreated={() => {
|
||||
setIsCreateOpen(false);
|
||||
loadScreens();
|
||||
}}
|
||||
isPop={true}
|
||||
/>
|
||||
|
||||
{/* 화면 설정 모달 */}
|
||||
<PopScreenSettingModal
|
||||
open={isSettingModalOpen}
|
||||
onOpenChange={setIsSettingModalOpen}
|
||||
screen={selectedScreen}
|
||||
onSave={(updatedFields) => {
|
||||
if (selectedScreen) {
|
||||
setSelectedScreen({ ...selectedScreen, ...updatedFields });
|
||||
}
|
||||
loadScreens();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,340 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, ArrowLeft, Smartphone, Tablet, RotateCcw, RotateCw } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||
import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
|
||||
import {
|
||||
PopLayoutDataV5,
|
||||
GridMode,
|
||||
isV5Layout,
|
||||
createEmptyPopLayoutV5,
|
||||
GAP_PRESETS,
|
||||
GRID_BREAKPOINTS,
|
||||
detectGridMode,
|
||||
} from "@/components/pop/designer/types/pop-layout";
|
||||
import PopRenderer from "@/components/pop/designer/renderers/PopRenderer";
|
||||
import {
|
||||
useResponsiveModeWithOverride,
|
||||
type DeviceType,
|
||||
} from "@/hooks/useDeviceOrientation";
|
||||
|
||||
// 디바이스별 크기 (너비만, 높이는 콘텐츠 기반)
|
||||
const DEVICE_SIZES: Record<DeviceType, Record<"landscape" | "portrait", { width: number; label: string }>> = {
|
||||
mobile: {
|
||||
landscape: { width: 600, label: "모바일 가로" },
|
||||
portrait: { width: 375, label: "모바일 세로" },
|
||||
},
|
||||
tablet: {
|
||||
landscape: { width: 1024, label: "태블릿 가로" },
|
||||
portrait: { width: 820, label: "태블릿 세로" },
|
||||
},
|
||||
};
|
||||
|
||||
// 모드 키 변환
|
||||
const getModeKey = (device: DeviceType, isLandscape: boolean): GridMode => {
|
||||
if (device === "tablet") {
|
||||
return isLandscape ? "tablet_landscape" : "tablet_portrait";
|
||||
}
|
||||
return isLandscape ? "mobile_landscape" : "mobile_portrait";
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 메인 컴포넌트 (v5 그리드 시스템 전용)
|
||||
// ========================================
|
||||
|
||||
function PopScreenViewPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const screenId = parseInt(params.screenId as string);
|
||||
|
||||
const isPreviewMode = searchParams.get("preview") === "true";
|
||||
|
||||
// 반응형 모드 감지 (화면 크기에 따라 tablet/mobile, landscape/portrait 자동 전환)
|
||||
// 프리뷰 모드에서는 수동 전환 가능
|
||||
const { mode, setDevice, setOrientation, isAutoDetect } = useResponsiveModeWithOverride(
|
||||
isPreviewMode ? "tablet" : undefined,
|
||||
isPreviewMode ? true : undefined
|
||||
);
|
||||
|
||||
// 현재 모드 정보
|
||||
const deviceType = mode.device;
|
||||
const isLandscape = mode.isLandscape;
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
const [screen, setScreen] = useState<ScreenDefinition | null>(null);
|
||||
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 뷰포트 너비 (클라이언트 사이드에서만 계산, 최대 1366px)
|
||||
const [viewportWidth, setViewportWidth] = useState(1024); // 기본값: 태블릿 가로
|
||||
|
||||
// 모드 결정:
|
||||
// - 프리뷰 모드: 수동 선택한 device/orientation 사용
|
||||
// - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치)
|
||||
const currentModeKey = isPreviewMode
|
||||
? getModeKey(deviceType, isLandscape)
|
||||
: detectGridMode(viewportWidth);
|
||||
|
||||
useEffect(() => {
|
||||
const updateViewportWidth = () => {
|
||||
setViewportWidth(Math.min(window.innerWidth, 1366));
|
||||
};
|
||||
|
||||
updateViewportWidth();
|
||||
window.addEventListener("resize", updateViewportWidth);
|
||||
return () => window.removeEventListener("resize", updateViewportWidth);
|
||||
}, []);
|
||||
|
||||
// 화면 및 POP 레이아웃 로드
|
||||
useEffect(() => {
|
||||
const loadScreen = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const screenData = await screenApi.getScreen(screenId);
|
||||
setScreen(screenData);
|
||||
|
||||
try {
|
||||
const popLayout = await screenApi.getLayoutPop(screenId);
|
||||
|
||||
if (popLayout && isV5Layout(popLayout)) {
|
||||
// v5 레이아웃 로드
|
||||
setLayout(popLayout);
|
||||
const componentCount = Object.keys(popLayout.components).length;
|
||||
console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
|
||||
} else if (popLayout) {
|
||||
// 다른 버전 레이아웃은 빈 v5로 처리
|
||||
console.log("[POP] 레거시 레이아웃 감지, 빈 레이아웃으로 시작합니다:", popLayout.version);
|
||||
setLayout(createEmptyPopLayoutV5());
|
||||
} else {
|
||||
console.log("[POP] 레이아웃 없음");
|
||||
setLayout(createEmptyPopLayoutV5());
|
||||
}
|
||||
} catch (layoutError) {
|
||||
console.warn("[POP] 레이아웃 로드 실패:", layoutError);
|
||||
setLayout(createEmptyPopLayoutV5());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[POP] 화면 로드 실패:", error);
|
||||
setError("화면을 불러오는데 실패했습니다.");
|
||||
toast.error("화면을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (screenId) {
|
||||
loadScreen();
|
||||
}
|
||||
}, [screenId]);
|
||||
|
||||
const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"];
|
||||
const hasComponents = Object.keys(layout.components).length > 0;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-10 w-10 animate-spin text-blue-500" />
|
||||
<p className="mt-4 text-gray-600">POP 화면 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !screen) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-gray-100">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
|
||||
<span className="text-2xl">!</span>
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-bold text-gray-800">화면을 찾을 수 없습니다</h2>
|
||||
<p className="mb-4 text-gray-600">{error || "요청하신 POP 화면이 존재하지 않습니다."}</p>
|
||||
<Button onClick={() => router.back()} variant="outline">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
돌아가기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScreenPreviewProvider isPreviewMode={isPreviewMode}>
|
||||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div className="h-screen bg-gray-100 flex flex-col overflow-hidden">
|
||||
{/* 상단 툴바 (프리뷰 모드에서만) */}
|
||||
{isPreviewMode && (
|
||||
<div className="sticky top-0 z-50 bg-white border-b shadow-sm">
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => window.close()}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
닫기
|
||||
</Button>
|
||||
<span className="text-sm font-medium">{screen.screenName}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
({currentModeKey.replace("_", " ")})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
||||
<Button
|
||||
variant={deviceType === "mobile" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setDevice("mobile")}
|
||||
className="gap-1"
|
||||
>
|
||||
<Smartphone className="h-4 w-4" />
|
||||
모바일
|
||||
</Button>
|
||||
<Button
|
||||
variant={deviceType === "tablet" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setDevice("tablet")}
|
||||
className="gap-1"
|
||||
>
|
||||
<Tablet className="h-4 w-4" />
|
||||
태블릿
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1">
|
||||
<Button
|
||||
variant={isLandscape ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setOrientation(true)}
|
||||
className="gap-1"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
가로
|
||||
</Button>
|
||||
<Button
|
||||
variant={!isLandscape ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setOrientation(false)}
|
||||
className="gap-1"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
세로
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 자동 감지 모드 버튼 */}
|
||||
<Button
|
||||
variant={isAutoDetect ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDevice(undefined);
|
||||
setOrientation(undefined);
|
||||
}}
|
||||
className="gap-1"
|
||||
>
|
||||
자동
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={() => window.location.reload()}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* POP 화면 컨텐츠 */}
|
||||
<div className={`flex-1 flex flex-col ${isPreviewMode ? "py-4 overflow-auto items-center" : ""}`}>
|
||||
{/* 현재 모드 표시 (일반 모드) */}
|
||||
{!isPreviewMode && (
|
||||
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded">
|
||||
{currentModeKey.replace("_", " ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full"}`}
|
||||
style={isPreviewMode ? {
|
||||
width: currentDevice.width,
|
||||
maxHeight: "80vh",
|
||||
flexShrink: 0,
|
||||
} : undefined}
|
||||
>
|
||||
{/* v5 그리드 렌더러 */}
|
||||
{hasComponents ? (
|
||||
<div
|
||||
className="mx-auto min-h-full"
|
||||
style={{ maxWidth: 1366 }}
|
||||
>
|
||||
{(() => {
|
||||
// Gap 프리셋 계산
|
||||
const currentGapPreset = layout.settings.gapPreset || "medium";
|
||||
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
|
||||
const breakpoint = GRID_BREAKPOINTS[currentModeKey];
|
||||
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
|
||||
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
|
||||
|
||||
return (
|
||||
<PopRenderer
|
||||
layout={layout}
|
||||
viewportWidth={isPreviewMode ? currentDevice.width : viewportWidth}
|
||||
currentMode={currentModeKey}
|
||||
isDesignMode={false}
|
||||
overrideGap={adjustedGap}
|
||||
overridePadding={adjustedPadding}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
// 빈 화면
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<Smartphone className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">
|
||||
화면이 비어있습니다
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 max-w-xs">
|
||||
POP 화면 디자이너에서 컴포넌트를 추가하여 화면을 구성하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
</ActiveTabProvider>
|
||||
</ScreenPreviewProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Provider 래퍼
|
||||
export default function PopScreenViewPageWrapper() {
|
||||
return (
|
||||
<TableSearchWidgetHeightProvider>
|
||||
<ScreenContextProvider>
|
||||
<SplitPanelProvider>
|
||||
<PopScreenViewPage />
|
||||
</SplitPanelProvider>
|
||||
</ScreenContextProvider>
|
||||
</TableSearchWidgetHeightProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -685,6 +685,7 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
|
|||
|
||||
return {
|
||||
valueId: selectedId,
|
||||
valueCode: node.valueCode, // valueCode 추가 (V2Select 호환)
|
||||
valueLabel: node.valueLabel,
|
||||
valuePath: pathParts.join(" > "),
|
||||
};
|
||||
|
|
@ -698,6 +699,7 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
|
|||
|
||||
const newMapping: CategoryFormatMapping = {
|
||||
categoryValueId: selectedInfo.valueId,
|
||||
categoryValueCode: selectedInfo.valueCode, // V2Select에서 valueCode를 value로 사용하므로 매칭용 저장
|
||||
categoryValueLabel: selectedInfo.valueLabel,
|
||||
categoryValuePath: selectedInfo.valuePath,
|
||||
format: newFormat.trim(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,971 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState, useEffect, useMemo } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PopLayoutDataV5,
|
||||
PopComponentDefinitionV5,
|
||||
PopComponentType,
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
GapPreset,
|
||||
GAP_PRESETS,
|
||||
GRID_BREAKPOINTS,
|
||||
DEFAULT_COMPONENT_GRID_SIZE,
|
||||
} from "./types/pop-layout";
|
||||
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff } from "lucide-react";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import PopRenderer from "./renderers/PopRenderer";
|
||||
import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, needsReview } from "./utils/gridUtils";
|
||||
import { DND_ITEM_TYPES } from "./constants";
|
||||
|
||||
/**
|
||||
* 캔버스 내 상대 좌표 → 그리드 좌표 변환
|
||||
* @param relX 캔버스 내 X 좌표 (패딩 포함)
|
||||
* @param relY 캔버스 내 Y 좌표 (패딩 포함)
|
||||
*/
|
||||
function calcGridPosition(
|
||||
relX: number,
|
||||
relY: number,
|
||||
canvasWidth: number,
|
||||
columns: number,
|
||||
rowHeight: number,
|
||||
gap: number,
|
||||
padding: number
|
||||
): { col: number; row: number } {
|
||||
// 패딩 제외한 좌표
|
||||
const x = relX - padding;
|
||||
const y = relY - padding;
|
||||
|
||||
// 사용 가능한 너비 (패딩과 gap 제외)
|
||||
const availableWidth = canvasWidth - padding * 2 - gap * (columns - 1);
|
||||
const colWidth = availableWidth / columns;
|
||||
|
||||
// 셀+gap 단위로 계산
|
||||
const cellStride = colWidth + gap;
|
||||
const rowStride = rowHeight + gap;
|
||||
|
||||
// 그리드 좌표 (1부터 시작)
|
||||
const col = Math.max(1, Math.min(columns, Math.floor(x / cellStride) + 1));
|
||||
const row = Math.max(1, Math.floor(y / rowStride) + 1);
|
||||
|
||||
return { col, row };
|
||||
}
|
||||
|
||||
// 드래그 아이템 타입 정의
|
||||
interface DragItemComponent {
|
||||
type: typeof DND_ITEM_TYPES.COMPONENT;
|
||||
componentType: PopComponentType;
|
||||
}
|
||||
|
||||
interface DragItemMoveComponent {
|
||||
componentId: string;
|
||||
originalPosition: PopGridPosition;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 프리셋 해상도 (4개 모드) - 너비만 정의
|
||||
// ========================================
|
||||
const VIEWPORT_PRESETS = [
|
||||
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, icon: Smartphone },
|
||||
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 600, icon: Smartphone },
|
||||
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 820, icon: Tablet },
|
||||
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, icon: Tablet },
|
||||
] as const;
|
||||
|
||||
type ViewportPreset = GridMode;
|
||||
|
||||
// 기본 프리셋 (태블릿 가로)
|
||||
const DEFAULT_PRESET: ViewportPreset = "tablet_landscape";
|
||||
|
||||
// 캔버스 세로 자동 확장 설정
|
||||
const MIN_CANVAS_HEIGHT = 600; // 최소 캔버스 높이 (px)
|
||||
const CANVAS_EXTRA_ROWS = 3; // 여유 행 수
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
// ========================================
|
||||
interface PopCanvasProps {
|
||||
layout: PopLayoutDataV5;
|
||||
selectedComponentId: string | null;
|
||||
currentMode: GridMode;
|
||||
onModeChange: (mode: GridMode) => void;
|
||||
onSelectComponent: (id: string | null) => void;
|
||||
onDropComponent: (type: PopComponentType, position: PopGridPosition) => void;
|
||||
onUpdateComponent: (componentId: string, updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
onDeleteComponent: (componentId: string) => void;
|
||||
onMoveComponent?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||
onResizeComponent?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||
onResizeEnd?: (componentId: string) => void;
|
||||
onHideComponent?: (componentId: string) => void;
|
||||
onUnhideComponent?: (componentId: string) => void;
|
||||
onLockLayout?: () => void;
|
||||
onResetOverride?: (mode: GridMode) => void;
|
||||
onChangeGapPreset?: (preset: GapPreset) => void;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// PopCanvas: 그리드 캔버스
|
||||
// ========================================
|
||||
|
||||
export default function PopCanvas({
|
||||
layout,
|
||||
selectedComponentId,
|
||||
currentMode,
|
||||
onModeChange,
|
||||
onSelectComponent,
|
||||
onDropComponent,
|
||||
onUpdateComponent,
|
||||
onDeleteComponent,
|
||||
onMoveComponent,
|
||||
onResizeComponent,
|
||||
onResizeEnd,
|
||||
onHideComponent,
|
||||
onUnhideComponent,
|
||||
onLockLayout,
|
||||
onResetOverride,
|
||||
onChangeGapPreset,
|
||||
}: PopCanvasProps) {
|
||||
// 줌 상태
|
||||
const [canvasScale, setCanvasScale] = useState(0.8);
|
||||
|
||||
// 커스텀 뷰포트 너비
|
||||
const [customWidth, setCustomWidth] = useState(1024);
|
||||
|
||||
// 그리드 가이드 표시 여부
|
||||
const [showGridGuide, setShowGridGuide] = useState(true);
|
||||
|
||||
// 패닝 상태
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState({ x: 0, y: 0 });
|
||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 현재 뷰포트 해상도
|
||||
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
|
||||
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||||
|
||||
// Gap 프리셋 적용
|
||||
const currentGapPreset = layout.settings.gapPreset || "medium";
|
||||
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
|
||||
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
|
||||
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
|
||||
|
||||
// 숨김 컴포넌트 ID 목록
|
||||
const hiddenComponentIds = layout.overrides?.[currentMode]?.hidden || [];
|
||||
|
||||
// 동적 캔버스 높이 계산 (컴포넌트 배치 기반)
|
||||
const dynamicCanvasHeight = useMemo(() => {
|
||||
const visibleComps = Object.values(layout.components).filter(
|
||||
comp => !hiddenComponentIds.includes(comp.id)
|
||||
);
|
||||
|
||||
if (visibleComps.length === 0) return MIN_CANVAS_HEIGHT;
|
||||
|
||||
// 최대 row + rowSpan 찾기
|
||||
const maxRowEnd = visibleComps.reduce((max, comp) => {
|
||||
const overridePos = layout.overrides?.[currentMode]?.positions?.[comp.id];
|
||||
const pos = overridePos ? { ...comp.position, ...overridePos } : comp.position;
|
||||
const rowEnd = pos.row + pos.rowSpan;
|
||||
return Math.max(max, rowEnd);
|
||||
}, 1);
|
||||
|
||||
// 높이 계산: (행 수 + 여유) * (행높이 + gap) + padding
|
||||
const totalRows = maxRowEnd + CANVAS_EXTRA_ROWS;
|
||||
const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2;
|
||||
|
||||
return Math.max(MIN_CANVAS_HEIGHT, height);
|
||||
}, [layout.components, layout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]);
|
||||
|
||||
// 그리드 라벨 계산 (동적 행 수)
|
||||
const gridLabels = useMemo(() => {
|
||||
const columnLabels = Array.from({ length: breakpoint.columns }, (_, i) => i + 1);
|
||||
|
||||
// 동적 행 수 계산
|
||||
const rowCount = Math.ceil(dynamicCanvasHeight / (breakpoint.rowHeight + adjustedGap));
|
||||
const rowLabels = Array.from({ length: rowCount }, (_, i) => i + 1);
|
||||
|
||||
return { columnLabels, rowLabels };
|
||||
}, [breakpoint.columns, breakpoint.rowHeight, dynamicCanvasHeight, adjustedGap]);
|
||||
|
||||
// 줌 컨트롤
|
||||
const handleZoomIn = () => setCanvasScale((prev) => Math.min(1.5, prev + 0.1));
|
||||
const handleZoomOut = () => setCanvasScale((prev) => Math.max(0.3, prev - 0.1));
|
||||
const handleZoomFit = () => setCanvasScale(1.0);
|
||||
|
||||
// 모드 변경
|
||||
const handleViewportChange = (mode: GridMode) => {
|
||||
onModeChange(mode);
|
||||
const presetData = VIEWPORT_PRESETS.find((p) => p.id === mode)!;
|
||||
setCustomWidth(presetData.width);
|
||||
// customHeight는 dynamicCanvasHeight로 자동 계산됨
|
||||
};
|
||||
|
||||
// 패닝
|
||||
const handlePanStart = (e: React.MouseEvent) => {
|
||||
const isMiddleButton = e.button === 1;
|
||||
if (isMiddleButton || isSpacePressed) {
|
||||
setIsPanning(true);
|
||||
setPanStart({ x: e.clientX, y: e.clientY });
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePanMove = (e: React.MouseEvent) => {
|
||||
if (!isPanning || !containerRef.current) return;
|
||||
const deltaX = e.clientX - panStart.x;
|
||||
const deltaY = e.clientY - panStart.y;
|
||||
containerRef.current.scrollLeft -= deltaX;
|
||||
containerRef.current.scrollTop -= deltaY;
|
||||
setPanStart({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
const handlePanEnd = () => setIsPanning(false);
|
||||
|
||||
// Ctrl + 휠로 줌 조정
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
setCanvasScale((prev) => Math.max(0.3, Math.min(1.5, prev + delta)));
|
||||
}
|
||||
};
|
||||
|
||||
// Space 키 감지
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "Space" && !isSpacePressed) setIsSpacePressed(true);
|
||||
};
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === "Space") setIsSpacePressed(false);
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [isSpacePressed]);
|
||||
|
||||
// 통합 드롭 핸들러 (팔레트에서 추가 + 컴포넌트 이동)
|
||||
const [{ isOver, canDrop }, drop] = useDrop(
|
||||
() => ({
|
||||
accept: [DND_ITEM_TYPES.COMPONENT, DND_ITEM_TYPES.MOVE_COMPONENT],
|
||||
drop: (item: DragItemComponent | DragItemMoveComponent, monitor) => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
const canvasRect = canvasRef.current.getBoundingClientRect();
|
||||
const itemType = monitor.getItemType();
|
||||
|
||||
// 팔레트에서 새 컴포넌트 추가 - 마우스 위치 기준
|
||||
if (itemType === DND_ITEM_TYPES.COMPONENT) {
|
||||
const offset = monitor.getClientOffset();
|
||||
if (!offset) return;
|
||||
|
||||
// 캔버스 내 상대 좌표 (스케일 보정)
|
||||
// canvasRect는 scale 적용된 크기이므로, 상대 좌표를 scale로 나눠야 실제 좌표
|
||||
const relX = (offset.x - canvasRect.left) / canvasScale;
|
||||
const relY = (offset.y - canvasRect.top) / canvasScale;
|
||||
|
||||
// 그리드 좌표 계산
|
||||
const gridPos = calcGridPosition(
|
||||
relX,
|
||||
relY,
|
||||
customWidth,
|
||||
breakpoint.columns,
|
||||
breakpoint.rowHeight,
|
||||
adjustedGap,
|
||||
adjustedPadding
|
||||
);
|
||||
|
||||
const dragItem = item as DragItemComponent;
|
||||
const defaultSize = DEFAULT_COMPONENT_GRID_SIZE[dragItem.componentType];
|
||||
|
||||
const candidatePosition: PopGridPosition = {
|
||||
col: gridPos.col,
|
||||
row: gridPos.row,
|
||||
colSpan: defaultSize.colSpan,
|
||||
rowSpan: defaultSize.rowSpan,
|
||||
};
|
||||
|
||||
// 현재 모드에서의 유효 위치들로 중첩 검사
|
||||
const effectivePositions = getAllEffectivePositions(layout, currentMode);
|
||||
const existingPositions = Array.from(effectivePositions.values());
|
||||
|
||||
const hasOverlap = existingPositions.some(pos =>
|
||||
isOverlapping(candidatePosition, pos)
|
||||
);
|
||||
|
||||
let finalPosition: PopGridPosition;
|
||||
|
||||
if (hasOverlap) {
|
||||
finalPosition = findNextEmptyPosition(
|
||||
existingPositions,
|
||||
defaultSize.colSpan,
|
||||
defaultSize.rowSpan,
|
||||
breakpoint.columns
|
||||
);
|
||||
toast.info("겹치는 위치입니다. 빈 위치로 자동 배치됩니다.");
|
||||
} else {
|
||||
finalPosition = candidatePosition;
|
||||
}
|
||||
|
||||
onDropComponent(dragItem.componentType, finalPosition);
|
||||
}
|
||||
|
||||
// 기존 컴포넌트 이동 - 마우스 위치 기준
|
||||
if (itemType === DND_ITEM_TYPES.MOVE_COMPONENT) {
|
||||
const offset = monitor.getClientOffset();
|
||||
if (!offset) return;
|
||||
|
||||
// 캔버스 내 상대 좌표 (스케일 보정)
|
||||
const relX = (offset.x - canvasRect.left) / canvasScale;
|
||||
const relY = (offset.y - canvasRect.top) / canvasScale;
|
||||
|
||||
const gridPos = calcGridPosition(
|
||||
relX,
|
||||
relY,
|
||||
customWidth,
|
||||
breakpoint.columns,
|
||||
breakpoint.rowHeight,
|
||||
adjustedGap,
|
||||
adjustedPadding
|
||||
);
|
||||
|
||||
const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean };
|
||||
|
||||
// 현재 모드에서의 유효 위치들 가져오기
|
||||
const effectivePositions = getAllEffectivePositions(layout, currentMode);
|
||||
|
||||
// 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기
|
||||
// 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용
|
||||
const currentEffectivePos = effectivePositions.get(dragItem.componentId);
|
||||
const componentData = layout.components[dragItem.componentId];
|
||||
|
||||
if (!currentEffectivePos && !componentData) return;
|
||||
|
||||
const sourcePosition = currentEffectivePos || componentData.position;
|
||||
|
||||
// colSpan이 현재 모드의 columns를 초과하면 제한
|
||||
const adjustedColSpan = Math.min(sourcePosition.colSpan, breakpoint.columns);
|
||||
|
||||
// 드롭 위치 + 크기가 범위를 초과하면 드롭 위치를 자동 조정
|
||||
let adjustedCol = gridPos.col;
|
||||
if (adjustedCol + adjustedColSpan - 1 > breakpoint.columns) {
|
||||
adjustedCol = Math.max(1, breakpoint.columns - adjustedColSpan + 1);
|
||||
}
|
||||
|
||||
const newPosition: PopGridPosition = {
|
||||
col: adjustedCol,
|
||||
row: gridPos.row,
|
||||
colSpan: adjustedColSpan,
|
||||
rowSpan: sourcePosition.rowSpan,
|
||||
};
|
||||
|
||||
// 자기 자신 제외한 다른 컴포넌트들의 유효 위치와 겹침 체크
|
||||
const hasOverlap = Array.from(effectivePositions.entries()).some(([id, pos]) => {
|
||||
if (id === dragItem.componentId) return false; // 자기 자신 제외
|
||||
return isOverlapping(newPosition, pos);
|
||||
});
|
||||
|
||||
if (hasOverlap) {
|
||||
toast.error("이 위치로 이동할 수 없습니다 (다른 컴포넌트와 겹침)");
|
||||
return;
|
||||
}
|
||||
|
||||
// 이동 처리 (숨김 컴포넌트의 경우 handleMoveComponent에서 숨김 해제도 함께 처리됨)
|
||||
onMoveComponent?.(dragItem.componentId, newPosition);
|
||||
|
||||
// 숨김 패널에서 드래그한 경우 안내 메시지
|
||||
if (dragItem.fromHidden) {
|
||||
toast.info("컴포넌트가 다시 표시됩니다");
|
||||
}
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
}),
|
||||
[onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding]
|
||||
);
|
||||
|
||||
drop(canvasRef);
|
||||
|
||||
// 빈 상태 체크
|
||||
const isEmpty = Object.keys(layout.components).length === 0;
|
||||
|
||||
// 숨김 처리된 컴포넌트 객체 목록 (hiddenComponentIds는 라인 166에서 정의됨)
|
||||
const hiddenComponents = useMemo(() => {
|
||||
return hiddenComponentIds
|
||||
.map(id => layout.components[id])
|
||||
.filter(Boolean);
|
||||
}, [hiddenComponentIds, layout.components]);
|
||||
|
||||
// 표시되는 컴포넌트 목록 (숨김 제외)
|
||||
const visibleComponents = useMemo(() => {
|
||||
return Object.values(layout.components).filter(
|
||||
comp => !hiddenComponentIds.includes(comp.id)
|
||||
);
|
||||
}, [layout.components, hiddenComponentIds]);
|
||||
|
||||
// 검토 필요 컴포넌트 목록
|
||||
const reviewComponents = useMemo(() => {
|
||||
return visibleComponents.filter(comp => {
|
||||
const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id];
|
||||
return needsReview(currentMode, hasOverride);
|
||||
});
|
||||
}, [visibleComponents, layout.overrides, currentMode]);
|
||||
|
||||
// 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때)
|
||||
const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0;
|
||||
|
||||
// 12칸 모드가 아닐 때만 패널 표시
|
||||
// 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시
|
||||
const hasGridComponents = Object.keys(layout.components).length > 0;
|
||||
const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents);
|
||||
const showRightPanel = showReviewPanel || showHiddenPanel;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gray-50">
|
||||
{/* 상단 컨트롤 */}
|
||||
<div className="flex items-center gap-2 border-b bg-white px-4 py-2">
|
||||
{/* 모드 프리셋 버튼 */}
|
||||
<div className="flex gap-1">
|
||||
{VIEWPORT_PRESETS.map((preset) => {
|
||||
const Icon = preset.icon;
|
||||
const isActive = currentMode === preset.id;
|
||||
const isDefault = preset.id === DEFAULT_PRESET;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={preset.id}
|
||||
variant={isActive ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleViewportChange(preset.id as GridMode)}
|
||||
className={cn(
|
||||
"h-8 gap-1 text-xs",
|
||||
isActive && "shadow-sm"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" />
|
||||
{preset.shortLabel}
|
||||
{isDefault && " (기본)"}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-gray-300" />
|
||||
|
||||
{/* 고정/되돌리기 버튼 (기본 모드 아닐 때만 표시) */}
|
||||
{currentMode !== DEFAULT_PRESET && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onLockLayout}
|
||||
className="h-8 gap-1 text-xs"
|
||||
>
|
||||
<Lock className="h-3 w-3" />
|
||||
고정
|
||||
</Button>
|
||||
|
||||
{layout.overrides?.[currentMode] && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onResetOverride?.(currentMode)}
|
||||
className="h-8 gap-1 text-xs"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
자동으로 되돌리기
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="h-4 w-px bg-gray-300" />
|
||||
|
||||
{/* 해상도 표시 */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{customWidth} × {Math.round(dynamicCanvasHeight)}
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-gray-300" />
|
||||
|
||||
{/* Gap 프리셋 선택 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">간격:</span>
|
||||
<Select
|
||||
value={currentGapPreset}
|
||||
onValueChange={(value) => onChangeGapPreset?.(value as GapPreset)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(GAP_PRESETS) as GapPreset[]).map((preset) => (
|
||||
<SelectItem key={preset} value={preset} className="text-xs">
|
||||
{GAP_PRESETS[preset].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{Math.round(canvasScale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleZoomOut}
|
||||
disabled={canvasScale <= 0.3}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<ZoomOut className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleZoomFit}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Maximize2 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleZoomIn}
|
||||
disabled={canvasScale >= 1.5}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<ZoomIn className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-gray-300" />
|
||||
|
||||
{/* 그리드 가이드 토글 */}
|
||||
<Button
|
||||
variant={showGridGuide ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setShowGridGuide(!showGridGuide)}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
그리드 {showGridGuide ? "ON" : "OFF"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 캔버스 영역 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"canvas-scroll-area relative flex-1 overflow-auto bg-gray-100",
|
||||
isSpacePressed && "cursor-grab",
|
||||
isPanning && "cursor-grabbing"
|
||||
)}
|
||||
onMouseDown={handlePanStart}
|
||||
onMouseMove={handlePanMove}
|
||||
onMouseUp={handlePanEnd}
|
||||
onMouseLeave={handlePanEnd}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div
|
||||
className="relative mx-auto my-8 origin-top overflow-visible flex gap-4"
|
||||
style={{
|
||||
width: showRightPanel
|
||||
? `${customWidth + 32 + 220}px` // 오른쪽 패널 공간 추가
|
||||
: `${customWidth + 32}px`,
|
||||
minHeight: `${dynamicCanvasHeight + 32}px`,
|
||||
transform: `scale(${canvasScale})`,
|
||||
}}
|
||||
>
|
||||
{/* 그리드 + 라벨 영역 */}
|
||||
<div className="relative">
|
||||
{/* 그리드 라벨 영역 */}
|
||||
{showGridGuide && (
|
||||
<>
|
||||
{/* 열 라벨 (상단) */}
|
||||
<div
|
||||
className="flex absolute top-0 left-8"
|
||||
style={{
|
||||
gap: `${adjustedGap}px`,
|
||||
paddingLeft: `${adjustedPadding}px`,
|
||||
}}
|
||||
>
|
||||
{gridLabels.columnLabels.map((num) => (
|
||||
<div
|
||||
key={`col-${num}`}
|
||||
className="flex items-center justify-center text-xs font-semibold text-blue-500"
|
||||
style={{
|
||||
width: `calc((${customWidth}px - ${adjustedPadding * 2}px - ${adjustedGap * (breakpoint.columns - 1)}px) / ${breakpoint.columns})`,
|
||||
height: "24px",
|
||||
}}
|
||||
>
|
||||
{num}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 행 라벨 (좌측) */}
|
||||
<div
|
||||
className="flex flex-col absolute top-8 left-0"
|
||||
style={{
|
||||
gap: `${adjustedGap}px`,
|
||||
paddingTop: `${adjustedPadding}px`,
|
||||
}}
|
||||
>
|
||||
{gridLabels.rowLabels.map((num) => (
|
||||
<div
|
||||
key={`row-${num}`}
|
||||
className="flex items-center justify-center text-xs font-semibold text-blue-500"
|
||||
style={{
|
||||
width: "24px",
|
||||
height: `${breakpoint.rowHeight}px`,
|
||||
}}
|
||||
>
|
||||
{num}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 디바이스 스크린 */}
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className={cn(
|
||||
"relative rounded-lg border-2 bg-white shadow-xl overflow-visible",
|
||||
canDrop && isOver && "ring-4 ring-primary/20"
|
||||
)}
|
||||
style={{
|
||||
width: `${customWidth}px`,
|
||||
minHeight: `${dynamicCanvasHeight}px`,
|
||||
marginLeft: "32px",
|
||||
marginTop: "32px",
|
||||
}}
|
||||
>
|
||||
{isEmpty ? (
|
||||
// 빈 상태
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-sm font-medium text-gray-500">
|
||||
컴포넌트를 드래그하여 배치하세요
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{breakpoint.label} - {breakpoint.columns}칸 그리드
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 그리드 렌더러
|
||||
<PopRenderer
|
||||
layout={layout}
|
||||
viewportWidth={customWidth}
|
||||
currentMode={currentMode}
|
||||
isDesignMode={true}
|
||||
showGridGuide={showGridGuide}
|
||||
selectedComponentId={selectedComponentId}
|
||||
onComponentClick={onSelectComponent}
|
||||
onBackgroundClick={() => onSelectComponent(null)}
|
||||
onComponentMove={onMoveComponent}
|
||||
onComponentResize={onResizeComponent}
|
||||
onComponentResizeEnd={onResizeEnd}
|
||||
overrideGap={adjustedGap}
|
||||
overridePadding={adjustedPadding}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 패널 영역 (초과 컴포넌트 + 숨김 컴포넌트) */}
|
||||
{showRightPanel && (
|
||||
<div
|
||||
className="flex flex-col gap-3"
|
||||
style={{ marginTop: "32px" }}
|
||||
>
|
||||
{/* 검토 필요 패널 */}
|
||||
{showReviewPanel && (
|
||||
<ReviewPanel
|
||||
components={reviewComponents}
|
||||
selectedComponentId={selectedComponentId}
|
||||
onSelectComponent={onSelectComponent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 숨김 컴포넌트 패널 */}
|
||||
{showHiddenPanel && (
|
||||
<HiddenPanel
|
||||
components={hiddenComponents}
|
||||
selectedComponentId={selectedComponentId}
|
||||
onSelectComponent={onSelectComponent}
|
||||
onHideComponent={onHideComponent}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 정보 */}
|
||||
<div className="flex items-center justify-between border-t bg-white px-4 py-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{breakpoint.label} - {breakpoint.columns}칸 그리드 (행 높이: {breakpoint.rowHeight}px)
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Space + 드래그: 패닝 | Ctrl + 휠: 줌
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 검토 필요 영역 (오른쪽 패널)
|
||||
// ========================================
|
||||
|
||||
interface ReviewPanelProps {
|
||||
components: PopComponentDefinitionV5[];
|
||||
selectedComponentId: string | null;
|
||||
onSelectComponent: (id: string | null) => void;
|
||||
}
|
||||
|
||||
function ReviewPanel({
|
||||
components,
|
||||
selectedComponentId,
|
||||
onSelectComponent,
|
||||
}: ReviewPanelProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col rounded-lg border-2 border-dashed border-blue-300 bg-blue-50/50"
|
||||
style={{
|
||||
width: "200px",
|
||||
maxHeight: "300px",
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 border-b border-blue-200 bg-blue-100/50 px-3 py-2 rounded-t-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-xs font-semibold text-blue-700">
|
||||
검토 필요 ({components.length}개)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div className="flex-1 overflow-auto p-2 space-y-2">
|
||||
{components.map((comp) => (
|
||||
<ReviewItem
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
isSelected={selectedComponentId === comp.id}
|
||||
onSelect={() => onSelectComponent(comp.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div className="border-t border-blue-200 px-3 py-2 bg-blue-50/80 rounded-b-lg">
|
||||
<p className="text-[10px] text-blue-600 leading-tight">
|
||||
자동 배치됨. 클릭하여 확인 후 편집 가능
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 검토 필요 아이템 (ReviewPanel 내부)
|
||||
// ========================================
|
||||
|
||||
interface ReviewItemProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function ReviewItem({
|
||||
component,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: ReviewItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 rounded-md border-2 p-2 cursor-pointer transition-all",
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-100 shadow-sm"
|
||||
: "border-blue-200 bg-white hover:border-blue-400 hover:bg-blue-50"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium text-blue-800 line-clamp-1">
|
||||
{component.label || component.id}
|
||||
</span>
|
||||
<span className="text-[10px] text-blue-600 bg-blue-50 rounded px-1.5 py-0.5 self-start">
|
||||
자동 배치됨
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 숨김 컴포넌트 영역 (오른쪽 패널)
|
||||
// ========================================
|
||||
|
||||
interface HiddenPanelProps {
|
||||
components: PopComponentDefinitionV5[];
|
||||
selectedComponentId: string | null;
|
||||
onSelectComponent: (id: string | null) => void;
|
||||
onHideComponent?: (componentId: string) => void;
|
||||
}
|
||||
|
||||
function HiddenPanel({
|
||||
components,
|
||||
selectedComponentId,
|
||||
onSelectComponent,
|
||||
onHideComponent,
|
||||
}: HiddenPanelProps) {
|
||||
// 그리드에서 컴포넌트를 드래그하여 이 패널에 드롭하면 숨김 처리
|
||||
const [{ isOver, canDrop }, drop] = useDrop(
|
||||
() => ({
|
||||
accept: DND_ITEM_TYPES.MOVE_COMPONENT,
|
||||
drop: (item: { componentId: string; fromHidden?: boolean }) => {
|
||||
// 이미 숨김 패널에서 온 아이템은 무시
|
||||
if (item.fromHidden) return;
|
||||
|
||||
// 숨김 처리
|
||||
onHideComponent?.(item.componentId);
|
||||
toast.info("컴포넌트가 숨김 처리되었습니다");
|
||||
},
|
||||
canDrop: (item: { componentId: string; fromHidden?: boolean }) => {
|
||||
// 숨김 패널에서 온 아이템은 드롭 불가
|
||||
return !item.fromHidden;
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOver: monitor.isOver(),
|
||||
canDrop: monitor.canDrop(),
|
||||
}),
|
||||
}),
|
||||
[onHideComponent]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drop}
|
||||
className={cn(
|
||||
"flex flex-col rounded-lg border-2 border-dashed bg-gray-100/50 transition-colors",
|
||||
isOver && canDrop
|
||||
? "border-gray-600 bg-gray-200/70"
|
||||
: "border-gray-400"
|
||||
)}
|
||||
style={{
|
||||
width: "200px",
|
||||
maxHeight: "300px",
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 border-b border-gray-300 bg-gray-200/50 px-3 py-2 rounded-t-lg">
|
||||
<EyeOff className="h-4 w-4 text-gray-600" />
|
||||
<span className="text-xs font-semibold text-gray-700">
|
||||
숨김 ({components.length}개)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div className="flex-1 overflow-auto p-2 space-y-2">
|
||||
{components.map((comp) => (
|
||||
<HiddenItem
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
isSelected={selectedComponentId === comp.id}
|
||||
onSelect={() => onSelectComponent(comp.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div className="border-t border-gray-300 px-3 py-2 bg-gray-100/80 rounded-b-lg">
|
||||
<p className="text-[10px] text-gray-600 leading-tight">
|
||||
그리드로 드래그하여 다시 표시
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ========================================
|
||||
// 숨김 컴포넌트 아이템 (드래그 가능)
|
||||
// ========================================
|
||||
|
||||
interface HiddenItemProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function HiddenItem({
|
||||
component,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: HiddenItemProps) {
|
||||
const [{ isDragging }, drag] = useDrag(
|
||||
() => ({
|
||||
type: DND_ITEM_TYPES.MOVE_COMPONENT,
|
||||
item: {
|
||||
componentId: component.id,
|
||||
originalPosition: component.position,
|
||||
fromHidden: true, // 숨김 패널에서 왔음을 표시
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
}),
|
||||
[component.id, component.position]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drag}
|
||||
className={cn(
|
||||
"rounded-md border-2 bg-white p-2 cursor-move transition-all opacity-60",
|
||||
isSelected
|
||||
? "border-primary ring-2 ring-primary/30"
|
||||
: "border-gray-400 hover:border-gray-500",
|
||||
isDragging && "opacity-30"
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{/* 컴포넌트 이름 */}
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-gray-600 truncate">
|
||||
<EyeOff className="h-3 w-3" />
|
||||
{component.label || component.type}
|
||||
</div>
|
||||
|
||||
{/* 원본 위치 정보 */}
|
||||
<div className="text-[10px] text-gray-500 mt-1">
|
||||
원본: {component.position.col}열, {component.position.row}행
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,661 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { ArrowLeft, Save, Undo2, Redo2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// POP 컴포넌트 자동 등록 (반드시 다른 import보다 먼저)
|
||||
import "@/lib/registry/pop-components";
|
||||
|
||||
import PopCanvas from "./PopCanvas";
|
||||
import ComponentEditorPanel from "./panels/ComponentEditorPanel";
|
||||
import ComponentPalette from "./panels/ComponentPalette";
|
||||
import {
|
||||
PopLayoutDataV5,
|
||||
PopComponentType,
|
||||
PopComponentDefinitionV5,
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
GapPreset,
|
||||
createEmptyPopLayoutV5,
|
||||
isV5Layout,
|
||||
addComponentToV5Layout,
|
||||
GRID_BREAKPOINTS,
|
||||
} from "./types/pop-layout";
|
||||
import { getAllEffectivePositions } from "./utils/gridUtils";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
// ========================================
|
||||
interface PopDesignerProps {
|
||||
selectedScreen: ScreenDefinition;
|
||||
onBackToList: () => void;
|
||||
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 메인 컴포넌트 (v5 그리드 시스템 전용)
|
||||
// ========================================
|
||||
export default function PopDesigner({
|
||||
selectedScreen,
|
||||
onBackToList,
|
||||
onScreenUpdate,
|
||||
}: PopDesignerProps) {
|
||||
// ========================================
|
||||
// 레이아웃 상태
|
||||
// ========================================
|
||||
const [layout, setLayout] = useState<PopLayoutDataV5>(createEmptyPopLayoutV5());
|
||||
|
||||
// 히스토리
|
||||
const [history, setHistory] = useState<PopLayoutDataV5[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
|
||||
// UI 상태
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [idCounter, setIdCounter] = useState(1);
|
||||
|
||||
// 선택 상태
|
||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||
|
||||
// 그리드 모드 (4개 프리셋)
|
||||
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
||||
|
||||
// 선택된 컴포넌트
|
||||
const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId
|
||||
? layout.components[selectedComponentId] || null
|
||||
: null;
|
||||
|
||||
// ========================================
|
||||
// 히스토리 관리
|
||||
// ========================================
|
||||
const saveToHistory = useCallback((newLayout: PopLayoutDataV5) => {
|
||||
setHistory((prev) => {
|
||||
const newHistory = prev.slice(0, historyIndex + 1);
|
||||
newHistory.push(JSON.parse(JSON.stringify(newLayout)));
|
||||
// 최대 50개 유지
|
||||
if (newHistory.length > 50) {
|
||||
newHistory.shift();
|
||||
return newHistory;
|
||||
}
|
||||
return newHistory;
|
||||
});
|
||||
setHistoryIndex((prev) => Math.min(prev + 1, 49));
|
||||
}, [historyIndex]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
const newIndex = historyIndex - 1;
|
||||
const previousLayout = history[newIndex];
|
||||
if (previousLayout) {
|
||||
setLayout(JSON.parse(JSON.stringify(previousLayout)));
|
||||
setHistoryIndex(newIndex);
|
||||
setHasChanges(true);
|
||||
toast.success("실행 취소됨");
|
||||
}
|
||||
}
|
||||
}, [historyIndex, history]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
const newIndex = historyIndex + 1;
|
||||
const nextLayout = history[newIndex];
|
||||
if (nextLayout) {
|
||||
setLayout(JSON.parse(JSON.stringify(nextLayout)));
|
||||
setHistoryIndex(newIndex);
|
||||
setHasChanges(true);
|
||||
toast.success("다시 실행됨");
|
||||
}
|
||||
}
|
||||
}, [historyIndex, history]);
|
||||
|
||||
const canUndo = historyIndex > 0;
|
||||
const canRedo = historyIndex < history.length - 1;
|
||||
|
||||
// ========================================
|
||||
// 레이아웃 로드
|
||||
// ========================================
|
||||
useEffect(() => {
|
||||
const loadLayout = async () => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||
|
||||
if (loadedLayout && isV5Layout(loadedLayout) && Object.keys(loadedLayout.components).length > 0) {
|
||||
// v5 레이아웃 로드
|
||||
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
|
||||
if (!loadedLayout.settings.gapPreset) {
|
||||
loadedLayout.settings.gapPreset = "medium";
|
||||
}
|
||||
setLayout(loadedLayout);
|
||||
setHistory([loadedLayout]);
|
||||
setHistoryIndex(0);
|
||||
|
||||
// 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지)
|
||||
const existingIds = Object.keys(loadedLayout.components);
|
||||
const maxId = existingIds.reduce((max, id) => {
|
||||
const match = id.match(/comp_(\d+)/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
return num > max ? num : max;
|
||||
}
|
||||
return max;
|
||||
}, 0);
|
||||
setIdCounter(maxId + 1);
|
||||
|
||||
console.log(`POP 레이아웃 로드: ${existingIds.length}개 컴포넌트, idCounter: ${maxId + 1}`);
|
||||
} else {
|
||||
// 새 화면 또는 빈 레이아웃
|
||||
const emptyLayout = createEmptyPopLayoutV5();
|
||||
setLayout(emptyLayout);
|
||||
setHistory([emptyLayout]);
|
||||
setHistoryIndex(0);
|
||||
console.log("새 POP 화면 생성 (v5 그리드)");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("레이아웃 로드 실패:", error);
|
||||
toast.error("레이아웃을 불러오는데 실패했습니다");
|
||||
const emptyLayout = createEmptyPopLayoutV5();
|
||||
setLayout(emptyLayout);
|
||||
setHistory([emptyLayout]);
|
||||
setHistoryIndex(0);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadLayout();
|
||||
}, [selectedScreen?.screenId]);
|
||||
|
||||
// ========================================
|
||||
// 저장
|
||||
// ========================================
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, layout);
|
||||
toast.success("저장되었습니다");
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
toast.error("저장에 실패했습니다");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [selectedScreen?.screenId, layout]);
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 핸들러
|
||||
// ========================================
|
||||
const handleDropComponent = useCallback(
|
||||
(type: PopComponentType, position: PopGridPosition) => {
|
||||
const componentId = `comp_${idCounter}`;
|
||||
setIdCounter((prev) => prev + 1);
|
||||
const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`);
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setSelectedComponentId(componentId);
|
||||
setHasChanges(true);
|
||||
},
|
||||
[idCounter, layout, saveToHistory]
|
||||
);
|
||||
|
||||
const handleUpdateComponent = useCallback(
|
||||
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
|
||||
const existingComponent = layout.components[componentId];
|
||||
if (!existingComponent) return;
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: {
|
||||
...layout.components,
|
||||
[componentId]: {
|
||||
...existingComponent,
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
},
|
||||
[layout, saveToHistory]
|
||||
);
|
||||
|
||||
const handleDeleteComponent = useCallback(
|
||||
(componentId: string) => {
|
||||
const newComponents = { ...layout.components };
|
||||
delete newComponents[componentId];
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: newComponents,
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setSelectedComponentId(null);
|
||||
setHasChanges(true);
|
||||
},
|
||||
[layout, saveToHistory]
|
||||
);
|
||||
|
||||
const handleMoveComponent = useCallback(
|
||||
(componentId: string, newPosition: PopGridPosition) => {
|
||||
const component = layout.components[componentId];
|
||||
if (!component) return;
|
||||
|
||||
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: {
|
||||
...layout.components,
|
||||
[componentId]: {
|
||||
...component,
|
||||
position: newPosition,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
} else {
|
||||
// 다른 모드인 경우: 오버라이드에 저장
|
||||
// 숨김 상태였던 컴포넌트를 이동하면 숨김 해제도 함께 처리
|
||||
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
|
||||
const isHidden = currentHidden.includes(componentId);
|
||||
const newHidden = isHidden
|
||||
? currentHidden.filter(id => id !== componentId)
|
||||
: currentHidden;
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: {
|
||||
...layout.overrides,
|
||||
[currentMode]: {
|
||||
...layout.overrides?.[currentMode],
|
||||
positions: {
|
||||
...layout.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
// 숨김 배열 업데이트 (빈 배열이면 undefined로)
|
||||
hidden: newHidden.length > 0 ? newHidden : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
}
|
||||
},
|
||||
[layout, saveToHistory, currentMode]
|
||||
);
|
||||
|
||||
const handleResizeComponent = useCallback(
|
||||
(componentId: string, newPosition: PopGridPosition) => {
|
||||
const component = layout.components[componentId];
|
||||
if (!component) return;
|
||||
|
||||
// 기본 모드(tablet_landscape, 12칸)인 경우: 원본 position 직접 수정
|
||||
if (currentMode === "tablet_landscape") {
|
||||
const newLayout = {
|
||||
...layout,
|
||||
components: {
|
||||
...layout.components,
|
||||
[componentId]: {
|
||||
...component,
|
||||
position: newPosition,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
// 리사이즈는 드래그 중 계속 호출되므로 히스토리는 마우스업 시에만 저장
|
||||
// 현재는 간단히 매번 저장 (최적화 가능)
|
||||
setHasChanges(true);
|
||||
} else {
|
||||
// 다른 모드인 경우: 오버라이드에 저장
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: {
|
||||
...layout.overrides,
|
||||
[currentMode]: {
|
||||
...layout.overrides?.[currentMode],
|
||||
positions: {
|
||||
...layout.overrides?.[currentMode]?.positions,
|
||||
[componentId]: newPosition,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
setHasChanges(true);
|
||||
}
|
||||
},
|
||||
[layout, currentMode]
|
||||
);
|
||||
|
||||
const handleResizeEnd = useCallback(
|
||||
(componentId: string) => {
|
||||
// 리사이즈 완료 시 현재 레이아웃을 히스토리에 저장
|
||||
saveToHistory(layout);
|
||||
},
|
||||
[layout, saveToHistory]
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// Gap 프리셋 관리
|
||||
// ========================================
|
||||
|
||||
const handleChangeGapPreset = useCallback((preset: GapPreset) => {
|
||||
const newLayout = {
|
||||
...layout,
|
||||
settings: {
|
||||
...layout.settings,
|
||||
gapPreset: preset,
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
}, [layout, saveToHistory]);
|
||||
|
||||
// ========================================
|
||||
// 모드별 오버라이드 관리
|
||||
// ========================================
|
||||
|
||||
const handleLockLayout = useCallback(() => {
|
||||
// 현재 화면에 보이는 유효 위치들을 저장 (오버라이드 또는 자동 재배치 위치)
|
||||
const effectivePositions = getAllEffectivePositions(layout, currentMode);
|
||||
|
||||
const positionsToSave: Record<string, PopGridPosition> = {};
|
||||
effectivePositions.forEach((position, componentId) => {
|
||||
positionsToSave[componentId] = position;
|
||||
});
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: {
|
||||
...layout.overrides,
|
||||
[currentMode]: {
|
||||
...layout.overrides?.[currentMode],
|
||||
positions: positionsToSave,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
toast.success("현재 배치가 고정되었습니다");
|
||||
}, [layout, currentMode, saveToHistory]);
|
||||
|
||||
const handleResetOverride = useCallback((mode: GridMode) => {
|
||||
const newOverrides = { ...layout.overrides };
|
||||
delete newOverrides[mode];
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined,
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
toast.success("자동 배치로 되돌렸습니다");
|
||||
}, [layout, saveToHistory]);
|
||||
|
||||
// ========================================
|
||||
// 숨김 관리
|
||||
// ========================================
|
||||
|
||||
const handleHideComponent = useCallback((componentId: string) => {
|
||||
// 12칸 모드에서는 숨기기 불가
|
||||
if (currentMode === "tablet_landscape") return;
|
||||
|
||||
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
|
||||
|
||||
// 이미 숨겨져 있으면 무시
|
||||
if (currentHidden.includes(componentId)) return;
|
||||
|
||||
const newHidden = [...currentHidden, componentId];
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: {
|
||||
...layout.overrides,
|
||||
[currentMode]: {
|
||||
...layout.overrides?.[currentMode],
|
||||
hidden: newHidden,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
setSelectedComponentId(null);
|
||||
}, [layout, currentMode, saveToHistory]);
|
||||
|
||||
const handleUnhideComponent = useCallback((componentId: string) => {
|
||||
const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
|
||||
|
||||
// 숨겨져 있지 않으면 무시
|
||||
if (!currentHidden.includes(componentId)) return;
|
||||
|
||||
const newHidden = currentHidden.filter(id => id !== componentId);
|
||||
|
||||
const newLayout = {
|
||||
...layout,
|
||||
overrides: {
|
||||
...layout.overrides,
|
||||
[currentMode]: {
|
||||
...layout.overrides?.[currentMode],
|
||||
hidden: newHidden.length > 0 ? newHidden : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
setLayout(newLayout);
|
||||
saveToHistory(newLayout);
|
||||
setHasChanges(true);
|
||||
}, [layout, currentMode, saveToHistory]);
|
||||
|
||||
// ========================================
|
||||
// 뒤로가기
|
||||
// ========================================
|
||||
const handleBack = useCallback(() => {
|
||||
if (hasChanges) {
|
||||
if (confirm("저장하지 않은 변경사항이 있습니다. 정말 나가시겠습니까?")) {
|
||||
onBackToList();
|
||||
}
|
||||
} else {
|
||||
onBackToList();
|
||||
}
|
||||
}, [hasChanges, onBackToList]);
|
||||
|
||||
// ========================================
|
||||
// 단축키 처리
|
||||
// ========================================
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = e.key.toLowerCase();
|
||||
const isCtrlOrCmd = e.ctrlKey || e.metaKey;
|
||||
|
||||
// Delete / Backspace: 컴포넌트 삭제
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
if (selectedComponentId) {
|
||||
handleDeleteComponent(selectedComponentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Z: Undo
|
||||
if (isCtrlOrCmd && key === "z" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canUndo) undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+Z or Ctrl+Y: Redo
|
||||
if ((isCtrlOrCmd && key === "z" && e.shiftKey) || (isCtrlOrCmd && key === "y")) {
|
||||
e.preventDefault();
|
||||
if (canRedo) redo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+S: 저장
|
||||
if (isCtrlOrCmd && key === "s") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
return;
|
||||
}
|
||||
|
||||
// H키: 선택된 컴포넌트 숨김 (12칸 모드가 아닐 때만)
|
||||
if (key === "h" && !isCtrlOrCmd && selectedComponentId) {
|
||||
e.preventDefault();
|
||||
handleHideComponent(selectedComponentId);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedComponentId, handleDeleteComponent, handleHideComponent, canUndo, canRedo, undo, redo, handleSave]);
|
||||
|
||||
// ========================================
|
||||
// 로딩
|
||||
// ========================================
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 렌더링
|
||||
// ========================================
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b bg-white px-4 py-2">
|
||||
{/* 왼쪽: 뒤로가기 + 화면명 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h2 className="text-sm font-medium">{selectedScreen?.screenName}</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
그리드 레이아웃 (v5)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: Undo/Redo + 저장 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Undo/Redo 버튼 */}
|
||||
<div className="flex items-center gap-1 border-r pr-2 mr-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
title="실행 취소 (Ctrl+Z)"
|
||||
>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
title="다시 실행 (Ctrl+Shift+Z)"
|
||||
>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasChanges}>
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
{isSaving ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<ResizablePanelGroup direction="horizontal" className="flex-1">
|
||||
{/* 왼쪽: 컴포넌트 팔레트 */}
|
||||
<ResizablePanel defaultSize={15} minSize={12} maxSize={20}>
|
||||
<ComponentPalette />
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 중앙: 캔버스 */}
|
||||
<ResizablePanel defaultSize={65}>
|
||||
<PopCanvas
|
||||
layout={layout}
|
||||
selectedComponentId={selectedComponentId}
|
||||
currentMode={currentMode}
|
||||
onModeChange={setCurrentMode}
|
||||
onSelectComponent={setSelectedComponentId}
|
||||
onDropComponent={handleDropComponent}
|
||||
onUpdateComponent={handleUpdateComponent}
|
||||
onDeleteComponent={handleDeleteComponent}
|
||||
onMoveComponent={handleMoveComponent}
|
||||
onResizeComponent={handleResizeComponent}
|
||||
onResizeEnd={handleResizeEnd}
|
||||
onHideComponent={handleHideComponent}
|
||||
onUnhideComponent={handleUnhideComponent}
|
||||
onLockLayout={handleLockLayout}
|
||||
onResetOverride={handleResetOverride}
|
||||
onChangeGapPreset={handleChangeGapPreset}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 오른쪽: 속성 패널 */}
|
||||
<ResizablePanel defaultSize={20} minSize={15} maxSize={30}>
|
||||
<ComponentEditorPanel
|
||||
component={selectedComponent}
|
||||
currentMode={currentMode}
|
||||
onUpdateComponent={
|
||||
selectedComponentId
|
||||
? (updates) => handleUpdateComponent(selectedComponentId, updates)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* DnD(Drag and Drop) 관련 상수
|
||||
*/
|
||||
|
||||
// DnD 아이템 타입
|
||||
export const DND_ITEM_TYPES = {
|
||||
/** 팔레트에서 새 컴포넌트 드래그 */
|
||||
COMPONENT: "POP_COMPONENT",
|
||||
/** 캔버스 내 기존 컴포넌트 이동 */
|
||||
MOVE_COMPONENT: "POP_MOVE_COMPONENT",
|
||||
} as const;
|
||||
|
||||
// 타입 추출
|
||||
export type DndItemType = typeof DND_ITEM_TYPES[keyof typeof DND_ITEM_TYPES];
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "./dnd";
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
// POP 디자이너 컴포넌트 export (v5 그리드 시스템)
|
||||
|
||||
// 타입
|
||||
export * from "./types";
|
||||
|
||||
// 메인 디자이너
|
||||
export { default as PopDesigner } from "./PopDesigner";
|
||||
|
||||
// 캔버스
|
||||
export { default as PopCanvas } from "./PopCanvas";
|
||||
|
||||
// 패널
|
||||
export { default as ComponentEditorPanel } from "./panels/ComponentEditorPanel";
|
||||
|
||||
// 렌더러
|
||||
export { default as PopRenderer } from "./renderers/PopRenderer";
|
||||
|
||||
// 유틸리티
|
||||
export * from "./utils/gridUtils";
|
||||
|
||||
// 핵심 타입 재export (편의)
|
||||
export type {
|
||||
PopLayoutDataV5,
|
||||
PopComponentDefinitionV5,
|
||||
PopComponentType,
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
PopGridConfig,
|
||||
PopDataBinding,
|
||||
PopDataFlow,
|
||||
} from "./types/pop-layout";
|
||||
|
|
@ -0,0 +1,438 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PopComponentDefinitionV5,
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
GRID_BREAKPOINTS,
|
||||
PopComponentType,
|
||||
} from "../types/pop-layout";
|
||||
import {
|
||||
Settings,
|
||||
Database,
|
||||
Eye,
|
||||
Grid3x3,
|
||||
MoveHorizontal,
|
||||
MoveVertical,
|
||||
} from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
// ========================================
|
||||
|
||||
interface ComponentEditorPanelProps {
|
||||
/** 선택된 컴포넌트 */
|
||||
component: PopComponentDefinitionV5 | null;
|
||||
/** 현재 모드 */
|
||||
currentMode: GridMode;
|
||||
/** 컴포넌트 업데이트 */
|
||||
onUpdateComponent?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 타입별 라벨
|
||||
// ========================================
|
||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||
"pop-field": "필드",
|
||||
"pop-button": "버튼",
|
||||
"pop-list": "리스트",
|
||||
"pop-indicator": "인디케이터",
|
||||
"pop-scanner": "스캐너",
|
||||
"pop-numpad": "숫자패드",
|
||||
"pop-spacer": "스페이서",
|
||||
"pop-break": "줄바꿈",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 편집 패널 (v5 그리드 시스템)
|
||||
// ========================================
|
||||
|
||||
export default function ComponentEditorPanel({
|
||||
component,
|
||||
currentMode,
|
||||
onUpdateComponent,
|
||||
className,
|
||||
}: ComponentEditorPanelProps) {
|
||||
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||||
|
||||
// 선택된 컴포넌트 없음
|
||||
if (!component) {
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col bg-white", className)}>
|
||||
<div className="border-b px-4 py-3">
|
||||
<h3 className="text-sm font-medium">속성</h3>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center p-4 text-sm text-muted-foreground">
|
||||
컴포넌트를 선택하세요
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 모드 여부
|
||||
const isDefaultMode = currentMode === "tablet_landscape";
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col bg-white", className)}>
|
||||
{/* 헤더 */}
|
||||
<div className="border-b px-4 py-3">
|
||||
<h3 className="text-sm font-medium">
|
||||
{component.label || COMPONENT_TYPE_LABELS[component.type]}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{component.type}</p>
|
||||
{!isDefaultMode && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
기본 모드(태블릿 가로)에서만 위치 편집 가능
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<Tabs defaultValue="position" className="flex flex-1 flex-col">
|
||||
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-2">
|
||||
<TabsTrigger value="position" className="gap-1 text-xs">
|
||||
<Grid3x3 className="h-3 w-3" />
|
||||
위치
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="gap-1 text-xs">
|
||||
<Settings className="h-3 w-3" />
|
||||
설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visibility" className="gap-1 text-xs">
|
||||
<Eye className="h-3 w-3" />
|
||||
표시
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="data" className="gap-1 text-xs">
|
||||
<Database className="h-3 w-3" />
|
||||
데이터
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 위치 탭 */}
|
||||
<TabsContent value="position" className="flex-1 overflow-auto p-4">
|
||||
<PositionForm
|
||||
component={component}
|
||||
currentMode={currentMode}
|
||||
isDefaultMode={isDefaultMode}
|
||||
columns={breakpoint.columns}
|
||||
onUpdate={onUpdateComponent}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 설정 탭 */}
|
||||
<TabsContent value="settings" className="flex-1 overflow-auto p-4">
|
||||
<ComponentSettingsForm
|
||||
component={component}
|
||||
onUpdate={onUpdateComponent}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 표시 탭 */}
|
||||
<TabsContent value="visibility" className="flex-1 overflow-auto p-4">
|
||||
<VisibilityForm
|
||||
component={component}
|
||||
onUpdate={onUpdateComponent}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 데이터 탭 */}
|
||||
<TabsContent value="data" className="flex-1 overflow-auto p-4">
|
||||
<DataBindingPlaceholder />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 위치 편집 폼
|
||||
// ========================================
|
||||
|
||||
interface PositionFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
currentMode: GridMode;
|
||||
isDefaultMode: boolean;
|
||||
columns: number;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
}
|
||||
|
||||
function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate }: PositionFormProps) {
|
||||
const { position } = component;
|
||||
|
||||
const handlePositionChange = (field: keyof PopGridPosition, value: number) => {
|
||||
// 범위 체크
|
||||
let clampedValue = Math.max(1, value);
|
||||
|
||||
if (field === "col" || field === "colSpan") {
|
||||
clampedValue = Math.min(columns, clampedValue);
|
||||
}
|
||||
if (field === "colSpan" && position.col + clampedValue - 1 > columns) {
|
||||
clampedValue = columns - position.col + 1;
|
||||
}
|
||||
|
||||
onUpdate?.({
|
||||
position: {
|
||||
...position,
|
||||
[field]: clampedValue,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 그리드 정보 */}
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<p className="text-xs font-medium text-gray-700 mb-1">
|
||||
현재 그리드: {GRID_BREAKPOINTS[currentMode].label}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
최대 {columns}칸 × 무제한 행
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 열 위치 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium flex items-center gap-1">
|
||||
<MoveHorizontal className="h-3 w-3" />
|
||||
열 위치 (Col)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={columns}
|
||||
value={position.col}
|
||||
onChange={(e) => handlePositionChange("col", parseInt(e.target.value) || 1)}
|
||||
disabled={!isDefaultMode}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(1~{columns})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 행 위치 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium flex items-center gap-1">
|
||||
<MoveVertical className="h-3 w-3" />
|
||||
행 위치 (Row)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={position.row}
|
||||
onChange={(e) => handlePositionChange("row", parseInt(e.target.value) || 1)}
|
||||
disabled={!isDefaultMode}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
(1~)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-gray-200" />
|
||||
|
||||
{/* 열 크기 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium flex items-center gap-1">
|
||||
<MoveHorizontal className="h-3 w-3" />
|
||||
열 크기 (ColSpan)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={columns}
|
||||
value={position.colSpan}
|
||||
onChange={(e) => handlePositionChange("colSpan", parseInt(e.target.value) || 1)}
|
||||
disabled={!isDefaultMode}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
칸 (1~{columns})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{Math.round((position.colSpan / columns) * 100)}% 너비
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 행 크기 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium flex items-center gap-1">
|
||||
<MoveVertical className="h-3 w-3" />
|
||||
행 크기 (RowSpan)
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={position.rowSpan}
|
||||
onChange={(e) => handlePositionChange("rowSpan", parseInt(e.target.value) || 1)}
|
||||
disabled={!isDefaultMode}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
행
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
높이: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 비활성화 안내 */}
|
||||
{!isDefaultMode && (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-200 p-3">
|
||||
<p className="text-xs text-amber-800">
|
||||
위치 편집은 기본 모드(태블릿 가로)에서만 가능합니다.
|
||||
다른 모드에서는 자동으로 변환됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 설정 폼
|
||||
// ========================================
|
||||
|
||||
interface ComponentSettingsFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
}
|
||||
|
||||
function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) {
|
||||
// PopComponentRegistry에서 configPanel 가져오기
|
||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||
const ConfigPanel = registeredComp?.configPanel;
|
||||
|
||||
// config 업데이트 핸들러
|
||||
const handleConfigUpdate = (newConfig: any) => {
|
||||
onUpdate?.({ config: newConfig });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 라벨 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">라벨</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={component.label || ""}
|
||||
onChange={(e) => onUpdate?.({ label: e.target.value })}
|
||||
placeholder="컴포넌트 이름"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 컴포넌트 타입별 설정 패널 */}
|
||||
{ConfigPanel ? (
|
||||
<ConfigPanel
|
||||
config={component.config || {}}
|
||||
onUpdate={handleConfigUpdate}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg bg-gray-50 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{component.type} 전용 설정이 없습니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 표시/숨김 폼
|
||||
// ========================================
|
||||
|
||||
interface VisibilityFormProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||
}
|
||||
|
||||
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
|
||||
const modes: Array<{ key: GridMode; label: string }> = [
|
||||
{ key: "tablet_landscape", label: "태블릿 가로 (12칸)" },
|
||||
{ key: "tablet_portrait", label: "태블릿 세로 (8칸)" },
|
||||
{ key: "mobile_landscape", label: "모바일 가로 (6칸)" },
|
||||
{ key: "mobile_portrait", label: "모바일 세로 (4칸)" },
|
||||
];
|
||||
|
||||
const handleVisibilityChange = (mode: GridMode, visible: boolean) => {
|
||||
onUpdate?.({
|
||||
visibility: {
|
||||
...component.visibility,
|
||||
[mode]: visible,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-medium">모드별 표시 설정</Label>
|
||||
|
||||
{modes.map((mode) => {
|
||||
const isVisible = component.visibility?.[mode.key] !== false;
|
||||
|
||||
return (
|
||||
<div key={mode.key} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`visibility-${mode.key}`}
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) =>
|
||||
handleVisibilityChange(mode.key, checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`visibility-${mode.key}`}
|
||||
className="text-xs cursor-pointer"
|
||||
>
|
||||
{mode.label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-blue-50 border border-blue-200 p-3">
|
||||
<p className="text-xs text-blue-800">
|
||||
체크 해제하면 해당 모드에서 컴포넌트가 숨겨집니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 데이터 바인딩 플레이스홀더
|
||||
// ========================================
|
||||
|
||||
function DataBindingPlaceholder() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-gray-50 p-4 text-center">
|
||||
<Database className="mx-auto mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-gray-700">데이터 바인딩</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Phase 4에서 구현 예정
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
import { useDrag } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PopComponentType } from "../types/pop-layout";
|
||||
import { Square, FileText } from "lucide-react";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
|
||||
// 컴포넌트 정의
|
||||
interface PaletteItem {
|
||||
type: PopComponentType;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PALETTE_ITEMS: PaletteItem[] = [
|
||||
{
|
||||
type: "pop-sample",
|
||||
label: "샘플 박스",
|
||||
icon: Square,
|
||||
description: "크기 조정 테스트용",
|
||||
},
|
||||
{
|
||||
type: "pop-text",
|
||||
label: "텍스트",
|
||||
icon: FileText,
|
||||
description: "텍스트, 시간, 이미지 표시",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
function DraggablePaletteItem({ item }: { item: PaletteItem }) {
|
||||
const [{ isDragging }, drag] = useDrag(
|
||||
() => ({
|
||||
type: DND_ITEM_TYPES.COMPONENT,
|
||||
item: { type: DND_ITEM_TYPES.COMPONENT, componentType: item.type },
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
}),
|
||||
[item.type]
|
||||
);
|
||||
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={drag}
|
||||
className={cn(
|
||||
"flex cursor-grab items-center gap-3 rounded-md border bg-white p-3",
|
||||
"transition-all hover:border-primary hover:shadow-sm",
|
||||
isDragging && "opacity-50 cursor-grabbing"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded bg-muted">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{item.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 컴포넌트 팔레트 패널
|
||||
export default function ComponentPalette() {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-gray-50">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b bg-white px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">컴포넌트</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
드래그하여 캔버스에 배치
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 컴포넌트 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<div className="space-y-2">
|
||||
{PALETTE_ITEMS.map((item) => (
|
||||
<DraggablePaletteItem key={item.type} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 안내 */}
|
||||
<div className="border-t bg-white px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tip: 캔버스의 그리드 칸에 드롭하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// POP 디자이너 패널 export (v5 그리드 시스템)
|
||||
export { default as ComponentEditorPanel } from "./ComponentEditorPanel";
|
||||
export { default as ComponentPalette } from "./ComponentPalette";
|
||||
|
|
@ -0,0 +1,564 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
import {
|
||||
PopLayoutDataV5,
|
||||
PopComponentDefinitionV5,
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
GRID_BREAKPOINTS,
|
||||
GridBreakpoint,
|
||||
detectGridMode,
|
||||
PopComponentType,
|
||||
} from "../types/pop-layout";
|
||||
import {
|
||||
convertAndResolvePositions,
|
||||
isOverlapping,
|
||||
getAllEffectivePositions,
|
||||
} from "../utils/gridUtils";
|
||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
// ========================================
|
||||
|
||||
interface PopRendererProps {
|
||||
/** v5 레이아웃 데이터 */
|
||||
layout: PopLayoutDataV5;
|
||||
/** 현재 뷰포트 너비 */
|
||||
viewportWidth: number;
|
||||
/** 현재 모드 (자동 감지 또는 수동 지정) */
|
||||
currentMode?: GridMode;
|
||||
/** 디자인 모드 여부 */
|
||||
isDesignMode?: boolean;
|
||||
/** 그리드 가이드 표시 여부 */
|
||||
showGridGuide?: boolean;
|
||||
/** 선택된 컴포넌트 ID */
|
||||
selectedComponentId?: string | null;
|
||||
/** 컴포넌트 클릭 */
|
||||
onComponentClick?: (componentId: string) => void;
|
||||
/** 배경 클릭 */
|
||||
onBackgroundClick?: () => void;
|
||||
/** 컴포넌트 이동 */
|
||||
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||
/** 컴포넌트 크기 조정 */
|
||||
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||
/** 컴포넌트 크기 조정 완료 (히스토리 저장용) */
|
||||
onComponentResizeEnd?: (componentId: string) => void;
|
||||
/** Gap 오버라이드 (Gap 프리셋 적용된 값) */
|
||||
overrideGap?: number;
|
||||
/** Padding 오버라이드 (Gap 프리셋 적용된 값) */
|
||||
overridePadding?: number;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 타입별 라벨
|
||||
// ========================================
|
||||
|
||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||
"pop-sample": "샘플",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// PopRenderer: v5 그리드 렌더러
|
||||
// ========================================
|
||||
|
||||
export default function PopRenderer({
|
||||
layout,
|
||||
viewportWidth,
|
||||
currentMode,
|
||||
isDesignMode = false,
|
||||
showGridGuide = true,
|
||||
selectedComponentId,
|
||||
onComponentClick,
|
||||
onBackgroundClick,
|
||||
onComponentMove,
|
||||
onComponentResize,
|
||||
onComponentResizeEnd,
|
||||
overrideGap,
|
||||
overridePadding,
|
||||
className,
|
||||
}: PopRendererProps) {
|
||||
const { gridConfig, components, overrides } = layout;
|
||||
|
||||
// 현재 모드 (자동 감지 또는 지정)
|
||||
const mode = currentMode || detectGridMode(viewportWidth);
|
||||
const breakpoint = GRID_BREAKPOINTS[mode];
|
||||
|
||||
// Gap/Padding: 오버라이드 우선, 없으면 기본값 사용
|
||||
const finalGap = overrideGap !== undefined ? overrideGap : breakpoint.gap;
|
||||
const finalPadding = overridePadding !== undefined ? overridePadding : breakpoint.padding;
|
||||
|
||||
// 숨김 컴포넌트 ID 목록
|
||||
const hiddenIds = overrides?.[mode]?.hidden || [];
|
||||
|
||||
// 동적 행 수 계산 (가이드 셀 + Grid 스타일 공유, 숨김 컴포넌트 제외)
|
||||
const dynamicRowCount = useMemo(() => {
|
||||
const visibleComps = Object.values(components).filter(
|
||||
comp => !hiddenIds.includes(comp.id)
|
||||
);
|
||||
const maxRowEnd = visibleComps.reduce((max, comp) => {
|
||||
const override = overrides?.[mode]?.positions?.[comp.id];
|
||||
const pos = override ? { ...comp.position, ...override } : comp.position;
|
||||
return Math.max(max, pos.row + pos.rowSpan);
|
||||
}, 1);
|
||||
return Math.max(10, maxRowEnd + 3);
|
||||
}, [components, overrides, mode, hiddenIds]);
|
||||
|
||||
// CSS Grid 스타일 (행 높이 강제 고정: 셀 크기 = 컴포넌트 크기의 기준)
|
||||
const gridStyle = useMemo((): React.CSSProperties => ({
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`,
|
||||
gridAutoRows: `${breakpoint.rowHeight}px`,
|
||||
gap: `${finalGap}px`,
|
||||
padding: `${finalPadding}px`,
|
||||
minHeight: "100%",
|
||||
backgroundColor: "#ffffff",
|
||||
position: "relative",
|
||||
}), [breakpoint, finalGap, finalPadding, dynamicRowCount]);
|
||||
|
||||
// 그리드 가이드 셀 생성 (동적 행 수)
|
||||
const gridCells = useMemo(() => {
|
||||
if (!isDesignMode || !showGridGuide) return [];
|
||||
|
||||
const cells = [];
|
||||
for (let row = 1; row <= dynamicRowCount; row++) {
|
||||
for (let col = 1; col <= breakpoint.columns; col++) {
|
||||
cells.push({
|
||||
id: `cell-${col}-${row}`,
|
||||
col,
|
||||
row
|
||||
});
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]);
|
||||
|
||||
// visibility 체크
|
||||
const isVisible = (comp: PopComponentDefinitionV5): boolean => {
|
||||
if (!comp.visibility) return true;
|
||||
const modeVisibility = comp.visibility[mode];
|
||||
return modeVisibility !== false;
|
||||
};
|
||||
|
||||
// 자동 재배치된 위치 계산 (오버라이드 없을 때)
|
||||
const autoResolvedPositions = useMemo(() => {
|
||||
const componentsArray = Object.entries(components).map(([id, comp]) => ({
|
||||
id,
|
||||
position: comp.position,
|
||||
}));
|
||||
|
||||
return convertAndResolvePositions(componentsArray, mode);
|
||||
}, [components, mode]);
|
||||
|
||||
// 위치 변환 (12칸 기준 → 현재 모드 칸 수)
|
||||
const convertPosition = (position: PopGridPosition): React.CSSProperties => {
|
||||
return {
|
||||
gridColumn: `${position.col} / span ${position.colSpan}`,
|
||||
gridRow: `${position.row} / span ${position.rowSpan}`,
|
||||
};
|
||||
};
|
||||
|
||||
// 오버라이드 적용 또는 자동 재배치
|
||||
const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => {
|
||||
// 1순위: 오버라이드가 있으면 사용
|
||||
const override = overrides?.[mode]?.positions?.[comp.id];
|
||||
if (override) {
|
||||
return { ...comp.position, ...override };
|
||||
}
|
||||
|
||||
// 2순위: 자동 재배치된 위치 사용
|
||||
const autoResolved = autoResolvedPositions.find(p => p.id === comp.id);
|
||||
if (autoResolved) {
|
||||
return autoResolved.position;
|
||||
}
|
||||
|
||||
// 3순위: 원본 위치 (12칸 모드)
|
||||
return comp.position;
|
||||
};
|
||||
|
||||
// 오버라이드 숨김 체크
|
||||
const isHiddenByOverride = (comp: PopComponentDefinitionV5): boolean => {
|
||||
return overrides?.[mode]?.hidden?.includes(comp.id) ?? false;
|
||||
};
|
||||
|
||||
// 모든 컴포넌트의 유효 위치 계산 (리사이즈 겹침 검사용)
|
||||
const effectivePositionsMap = useMemo(() =>
|
||||
getAllEffectivePositions(layout, mode),
|
||||
[layout, mode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative min-h-full w-full", className)}
|
||||
style={gridStyle}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onBackgroundClick?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 그리드 가이드 셀 (실제 DOM) */}
|
||||
{gridCells.map(cell => (
|
||||
<div
|
||||
key={cell.id}
|
||||
className="pointer-events-none border border-dashed border-blue-300/40"
|
||||
style={{
|
||||
gridColumn: cell.col,
|
||||
gridRow: cell.row,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 컴포넌트 렌더링 (z-index로 위에 표시) */}
|
||||
{/* v5.1: 자동 줄바꿈으로 모든 컴포넌트가 그리드 안에 배치됨 */}
|
||||
{Object.values(components).map((comp) => {
|
||||
// visibility 체크
|
||||
if (!isVisible(comp)) return null;
|
||||
|
||||
// 오버라이드 숨김 체크
|
||||
if (isHiddenByOverride(comp)) return null;
|
||||
|
||||
const position = getEffectivePosition(comp);
|
||||
const positionStyle = convertPosition(position);
|
||||
const isSelected = selectedComponentId === comp.id;
|
||||
|
||||
// 디자인 모드에서는 드래그 가능한 컴포넌트, 뷰어 모드에서는 일반 컴포넌트
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<DraggableComponent
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
position={position}
|
||||
positionStyle={positionStyle}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
breakpoint={breakpoint}
|
||||
viewportWidth={viewportWidth}
|
||||
allEffectivePositions={effectivePositionsMap}
|
||||
effectiveGap={finalGap}
|
||||
effectivePadding={finalPadding}
|
||||
onComponentClick={onComponentClick}
|
||||
onComponentMove={onComponentMove}
|
||||
onComponentResize={onComponentResize}
|
||||
onComponentResizeEnd={onComponentResizeEnd}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 뷰어 모드: 드래그 없는 일반 렌더링
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="relative rounded-lg border-2 border-gray-200 bg-white transition-all overflow-hidden z-10"
|
||||
style={positionStyle}
|
||||
>
|
||||
<ComponentContent
|
||||
component={comp}
|
||||
effectivePosition={position}
|
||||
isDesignMode={false}
|
||||
isSelected={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 드래그 가능한 컴포넌트 래퍼
|
||||
// ========================================
|
||||
|
||||
interface DraggableComponentProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
position: PopGridPosition;
|
||||
positionStyle: React.CSSProperties;
|
||||
isSelected: boolean;
|
||||
isDesignMode: boolean;
|
||||
breakpoint: GridBreakpoint;
|
||||
viewportWidth: number;
|
||||
allEffectivePositions: Map<string, PopGridPosition>;
|
||||
effectiveGap: number;
|
||||
effectivePadding: number;
|
||||
onComponentClick?: (componentId: string) => void;
|
||||
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||
onComponentResizeEnd?: (componentId: string) => void;
|
||||
}
|
||||
|
||||
function DraggableComponent({
|
||||
component,
|
||||
position,
|
||||
positionStyle,
|
||||
isSelected,
|
||||
isDesignMode,
|
||||
breakpoint,
|
||||
viewportWidth,
|
||||
allEffectivePositions,
|
||||
effectiveGap,
|
||||
effectivePadding,
|
||||
onComponentClick,
|
||||
onComponentMove,
|
||||
onComponentResize,
|
||||
onComponentResizeEnd,
|
||||
}: DraggableComponentProps) {
|
||||
const [{ isDragging }, drag] = useDrag(
|
||||
() => ({
|
||||
type: DND_ITEM_TYPES.MOVE_COMPONENT,
|
||||
item: {
|
||||
componentId: component.id,
|
||||
originalPosition: position
|
||||
},
|
||||
canDrag: isDesignMode,
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
}),
|
||||
}),
|
||||
[component.id, position, isDesignMode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={isDesignMode ? drag : null}
|
||||
className={cn(
|
||||
"relative rounded-lg border-2 transition-all overflow-hidden z-10 bg-white",
|
||||
isSelected
|
||||
? "border-primary ring-2 ring-primary/30"
|
||||
: "border-gray-200",
|
||||
isDesignMode && "cursor-move hover:border-gray-300 hover:shadow-sm",
|
||||
isDragging && "opacity-50"
|
||||
)}
|
||||
style={positionStyle}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onComponentClick?.(component.id);
|
||||
}}
|
||||
>
|
||||
<ComponentContent
|
||||
component={component}
|
||||
effectivePosition={position}
|
||||
isDesignMode={isDesignMode}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
|
||||
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
|
||||
{isDesignMode && isSelected && onComponentResize && (
|
||||
<ResizeHandles
|
||||
component={component}
|
||||
position={position}
|
||||
breakpoint={breakpoint}
|
||||
viewportWidth={viewportWidth}
|
||||
allEffectivePositions={allEffectivePositions}
|
||||
effectiveGap={effectiveGap}
|
||||
effectivePadding={effectivePadding}
|
||||
onResize={onComponentResize}
|
||||
onResizeEnd={onComponentResizeEnd}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 리사이즈 핸들
|
||||
// ========================================
|
||||
|
||||
interface ResizeHandlesProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
position: PopGridPosition;
|
||||
breakpoint: GridBreakpoint;
|
||||
viewportWidth: number;
|
||||
allEffectivePositions: Map<string, PopGridPosition>;
|
||||
effectiveGap: number;
|
||||
effectivePadding: number;
|
||||
onResize: (componentId: string, newPosition: PopGridPosition) => void;
|
||||
onResizeEnd?: (componentId: string) => void;
|
||||
}
|
||||
|
||||
function ResizeHandles({
|
||||
component,
|
||||
position,
|
||||
breakpoint,
|
||||
viewportWidth,
|
||||
allEffectivePositions,
|
||||
effectiveGap,
|
||||
effectivePadding,
|
||||
onResize,
|
||||
onResizeEnd,
|
||||
}: ResizeHandlesProps) {
|
||||
const handleMouseDown = (direction: 'right' | 'bottom' | 'corner') => (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
const startColSpan = position.colSpan;
|
||||
const startRowSpan = position.rowSpan;
|
||||
|
||||
// 그리드 셀 크기 동적 계산 (Gap 프리셋 적용된 값 사용)
|
||||
// 사용 가능한 너비 = 뷰포트 너비 - 양쪽 패딩 - gap*(칸수-1)
|
||||
const availableWidth = viewportWidth - effectivePadding * 2 - effectiveGap * (breakpoint.columns - 1);
|
||||
const cellWidth = availableWidth / breakpoint.columns + effectiveGap; // 셀 너비 + gap 단위
|
||||
const cellHeight = breakpoint.rowHeight + effectiveGap;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
|
||||
let newColSpan = startColSpan;
|
||||
let newRowSpan = startRowSpan;
|
||||
|
||||
if (direction === 'right' || direction === 'corner') {
|
||||
const colDelta = Math.round(deltaX / cellWidth);
|
||||
newColSpan = Math.max(1, startColSpan + colDelta);
|
||||
// 최대 칸 수 제한
|
||||
newColSpan = Math.min(newColSpan, breakpoint.columns - position.col + 1);
|
||||
}
|
||||
|
||||
if (direction === 'bottom' || direction === 'corner') {
|
||||
const rowDelta = Math.round(deltaY / cellHeight);
|
||||
newRowSpan = Math.max(1, startRowSpan + rowDelta);
|
||||
}
|
||||
|
||||
// 변경사항이 있으면 업데이트
|
||||
if (newColSpan !== position.colSpan || newRowSpan !== position.rowSpan) {
|
||||
const newPosition: PopGridPosition = {
|
||||
...position,
|
||||
colSpan: newColSpan,
|
||||
rowSpan: newRowSpan,
|
||||
};
|
||||
|
||||
// 유효 위치 기반 겹침 검사 (다른 컴포넌트와)
|
||||
const hasOverlap = Array.from(allEffectivePositions.entries()).some(
|
||||
([id, pos]) => {
|
||||
if (id === component.id) return false; // 자기 자신 제외
|
||||
return isOverlapping(newPosition, pos);
|
||||
}
|
||||
);
|
||||
|
||||
// 겹치지 않을 때만 리사이즈 적용
|
||||
if (!hasOverlap) {
|
||||
onResize(component.id, newPosition);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
// 리사이즈 완료 알림 (히스토리 저장용)
|
||||
onResizeEnd?.(component.id);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 오른쪽 핸들 (가로 크기) */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-2 cursor-ew-resize bg-primary/20 hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={handleMouseDown('right')}
|
||||
style={{ right: '-4px' }}
|
||||
/>
|
||||
|
||||
{/* 아래쪽 핸들 (세로 크기) */}
|
||||
<div
|
||||
className="absolute left-0 right-0 h-2 cursor-ns-resize bg-primary/20 hover:bg-primary/50 transition-colors"
|
||||
onMouseDown={handleMouseDown('bottom')}
|
||||
style={{ bottom: '-4px' }}
|
||||
/>
|
||||
|
||||
{/* 오른쪽 아래 모서리 (가로+세로) */}
|
||||
<div
|
||||
className="absolute h-3 w-3 cursor-nwse-resize bg-primary hover:bg-primary/80 transition-colors rounded-sm"
|
||||
onMouseDown={handleMouseDown('corner')}
|
||||
style={{ right: '-6px', bottom: '-6px' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 컴포넌트 내용 렌더링
|
||||
// ========================================
|
||||
|
||||
interface ComponentContentProps {
|
||||
component: PopComponentDefinitionV5;
|
||||
effectivePosition: PopGridPosition;
|
||||
isDesignMode: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) {
|
||||
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||
|
||||
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
|
||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||
const PreviewComponent = registeredComp?.preview;
|
||||
|
||||
// 디자인 모드: 미리보기 컴포넌트 또는 플레이스홀더 표시
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 shrink-0 items-center border-b px-2",
|
||||
isSelected ? "bg-primary/10 border-primary" : "bg-gray-50 border-gray-200"
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"text-[10px] font-medium truncate",
|
||||
isSelected ? "text-primary" : "text-gray-600"
|
||||
)}>
|
||||
{component.label || typeLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */}
|
||||
<div className="flex flex-1 items-center justify-center overflow-hidden">
|
||||
{PreviewComponent ? (
|
||||
<PreviewComponent config={component.config} />
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 p-2">
|
||||
{typeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 위치 정보 표시 (유효 위치 사용) */}
|
||||
<div className="absolute bottom-1 right-1 text-[9px] text-gray-400 bg-white/80 px-1 rounded">
|
||||
{effectivePosition.col},{effectivePosition.row}
|
||||
({effectivePosition.colSpan}×{effectivePosition.rowSpan})
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 실제 모드: 컴포넌트 렌더링
|
||||
return renderActualComponent(component);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 실제 컴포넌트 렌더링 (뷰어 모드)
|
||||
// ========================================
|
||||
|
||||
function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode {
|
||||
const typeLabel = COMPONENT_TYPE_LABELS[component.type];
|
||||
|
||||
// 샘플 박스 렌더링
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-2">
|
||||
<span className="text-xs text-gray-500">{component.label || typeLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
// POP 레이아웃 렌더러 모듈 (v5 그리드 시스템)
|
||||
// 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러
|
||||
|
||||
export { default as PopRenderer } from "./PopRenderer";
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
// POP 디자이너 타입 export
|
||||
export * from "./pop-layout";
|
||||
|
|
@ -0,0 +1,395 @@
|
|||
// POP 디자이너 레이아웃 타입 정의
|
||||
// v5.0: CSS Grid 기반 그리드 시스템
|
||||
// 2024-02 버전 통합: v1~v4 제거, v5 단일 버전
|
||||
|
||||
// ========================================
|
||||
// 공통 타입
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* POP 컴포넌트 타입
|
||||
*/
|
||||
export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트
|
||||
|
||||
/**
|
||||
* 데이터 흐름 정의
|
||||
*/
|
||||
export interface PopDataFlow {
|
||||
connections: PopDataConnection[];
|
||||
}
|
||||
|
||||
export interface PopDataConnection {
|
||||
id: string;
|
||||
sourceComponent: string;
|
||||
sourceField: string;
|
||||
targetComponent: string;
|
||||
targetField: string;
|
||||
transformType?: "direct" | "calculate" | "lookup";
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 바인딩
|
||||
*/
|
||||
export interface PopDataBinding {
|
||||
entityField?: string;
|
||||
defaultValue?: any;
|
||||
format?: string;
|
||||
validation?: {
|
||||
required?: boolean;
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 스타일 프리셋
|
||||
*/
|
||||
export interface PopStylePreset {
|
||||
theme?: "default" | "primary" | "success" | "warning" | "danger";
|
||||
size?: "sm" | "md" | "lg";
|
||||
variant?: "solid" | "outline" | "ghost";
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 설정
|
||||
*/
|
||||
export interface PopComponentConfig {
|
||||
// 필드 설정
|
||||
inputType?: "text" | "number" | "date" | "select" | "barcode";
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
|
||||
// 버튼 설정
|
||||
action?: "submit" | "scan" | "navigate" | "custom";
|
||||
targetScreen?: string;
|
||||
|
||||
// 리스트 설정
|
||||
columns?: { field: string; label: string; width?: number }[];
|
||||
selectable?: boolean;
|
||||
|
||||
// 인디케이터 설정
|
||||
indicatorType?: "status" | "progress" | "count";
|
||||
|
||||
// 스캐너 설정
|
||||
scanType?: "barcode" | "qr" | "both";
|
||||
autoSubmit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타데이터
|
||||
*/
|
||||
export interface PopLayoutMetadata {
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// v5 그리드 기반 레이아웃
|
||||
// ========================================
|
||||
// 핵심: CSS Grid로 정확한 위치 지정
|
||||
// - 열/행 좌표로 배치 (col, row)
|
||||
// - 칸 단위 크기 (colSpan, rowSpan)
|
||||
// - Material Design 브레이크포인트 기반
|
||||
|
||||
/**
|
||||
* 그리드 모드 (4가지)
|
||||
*/
|
||||
export type GridMode =
|
||||
| "mobile_portrait" // 4칸
|
||||
| "mobile_landscape" // 6칸
|
||||
| "tablet_portrait" // 8칸
|
||||
| "tablet_landscape"; // 12칸 (기본)
|
||||
|
||||
/**
|
||||
* 그리드 브레이크포인트 설정
|
||||
*/
|
||||
export interface GridBreakpoint {
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
columns: number;
|
||||
rowHeight: number;
|
||||
gap: number;
|
||||
padding: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 브레이크포인트 상수
|
||||
* 업계 표준 (768px, 1024px) + 실제 기기 커버리지 기반
|
||||
*/
|
||||
export const GRID_BREAKPOINTS: Record<GridMode, GridBreakpoint> = {
|
||||
// 스마트폰 세로 (iPhone SE ~ Galaxy S25 Ultra)
|
||||
mobile_portrait: {
|
||||
maxWidth: 479,
|
||||
columns: 4,
|
||||
rowHeight: 40,
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
label: "모바일 세로 (4칸)",
|
||||
},
|
||||
|
||||
// 스마트폰 가로 + 소형 태블릿
|
||||
mobile_landscape: {
|
||||
minWidth: 480,
|
||||
maxWidth: 767,
|
||||
columns: 6,
|
||||
rowHeight: 44,
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
label: "모바일 가로 (6칸)",
|
||||
},
|
||||
|
||||
// 태블릿 세로 (iPad Mini ~ iPad Pro)
|
||||
tablet_portrait: {
|
||||
minWidth: 768,
|
||||
maxWidth: 1023,
|
||||
columns: 8,
|
||||
rowHeight: 48,
|
||||
gap: 12,
|
||||
padding: 16,
|
||||
label: "태블릿 세로 (8칸)",
|
||||
},
|
||||
|
||||
// 태블릿 가로 + 데스크톱 (기본)
|
||||
tablet_landscape: {
|
||||
minWidth: 1024,
|
||||
columns: 12,
|
||||
rowHeight: 48,
|
||||
gap: 16,
|
||||
padding: 24,
|
||||
label: "태블릿 가로 (12칸)",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 기본 그리드 모드
|
||||
*/
|
||||
export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape";
|
||||
|
||||
/**
|
||||
* 뷰포트 너비로 모드 감지
|
||||
* GRID_BREAKPOINTS와 일치하는 브레이크포인트 사용
|
||||
*/
|
||||
export function detectGridMode(viewportWidth: number): GridMode {
|
||||
if (viewportWidth < 480) return "mobile_portrait";
|
||||
if (viewportWidth < 768) return "mobile_landscape";
|
||||
if (viewportWidth < 1024) return "tablet_portrait";
|
||||
return "tablet_landscape";
|
||||
}
|
||||
|
||||
/**
|
||||
* v5 레이아웃 (그리드 기반)
|
||||
*/
|
||||
export interface PopLayoutDataV5 {
|
||||
version: "pop-5.0";
|
||||
|
||||
// 그리드 설정
|
||||
gridConfig: PopGridConfig;
|
||||
|
||||
// 컴포넌트 정의 (ID → 정의)
|
||||
components: Record<string, PopComponentDefinitionV5>;
|
||||
|
||||
// 데이터 흐름
|
||||
dataFlow: PopDataFlow;
|
||||
|
||||
// 전역 설정
|
||||
settings: PopGlobalSettingsV5;
|
||||
|
||||
// 메타데이터
|
||||
metadata?: PopLayoutMetadata;
|
||||
|
||||
// 모드별 오버라이드 (위치 변경용)
|
||||
overrides?: {
|
||||
mobile_portrait?: PopModeOverrideV5;
|
||||
mobile_landscape?: PopModeOverrideV5;
|
||||
tablet_portrait?: PopModeOverrideV5;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리드 설정
|
||||
*/
|
||||
export interface PopGridConfig {
|
||||
// 행 높이 (px) - 1행의 기본 높이
|
||||
rowHeight: number; // 기본 48px
|
||||
|
||||
// 간격 (px)
|
||||
gap: number; // 기본 8px
|
||||
|
||||
// 패딩 (px)
|
||||
padding: number; // 기본 16px
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리드 위치 (열/행 좌표)
|
||||
*/
|
||||
export interface PopGridPosition {
|
||||
col: number; // 시작 열 (1부터, 최대 12)
|
||||
row: number; // 시작 행 (1부터)
|
||||
colSpan: number; // 차지할 열 수 (1~12)
|
||||
rowSpan: number; // 차지할 행 수 (1~)
|
||||
}
|
||||
|
||||
/**
|
||||
* v5 컴포넌트 정의
|
||||
*/
|
||||
export interface PopComponentDefinitionV5 {
|
||||
id: string;
|
||||
type: PopComponentType;
|
||||
label?: string;
|
||||
|
||||
// 위치 (열/행 좌표) - 기본 모드(태블릿 가로 12칸) 기준
|
||||
position: PopGridPosition;
|
||||
|
||||
// 모드별 표시/숨김
|
||||
visibility?: {
|
||||
tablet_landscape?: boolean;
|
||||
tablet_portrait?: boolean;
|
||||
mobile_landscape?: boolean;
|
||||
mobile_portrait?: boolean;
|
||||
};
|
||||
|
||||
// 기존 속성
|
||||
dataBinding?: PopDataBinding;
|
||||
style?: PopStylePreset;
|
||||
config?: PopComponentConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gap 프리셋 타입
|
||||
*/
|
||||
export type GapPreset = "narrow" | "medium" | "wide";
|
||||
|
||||
/**
|
||||
* Gap 프리셋 설정
|
||||
*/
|
||||
export interface GapPresetConfig {
|
||||
multiplier: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gap 프리셋 상수
|
||||
*/
|
||||
export const GAP_PRESETS: Record<GapPreset, GapPresetConfig> = {
|
||||
narrow: { multiplier: 0.5, label: "좁게" },
|
||||
medium: { multiplier: 1.0, label: "보통" },
|
||||
wide: { multiplier: 1.5, label: "넓게" },
|
||||
};
|
||||
|
||||
/**
|
||||
* v5 전역 설정
|
||||
*/
|
||||
export interface PopGlobalSettingsV5 {
|
||||
// 터치 최소 크기 (px)
|
||||
touchTargetMin: number; // 기본 48
|
||||
|
||||
// 모드
|
||||
mode: "normal" | "industrial";
|
||||
|
||||
// Gap 프리셋
|
||||
gapPreset: GapPreset; // 기본 "medium"
|
||||
}
|
||||
|
||||
/**
|
||||
* v5 모드별 오버라이드
|
||||
*/
|
||||
export interface PopModeOverrideV5 {
|
||||
// 컴포넌트별 위치 오버라이드
|
||||
positions?: Record<string, Partial<PopGridPosition>>;
|
||||
|
||||
// 컴포넌트별 숨김
|
||||
hidden?: string[];
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// v5 유틸리티 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 빈 v5 레이아웃 생성
|
||||
*/
|
||||
export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
|
||||
version: "pop-5.0",
|
||||
gridConfig: {
|
||||
rowHeight: 48,
|
||||
gap: 8,
|
||||
padding: 16,
|
||||
},
|
||||
components: {},
|
||||
dataFlow: { connections: [] },
|
||||
settings: {
|
||||
touchTargetMin: 48,
|
||||
mode: "normal",
|
||||
gapPreset: "medium",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* v5 레이아웃 여부 확인
|
||||
*/
|
||||
export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
|
||||
return layout?.version === "pop-5.0";
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트 타입별 기본 크기 (칸 단위)
|
||||
*/
|
||||
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
||||
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-text": { colSpan: 3, rowSpan: 1 },
|
||||
};
|
||||
|
||||
/**
|
||||
* v5 컴포넌트 정의 생성
|
||||
*/
|
||||
export const createComponentDefinitionV5 = (
|
||||
id: string,
|
||||
type: PopComponentType,
|
||||
position: PopGridPosition,
|
||||
label?: string
|
||||
): PopComponentDefinitionV5 => ({
|
||||
id,
|
||||
type,
|
||||
label,
|
||||
position,
|
||||
});
|
||||
|
||||
/**
|
||||
* v5 레이아웃에 컴포넌트 추가
|
||||
*/
|
||||
export const addComponentToV5Layout = (
|
||||
layout: PopLayoutDataV5,
|
||||
componentId: string,
|
||||
type: PopComponentType,
|
||||
position: PopGridPosition,
|
||||
label?: string
|
||||
): PopLayoutDataV5 => {
|
||||
const newLayout = { ...layout };
|
||||
|
||||
// 컴포넌트 정의 추가
|
||||
newLayout.components = {
|
||||
...newLayout.components,
|
||||
[componentId]: createComponentDefinitionV5(componentId, type, position, label),
|
||||
};
|
||||
|
||||
return newLayout;
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 레거시 타입 별칭 (하위 호환 - 추후 제거)
|
||||
// ========================================
|
||||
// 기존 코드에서 import 오류 방지용
|
||||
|
||||
/** @deprecated v5에서는 PopLayoutDataV5 사용 */
|
||||
export type PopLayoutData = PopLayoutDataV5;
|
||||
|
||||
/** @deprecated v5에서는 PopComponentDefinitionV5 사용 */
|
||||
export type PopComponentDefinition = PopComponentDefinitionV5;
|
||||
|
||||
/** @deprecated v5에서는 PopGridPosition 사용 */
|
||||
export type GridPosition = PopGridPosition;
|
||||
|
|
@ -0,0 +1,562 @@
|
|||
import {
|
||||
PopGridPosition,
|
||||
GridMode,
|
||||
GRID_BREAKPOINTS,
|
||||
GridBreakpoint,
|
||||
GapPreset,
|
||||
GAP_PRESETS,
|
||||
PopLayoutDataV5,
|
||||
PopComponentDefinitionV5,
|
||||
} from "../types/pop-layout";
|
||||
|
||||
// ========================================
|
||||
// Gap/Padding 조정
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Gap 프리셋에 따라 breakpoint의 gap/padding 조정
|
||||
*
|
||||
* @param base 기본 breakpoint 설정
|
||||
* @param preset Gap 프리셋 ("narrow" | "medium" | "wide")
|
||||
* @returns 조정된 breakpoint (gap, padding 계산됨)
|
||||
*/
|
||||
export function getAdjustedBreakpoint(
|
||||
base: GridBreakpoint,
|
||||
preset: GapPreset
|
||||
): GridBreakpoint {
|
||||
const multiplier = GAP_PRESETS[preset]?.multiplier || 1.0;
|
||||
|
||||
return {
|
||||
...base,
|
||||
gap: Math.round(base.gap * multiplier),
|
||||
padding: Math.max(8, Math.round(base.padding * multiplier)), // 최소 8px
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 그리드 위치 변환
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 12칸 기준 위치를 다른 모드로 변환
|
||||
*/
|
||||
export function convertPositionToMode(
|
||||
position: PopGridPosition,
|
||||
targetMode: GridMode
|
||||
): PopGridPosition {
|
||||
const sourceColumns = 12;
|
||||
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
|
||||
|
||||
// 같은 칸 수면 그대로 반환
|
||||
if (sourceColumns === targetColumns) {
|
||||
return position;
|
||||
}
|
||||
|
||||
const ratio = targetColumns / sourceColumns;
|
||||
|
||||
// 열 위치 변환
|
||||
let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1);
|
||||
let newColSpan = Math.max(1, Math.round(position.colSpan * ratio));
|
||||
|
||||
// 범위 초과 방지
|
||||
if (newCol > targetColumns) {
|
||||
newCol = 1;
|
||||
}
|
||||
if (newCol + newColSpan - 1 > targetColumns) {
|
||||
newColSpan = targetColumns - newCol + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
col: newCol,
|
||||
row: position.row,
|
||||
colSpan: Math.max(1, newColSpan),
|
||||
rowSpan: position.rowSpan,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 컴포넌트를 모드별로 변환하고 겹침 해결
|
||||
*
|
||||
* v5.1 자동 줄바꿈:
|
||||
* - 원본 col > targetColumns인 컴포넌트는 자동으로 맨 아래에 배치
|
||||
* - 정보 손실 방지: 모든 컴포넌트가 그리드 안에 배치됨
|
||||
*/
|
||||
export function convertAndResolvePositions(
|
||||
components: Array<{ id: string; position: PopGridPosition }>,
|
||||
targetMode: GridMode
|
||||
): Array<{ id: string; position: PopGridPosition }> {
|
||||
// 엣지 케이스: 빈 배열
|
||||
if (components.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
|
||||
|
||||
// 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존)
|
||||
const converted = components.map(comp => ({
|
||||
id: comp.id,
|
||||
position: convertPositionToMode(comp.position, targetMode),
|
||||
originalCol: comp.position.col, // 원본 col 보존
|
||||
}));
|
||||
|
||||
// 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리
|
||||
const normalComponents = converted.filter(c => c.originalCol <= targetColumns);
|
||||
const overflowComponents = converted.filter(c => c.originalCol > targetColumns);
|
||||
|
||||
// 3단계: 정상 컴포넌트의 최대 row 계산
|
||||
const maxRow = normalComponents.length > 0
|
||||
? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1))
|
||||
: 0;
|
||||
|
||||
// 4단계: 초과 컴포넌트들을 맨 아래에 순차 배치
|
||||
let currentRow = maxRow + 1;
|
||||
const wrappedComponents = overflowComponents.map(comp => {
|
||||
const wrappedPosition: PopGridPosition = {
|
||||
col: 1, // 왼쪽 끝부터 시작
|
||||
row: currentRow,
|
||||
colSpan: Math.min(comp.position.colSpan, targetColumns), // 최대 칸 수 제한
|
||||
rowSpan: comp.position.rowSpan,
|
||||
};
|
||||
currentRow += comp.position.rowSpan; // 다음 행으로 이동
|
||||
|
||||
return {
|
||||
id: comp.id,
|
||||
position: wrappedPosition,
|
||||
};
|
||||
});
|
||||
|
||||
// 5단계: 정상 + 줄바꿈 컴포넌트 병합
|
||||
const adjusted = [
|
||||
...normalComponents.map(c => ({ id: c.id, position: c.position })),
|
||||
...wrappedComponents,
|
||||
];
|
||||
|
||||
// 6단계: 겹침 해결 (아래로 밀기)
|
||||
return resolveOverlaps(adjusted, targetColumns);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 검토 필요 판별
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 컴포넌트가 현재 모드에서 "검토 필요" 상태인지 확인
|
||||
*
|
||||
* v5.1 검토 필요 기준:
|
||||
* - 12칸 모드(기본 모드)가 아님
|
||||
* - 해당 모드에서 오버라이드가 없음 (아직 편집 안 함)
|
||||
*
|
||||
* @param currentMode 현재 그리드 모드
|
||||
* @param hasOverride 해당 모드에서 오버라이드 존재 여부
|
||||
* @returns true = 검토 필요, false = 검토 완료 또는 불필요
|
||||
*/
|
||||
export function needsReview(
|
||||
currentMode: GridMode,
|
||||
hasOverride: boolean
|
||||
): boolean {
|
||||
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
|
||||
|
||||
// 12칸 모드는 기본 모드이므로 검토 불필요
|
||||
if (targetColumns === 12) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 오버라이드가 있으면 이미 편집함 → 검토 완료
|
||||
if (hasOverride) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 오버라이드 없으면 → 검토 필요
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated v5.1부터 needsReview() 사용 권장
|
||||
*
|
||||
* 기존 isOutOfBounds는 "화면 밖" 개념이었으나,
|
||||
* v5.1 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 배치됩니다.
|
||||
* 대신 needsReview()로 "검토 필요" 여부를 판별하세요.
|
||||
*/
|
||||
export function isOutOfBounds(
|
||||
originalPosition: PopGridPosition,
|
||||
currentMode: GridMode,
|
||||
overridePosition?: PopGridPosition | null
|
||||
): boolean {
|
||||
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
|
||||
|
||||
// 12칸 모드면 초과 불가
|
||||
if (targetColumns === 12) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 오버라이드가 있으면 오버라이드 위치로 판단
|
||||
if (overridePosition) {
|
||||
return overridePosition.col > targetColumns;
|
||||
}
|
||||
|
||||
// 오버라이드 없으면 원본 col로 판단
|
||||
return originalPosition.col > targetColumns;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 겹침 감지 및 해결
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 두 위치가 겹치는지 확인
|
||||
*/
|
||||
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
|
||||
// 열 겹침 체크
|
||||
const aColEnd = a.col + a.colSpan - 1;
|
||||
const bColEnd = b.col + b.colSpan - 1;
|
||||
const colOverlap = !(aColEnd < b.col || bColEnd < a.col);
|
||||
|
||||
// 행 겹침 체크
|
||||
const aRowEnd = a.row + a.rowSpan - 1;
|
||||
const bRowEnd = b.row + b.rowSpan - 1;
|
||||
const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row);
|
||||
|
||||
return colOverlap && rowOverlap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 겹침 해결 (아래로 밀기)
|
||||
*/
|
||||
export function resolveOverlaps(
|
||||
positions: Array<{ id: string; position: PopGridPosition }>,
|
||||
columns: number
|
||||
): Array<{ id: string; position: PopGridPosition }> {
|
||||
// row, col 순으로 정렬
|
||||
const sorted = [...positions].sort((a, b) =>
|
||||
a.position.row - b.position.row || a.position.col - b.position.col
|
||||
);
|
||||
|
||||
const resolved: Array<{ id: string; position: PopGridPosition }> = [];
|
||||
|
||||
sorted.forEach((item) => {
|
||||
let { row, col, colSpan, rowSpan } = item.position;
|
||||
|
||||
// 열이 범위를 초과하면 조정
|
||||
if (col + colSpan - 1 > columns) {
|
||||
colSpan = columns - col + 1;
|
||||
}
|
||||
|
||||
// 기존 배치와 겹치면 아래로 이동
|
||||
let attempts = 0;
|
||||
const maxAttempts = 100;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
|
||||
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
|
||||
|
||||
if (!hasOverlap) break;
|
||||
|
||||
row++;
|
||||
attempts++;
|
||||
}
|
||||
|
||||
resolved.push({
|
||||
id: item.id,
|
||||
position: { col, row, colSpan, rowSpan },
|
||||
});
|
||||
});
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 좌표 변환
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 마우스 좌표 → 그리드 좌표 변환
|
||||
*
|
||||
* CSS Grid 계산 방식:
|
||||
* - 사용 가능 너비 = 캔버스 너비 - 패딩*2 - gap*(columns-1)
|
||||
* - 각 칸 너비 = 사용 가능 너비 / columns
|
||||
* - 셀 N의 시작 X = padding + (N-1) * (칸너비 + gap)
|
||||
*/
|
||||
export function mouseToGridPosition(
|
||||
mouseX: number,
|
||||
mouseY: number,
|
||||
canvasRect: DOMRect,
|
||||
columns: number,
|
||||
rowHeight: number,
|
||||
gap: number,
|
||||
padding: number
|
||||
): { col: number; row: number } {
|
||||
// 캔버스 내 상대 위치 (패딩 영역 포함)
|
||||
const relX = mouseX - canvasRect.left - padding;
|
||||
const relY = mouseY - canvasRect.top - padding;
|
||||
|
||||
// CSS Grid 1fr 계산과 동일하게
|
||||
// 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap)
|
||||
const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1);
|
||||
const colWidth = availableWidth / columns;
|
||||
|
||||
// 각 셀의 실제 간격 (셀 너비 + gap)
|
||||
const cellStride = colWidth + gap;
|
||||
|
||||
// 그리드 좌표 계산 (1부터 시작)
|
||||
// relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음
|
||||
const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1));
|
||||
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1);
|
||||
|
||||
return { col, row };
|
||||
}
|
||||
|
||||
/**
|
||||
* 그리드 좌표 → 픽셀 좌표 변환
|
||||
*/
|
||||
export function gridToPixelPosition(
|
||||
col: number,
|
||||
row: number,
|
||||
colSpan: number,
|
||||
rowSpan: number,
|
||||
canvasWidth: number,
|
||||
columns: number,
|
||||
rowHeight: number,
|
||||
gap: number,
|
||||
padding: number
|
||||
): { x: number; y: number; width: number; height: number } {
|
||||
const totalGap = gap * (columns - 1);
|
||||
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
|
||||
|
||||
return {
|
||||
x: padding + (col - 1) * (colWidth + gap),
|
||||
y: padding + (row - 1) * (rowHeight + gap),
|
||||
width: colWidth * colSpan + gap * (colSpan - 1),
|
||||
height: rowHeight * rowSpan + gap * (rowSpan - 1),
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 위치 검증
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 위치가 그리드 범위 내에 있는지 확인
|
||||
*/
|
||||
export function isValidPosition(
|
||||
position: PopGridPosition,
|
||||
columns: number
|
||||
): boolean {
|
||||
return (
|
||||
position.col >= 1 &&
|
||||
position.row >= 1 &&
|
||||
position.colSpan >= 1 &&
|
||||
position.rowSpan >= 1 &&
|
||||
position.col + position.colSpan - 1 <= columns
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 위치를 그리드 범위 내로 조정
|
||||
*/
|
||||
export function clampPosition(
|
||||
position: PopGridPosition,
|
||||
columns: number
|
||||
): PopGridPosition {
|
||||
let { col, row, colSpan, rowSpan } = position;
|
||||
|
||||
// 최소값 보장
|
||||
col = Math.max(1, col);
|
||||
row = Math.max(1, row);
|
||||
colSpan = Math.max(1, colSpan);
|
||||
rowSpan = Math.max(1, rowSpan);
|
||||
|
||||
// 열 범위 초과 방지
|
||||
if (col + colSpan - 1 > columns) {
|
||||
if (col > columns) {
|
||||
col = 1;
|
||||
}
|
||||
colSpan = columns - col + 1;
|
||||
}
|
||||
|
||||
return { col, row, colSpan, rowSpan };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 자동 배치
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 다음 빈 위치 찾기
|
||||
*/
|
||||
export function findNextEmptyPosition(
|
||||
existingPositions: PopGridPosition[],
|
||||
colSpan: number,
|
||||
rowSpan: number,
|
||||
columns: number
|
||||
): PopGridPosition {
|
||||
let row = 1;
|
||||
let col = 1;
|
||||
|
||||
const maxAttempts = 1000;
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan };
|
||||
|
||||
// 범위 체크
|
||||
if (col + colSpan - 1 > columns) {
|
||||
col = 1;
|
||||
row++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 겹침 체크
|
||||
const hasOverlap = existingPositions.some(pos =>
|
||||
isOverlapping(candidatePos, pos)
|
||||
);
|
||||
|
||||
if (!hasOverlap) {
|
||||
return candidatePos;
|
||||
}
|
||||
|
||||
// 다음 위치로 이동
|
||||
col++;
|
||||
if (col + colSpan - 1 > columns) {
|
||||
col = 1;
|
||||
row++;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
}
|
||||
|
||||
// 실패 시 마지막 행에 배치
|
||||
return { col: 1, row: row + 1, colSpan, rowSpan };
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트들을 자동으로 배치
|
||||
*/
|
||||
export function autoLayoutComponents(
|
||||
components: Array<{ id: string; colSpan: number; rowSpan: number }>,
|
||||
columns: number
|
||||
): Array<{ id: string; position: PopGridPosition }> {
|
||||
const result: Array<{ id: string; position: PopGridPosition }> = [];
|
||||
|
||||
let currentRow = 1;
|
||||
let currentCol = 1;
|
||||
|
||||
components.forEach(comp => {
|
||||
// 현재 행에 공간이 부족하면 다음 행으로
|
||||
if (currentCol + comp.colSpan - 1 > columns) {
|
||||
currentRow++;
|
||||
currentCol = 1;
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: comp.id,
|
||||
position: {
|
||||
col: currentCol,
|
||||
row: currentRow,
|
||||
colSpan: comp.colSpan,
|
||||
rowSpan: comp.rowSpan,
|
||||
},
|
||||
});
|
||||
|
||||
currentCol += comp.colSpan;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 유효 위치 계산 (통합 함수)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 컴포넌트의 유효 위치를 계산합니다.
|
||||
* 우선순위: 1. 오버라이드 → 2. 자동 재배치 → 3. 원본 위치
|
||||
*
|
||||
* @param componentId 컴포넌트 ID
|
||||
* @param layout 전체 레이아웃 데이터
|
||||
* @param mode 현재 그리드 모드
|
||||
* @param autoResolvedPositions 미리 계산된 자동 재배치 위치 (선택적)
|
||||
*/
|
||||
export function getEffectiveComponentPosition(
|
||||
componentId: string,
|
||||
layout: PopLayoutDataV5,
|
||||
mode: GridMode,
|
||||
autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }>
|
||||
): PopGridPosition | null {
|
||||
const component = layout.components[componentId];
|
||||
if (!component) return null;
|
||||
|
||||
// 1순위: 오버라이드가 있으면 사용
|
||||
const override = layout.overrides?.[mode]?.positions?.[componentId];
|
||||
if (override) {
|
||||
return { ...component.position, ...override };
|
||||
}
|
||||
|
||||
// 2순위: 자동 재배치된 위치 사용
|
||||
if (autoResolvedPositions) {
|
||||
const autoResolved = autoResolvedPositions.find(p => p.id === componentId);
|
||||
if (autoResolved) {
|
||||
return autoResolved.position;
|
||||
}
|
||||
} else {
|
||||
// 자동 재배치 직접 계산
|
||||
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
|
||||
id,
|
||||
position: comp.position,
|
||||
}));
|
||||
const resolved = convertAndResolvePositions(componentsArray, mode);
|
||||
const autoResolved = resolved.find(p => p.id === componentId);
|
||||
if (autoResolved) {
|
||||
return autoResolved.position;
|
||||
}
|
||||
}
|
||||
|
||||
// 3순위: 원본 위치 (12칸 모드)
|
||||
return component.position;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 컴포넌트의 유효 위치를 일괄 계산합니다.
|
||||
* 숨김 처리된 컴포넌트는 제외됩니다.
|
||||
*
|
||||
* v5.1: 자동 줄바꿈 시스템으로 인해 모든 컴포넌트가 그리드 안에 배치되므로
|
||||
* "화면 밖" 개념이 제거되었습니다.
|
||||
*/
|
||||
export function getAllEffectivePositions(
|
||||
layout: PopLayoutDataV5,
|
||||
mode: GridMode
|
||||
): Map<string, PopGridPosition> {
|
||||
const result = new Map<string, PopGridPosition>();
|
||||
|
||||
// 숨김 처리된 컴포넌트 ID 목록
|
||||
const hiddenIds = layout.overrides?.[mode]?.hidden || [];
|
||||
|
||||
// 자동 재배치 위치 미리 계산
|
||||
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
|
||||
id,
|
||||
position: comp.position,
|
||||
}));
|
||||
const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode);
|
||||
|
||||
// 각 컴포넌트의 유효 위치 계산
|
||||
Object.keys(layout.components).forEach(componentId => {
|
||||
// 숨김 처리된 컴포넌트는 제외
|
||||
if (hiddenIds.includes(componentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = getEffectiveComponentPosition(
|
||||
componentId,
|
||||
layout,
|
||||
mode,
|
||||
autoResolvedPositions
|
||||
);
|
||||
|
||||
// v5.1: 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 있음
|
||||
// 따라서 추가 필터링 불필요
|
||||
if (position) {
|
||||
result.set(componentId, position);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,347 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Node,
|
||||
Edge,
|
||||
Position,
|
||||
MarkerType,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Monitor, Layers, ArrowRight, Loader2 } from "lucide-react";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
|
||||
// ============================================================
|
||||
// 타입 정의
|
||||
// ============================================================
|
||||
|
||||
interface PopScreenFlowViewProps {
|
||||
screen: ScreenDefinition | null;
|
||||
className?: string;
|
||||
onSubScreenSelect?: (subScreenId: string) => void;
|
||||
}
|
||||
|
||||
interface PopLayoutData {
|
||||
version?: string;
|
||||
sections?: any[];
|
||||
mainScreen?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
subScreens?: SubScreen[];
|
||||
flow?: FlowConnection[];
|
||||
}
|
||||
|
||||
interface SubScreen {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "modal" | "drawer" | "fullscreen";
|
||||
triggerFrom?: string; // 어느 화면/버튼에서 트리거되는지
|
||||
}
|
||||
|
||||
interface FlowConnection {
|
||||
from: string;
|
||||
to: string;
|
||||
trigger?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 커스텀 노드 컴포넌트
|
||||
// ============================================================
|
||||
|
||||
interface ScreenNodeData {
|
||||
label: string;
|
||||
type: "main" | "modal" | "drawer" | "fullscreen";
|
||||
isMain?: boolean;
|
||||
}
|
||||
|
||||
function ScreenNode({ data }: { data: ScreenNodeData }) {
|
||||
const isMain = data.type === "main" || data.isMain;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-3 rounded-lg border-2 shadow-sm min-w-[140px] text-center transition-colors",
|
||||
isMain
|
||||
? "bg-primary/10 border-primary text-primary"
|
||||
: "bg-background border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 mb-1">
|
||||
{isMain ? (
|
||||
<Monitor className="h-4 w-4" />
|
||||
) : (
|
||||
<Layers className="h-4 w-4" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{isMain ? "메인 화면" : data.type === "modal" ? "모달" : data.type === "drawer" ? "드로어" : "전체화면"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-sm">{data.label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
screenNode: ScreenNode,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 메인 컴포넌트
|
||||
// ============================================================
|
||||
|
||||
export function PopScreenFlowView({ screen, className, onSubScreenSelect }: PopScreenFlowViewProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [layoutData, setLayoutData] = useState<PopLayoutData | null>(null);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
|
||||
// 레이아웃 데이터 로드
|
||||
useEffect(() => {
|
||||
if (!screen) {
|
||||
setLayoutData(null);
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadLayout = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const layout = await screenApi.getLayoutPop(screen.screenId);
|
||||
|
||||
if (layout && layout.version === "pop-1.0") {
|
||||
setLayoutData(layout);
|
||||
} else {
|
||||
setLayoutData(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("레이아웃 로드 실패:", error);
|
||||
setLayoutData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadLayout();
|
||||
}, [screen]);
|
||||
|
||||
// 레이아웃 데이터에서 노드/엣지 생성
|
||||
useEffect(() => {
|
||||
if (!layoutData || !screen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newNodes: Node[] = [];
|
||||
const newEdges: Edge[] = [];
|
||||
|
||||
// 메인 화면 노드
|
||||
const mainNodeId = "main";
|
||||
newNodes.push({
|
||||
id: mainNodeId,
|
||||
type: "screenNode",
|
||||
position: { x: 50, y: 100 },
|
||||
data: {
|
||||
label: screen.screenName,
|
||||
type: "main",
|
||||
isMain: true,
|
||||
},
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
});
|
||||
|
||||
// 하위 화면 노드들
|
||||
const subScreens = layoutData.subScreens || [];
|
||||
const horizontalGap = 200;
|
||||
const verticalGap = 100;
|
||||
|
||||
subScreens.forEach((subScreen, index) => {
|
||||
// 세로로 나열, 여러 개일 경우 열 분리
|
||||
const col = Math.floor(index / 3);
|
||||
const row = index % 3;
|
||||
|
||||
newNodes.push({
|
||||
id: subScreen.id,
|
||||
type: "screenNode",
|
||||
position: {
|
||||
x: 300 + col * horizontalGap,
|
||||
y: 50 + row * verticalGap,
|
||||
},
|
||||
data: {
|
||||
label: subScreen.name,
|
||||
type: subScreen.type || "modal",
|
||||
},
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
});
|
||||
});
|
||||
|
||||
// 플로우 연결 (flow 배열 또는 triggerFrom 기반)
|
||||
const flows = layoutData.flow || [];
|
||||
|
||||
if (flows.length > 0) {
|
||||
// 명시적 flow 배열 사용
|
||||
flows.forEach((flow, index) => {
|
||||
newEdges.push({
|
||||
id: `edge-${index}`,
|
||||
source: flow.from,
|
||||
target: flow.to,
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
label: flow.label || flow.trigger,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: "#888",
|
||||
},
|
||||
style: { stroke: "#888", strokeWidth: 2 },
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// triggerFrom 기반으로 엣지 생성 (기본: 메인 → 서브)
|
||||
subScreens.forEach((subScreen, index) => {
|
||||
const sourceId = subScreen.triggerFrom || mainNodeId;
|
||||
newEdges.push({
|
||||
id: `edge-${index}`,
|
||||
source: sourceId,
|
||||
target: subScreen.id,
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: "#888",
|
||||
},
|
||||
style: { stroke: "#888", strokeWidth: 2 },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setNodes(newNodes);
|
||||
setEdges(newEdges);
|
||||
}, [layoutData, screen, setNodes, setEdges]);
|
||||
|
||||
// 노드 클릭 핸들러
|
||||
const onNodeClick = useCallback(
|
||||
(_: React.MouseEvent, node: Node) => {
|
||||
if (node.id !== "main" && onSubScreenSelect) {
|
||||
onSubScreenSelect(node.id);
|
||||
}
|
||||
},
|
||||
[onSubScreenSelect]
|
||||
);
|
||||
|
||||
// 레이아웃 또는 하위 화면이 없는 경우
|
||||
const hasSubScreens = layoutData?.subScreens && layoutData.subScreens.length > 0;
|
||||
|
||||
if (!screen) {
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
|
||||
<div className="shrink-0 p-3 border-b bg-background">
|
||||
<h3 className="text-sm font-medium">화면 흐름</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<ArrowRight className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">화면을 선택하면 흐름이 표시됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
|
||||
<div className="shrink-0 p-3 border-b bg-background">
|
||||
<h3 className="text-sm font-medium">화면 흐름</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!layoutData) {
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
|
||||
<div className="shrink-0 p-3 border-b bg-background">
|
||||
<h3 className="text-sm font-medium">화면 흐름</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<ArrowRight className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">POP 레이아웃이 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full", className)}>
|
||||
<div className="shrink-0 p-3 border-b bg-background flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">화면 흐름</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{screen.screenName}
|
||||
</span>
|
||||
</div>
|
||||
{!hasSubScreens && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
하위 화면 없음
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
{hasSubScreens ? (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
minZoom={0.5}
|
||||
maxZoom={1.5}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background color="#ddd" gap={16} />
|
||||
<Controls showInteractive={false} />
|
||||
<MiniMap
|
||||
nodeColor={(node) => (node.data?.isMain ? "#3b82f6" : "#9ca3af")}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
className="!bg-muted/50"
|
||||
/>
|
||||
</ReactFlow>
|
||||
) : (
|
||||
// 하위 화면이 없으면 간단한 단일 노드 표시
|
||||
<div className="h-full flex items-center justify-center bg-muted/10">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center px-6 py-4 rounded-lg border-2 border-primary bg-primary/10">
|
||||
<Monitor className="h-5 w-5 mr-2 text-primary" />
|
||||
<span className="font-medium text-primary">{screen.screenName}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-4">
|
||||
이 화면에 연결된 하위 화면(모달)이 없습니다.
|
||||
<br />
|
||||
화면 설정에서 하위 화면을 추가할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Smartphone, Tablet, Loader2, ExternalLink, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
|
||||
// ============================================================
|
||||
// 타입 정의
|
||||
// ============================================================
|
||||
|
||||
type DeviceType = "mobile" | "tablet";
|
||||
|
||||
interface PopScreenPreviewProps {
|
||||
screen: ScreenDefinition | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 디바이스 프레임 크기
|
||||
// 모바일: 세로(portrait), 태블릿: 가로(landscape) 디폴트
|
||||
const DEVICE_SIZES = {
|
||||
mobile: { width: 375, height: 667 }, // iPhone SE 기준 (세로)
|
||||
tablet: { width: 1024, height: 768 }, // iPad 기준 (가로)
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 메인 컴포넌트
|
||||
// ============================================================
|
||||
|
||||
export function PopScreenPreview({ screen, className }: PopScreenPreviewProps) {
|
||||
const [deviceType, setDeviceType] = useState<DeviceType>("tablet");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasLayout, setHasLayout] = useState(false);
|
||||
const [key, setKey] = useState(0); // iframe 새로고침용
|
||||
|
||||
// 레이아웃 존재 여부 확인
|
||||
useEffect(() => {
|
||||
if (!screen) {
|
||||
setHasLayout(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const checkLayout = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const layout = await screenApi.getLayoutPop(screen.screenId);
|
||||
|
||||
// v2 레이아웃: sections는 객체 (Record<string, PopSectionDefinition>)
|
||||
// v1 레이아웃: sections는 배열
|
||||
if (layout) {
|
||||
const isV2 = layout.version === "pop-2.0";
|
||||
const hasSections = isV2
|
||||
? layout.sections && Object.keys(layout.sections).length > 0
|
||||
: layout.sections && Array.isArray(layout.sections) && layout.sections.length > 0;
|
||||
|
||||
setHasLayout(hasSections);
|
||||
} else {
|
||||
setHasLayout(false);
|
||||
}
|
||||
} catch {
|
||||
setHasLayout(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkLayout();
|
||||
}, [screen]);
|
||||
|
||||
// 미리보기 URL
|
||||
const previewUrl = screen ? `/pop/screens/${screen.screenId}?preview=true&device=${deviceType}` : null;
|
||||
|
||||
// 새 탭에서 열기
|
||||
const openInNewTab = () => {
|
||||
if (previewUrl) {
|
||||
const size = DEVICE_SIZES[deviceType];
|
||||
window.open(previewUrl, "_blank", `width=${size.width + 40},height=${size.height + 80}`);
|
||||
}
|
||||
};
|
||||
|
||||
// iframe 새로고침
|
||||
const refreshPreview = () => {
|
||||
setKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const deviceSize = DEVICE_SIZES[deviceType];
|
||||
// 미리보기 컨테이너에 맞게 스케일 조정
|
||||
const scale = deviceType === "tablet" ? 0.5 : 0.6;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
|
||||
{/* 헤더 */}
|
||||
<div className="shrink-0 p-3 border-b bg-background flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">미리보기</h3>
|
||||
{screen && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||
{screen.screenName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 디바이스 선택 */}
|
||||
<Tabs value={deviceType} onValueChange={(v) => setDeviceType(v as DeviceType)}>
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="mobile" className="h-7 px-3 gap-1.5" title="모바일 (375x667)">
|
||||
<Smartphone className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">모바일</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tablet" className="h-7 px-3 gap-1.5" title="태블릿 (1024x768 가로)">
|
||||
<Tablet className="h-3.5 w-3.5" />
|
||||
<span className="text-xs">태블릿</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{screen && hasLayout && (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={refreshPreview}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={openInNewTab}>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 영역 */}
|
||||
<div className="flex-1 flex items-center justify-center p-4 overflow-auto">
|
||||
{!screen ? (
|
||||
// 화면 미선택
|
||||
<div className="text-center text-muted-foreground">
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
|
||||
{deviceType === "mobile" ? (
|
||||
<Smartphone className="h-8 w-8" />
|
||||
) : (
|
||||
<Tablet className="h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm">화면을 선택하면 미리보기가 표시됩니다.</p>
|
||||
</div>
|
||||
) : loading ? (
|
||||
// 로딩 중
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-3" />
|
||||
<p className="text-sm">레이아웃 확인 중...</p>
|
||||
</div>
|
||||
) : !hasLayout ? (
|
||||
// 레이아웃 없음
|
||||
<div className="text-center text-muted-foreground">
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-3">
|
||||
{deviceType === "mobile" ? (
|
||||
<Smartphone className="h-8 w-8" />
|
||||
) : (
|
||||
<Tablet className="h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm mb-2">POP 레이아웃이 없습니다.</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
화면을 더블클릭하여 설계 모드로 이동하세요.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
// 디바이스 프레임 + iframe (심플한 테두리)
|
||||
<div
|
||||
className="relative border-2 border-gray-300 rounded-lg shadow-lg overflow-hidden"
|
||||
style={{
|
||||
width: deviceSize.width * scale,
|
||||
height: deviceSize.height * scale,
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
key={key}
|
||||
src={previewUrl || ""}
|
||||
className="w-full h-full border-0"
|
||||
style={{
|
||||
width: deviceSize.width,
|
||||
height: deviceSize.height,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
title="POP Screen Preview"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,442 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
FileText,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { PopScreenGroup, getPopScreenGroups } from "@/lib/api/popScreenGroup";
|
||||
import { PopScreenFlowView } from "./PopScreenFlowView";
|
||||
|
||||
// ============================================================
|
||||
// 타입 정의
|
||||
// ============================================================
|
||||
|
||||
interface PopScreenSettingModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
screen: ScreenDefinition | null;
|
||||
onSave?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
||||
}
|
||||
|
||||
interface SubScreenItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "modal" | "drawer" | "fullscreen";
|
||||
triggerFrom?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 메인 컴포넌트
|
||||
// ============================================================
|
||||
|
||||
export function PopScreenSettingModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
screen,
|
||||
onSave,
|
||||
}: PopScreenSettingModalProps) {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 개요 탭 상태
|
||||
const [screenName, setScreenName] = useState("");
|
||||
const [screenDescription, setScreenDescription] = useState("");
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string>("");
|
||||
const [screenIcon, setScreenIcon] = useState("");
|
||||
|
||||
// 하위 화면 탭 상태
|
||||
const [subScreens, setSubScreens] = useState<SubScreenItem[]>([]);
|
||||
|
||||
// 카테고리 목록
|
||||
const [categories, setCategories] = useState<PopScreenGroup[]>([]);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
if (!open || !screen) return;
|
||||
|
||||
// 화면 정보 설정
|
||||
setScreenName(screen.screenName || "");
|
||||
setScreenDescription(screen.description || "");
|
||||
setScreenIcon("");
|
||||
setSelectedCategoryId("");
|
||||
|
||||
// 카테고리 목록 로드
|
||||
loadCategories();
|
||||
|
||||
// 레이아웃에서 하위 화면 정보 로드
|
||||
loadLayoutData();
|
||||
}, [open, screen]);
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const data = await getPopScreenGroups();
|
||||
setCategories(data.filter((g) => g.hierarchy_path?.startsWith("POP/")));
|
||||
} catch (error) {
|
||||
console.error("카테고리 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLayoutData = async () => {
|
||||
if (!screen) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const layout = await screenApi.getLayoutPop(screen.screenId);
|
||||
|
||||
if (layout && layout.subScreens) {
|
||||
setSubScreens(
|
||||
layout.subScreens.map((sub: any) => ({
|
||||
id: sub.id || `sub-${Date.now()}`,
|
||||
name: sub.name || "",
|
||||
type: sub.type || "modal",
|
||||
triggerFrom: sub.triggerFrom || "main",
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setSubScreens([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("레이아웃 로드 실패:", error);
|
||||
setSubScreens([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 하위 화면 추가
|
||||
const addSubScreen = () => {
|
||||
const newSubScreen: SubScreenItem = {
|
||||
id: `sub-${Date.now()}`,
|
||||
name: `새 모달 ${subScreens.length + 1}`,
|
||||
type: "modal",
|
||||
triggerFrom: "main",
|
||||
};
|
||||
setSubScreens([...subScreens, newSubScreen]);
|
||||
};
|
||||
|
||||
// 하위 화면 삭제
|
||||
const removeSubScreen = (id: string) => {
|
||||
setSubScreens(subScreens.filter((s) => s.id !== id));
|
||||
};
|
||||
|
||||
// 하위 화면 업데이트
|
||||
const updateSubScreen = (id: string, field: keyof SubScreenItem, value: string) => {
|
||||
setSubScreens(
|
||||
subScreens.map((s) => (s.id === id ? { ...s, [field]: value } : s))
|
||||
);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!screen) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// 화면 기본 정보 업데이트
|
||||
const screenUpdate: Partial<ScreenDefinition> = {
|
||||
screenName,
|
||||
description: screenDescription,
|
||||
};
|
||||
|
||||
// 레이아웃에 하위 화면 정보 저장
|
||||
const currentLayout = await screenApi.getLayoutPop(screen.screenId);
|
||||
const updatedLayout = {
|
||||
...currentLayout,
|
||||
version: "pop-1.0",
|
||||
subScreens: subScreens,
|
||||
// flow 배열 자동 생성 (메인 → 각 서브)
|
||||
flow: subScreens.map((sub) => ({
|
||||
from: sub.triggerFrom || "main",
|
||||
to: sub.id,
|
||||
})),
|
||||
};
|
||||
|
||||
await screenApi.saveLayoutPop(screen.screenId, updatedLayout);
|
||||
|
||||
toast.success("화면 설정이 저장되었습니다.");
|
||||
onSave?.(screenUpdate);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("저장 실패:", error);
|
||||
toast.error("저장에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!screen) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] flex flex-col p-0">
|
||||
<DialogHeader className="p-4 pb-0 shrink-0">
|
||||
<DialogTitle className="text-base sm:text-lg">POP 화면 설정</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
{screen.screenName} ({screen.screenCode})
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="shrink-0 mx-4 justify-start border-b rounded-none bg-transparent h-auto p-0">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
개요
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="subscreens"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
<Layers className="h-4 w-4 mr-2" />
|
||||
하위 화면
|
||||
{subScreens.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2 text-xs">
|
||||
{subScreens.length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="flow"
|
||||
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2"
|
||||
>
|
||||
<GitBranch className="h-4 w-4 mr-2" />
|
||||
화면 흐름
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 개요 탭 */}
|
||||
<TabsContent value="overview" className="flex-1 m-0 p-4 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 max-w-[500px]">
|
||||
<div>
|
||||
<Label htmlFor="screenName" className="text-xs sm:text-sm">
|
||||
화면명 *
|
||||
</Label>
|
||||
<Input
|
||||
id="screenName"
|
||||
value={screenName}
|
||||
onChange={(e) => setScreenName(e.target.value)}
|
||||
placeholder="화면 이름"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="category" className="text-xs sm:text-sm">
|
||||
카테고리
|
||||
</Label>
|
||||
<Select value={selectedCategoryId} onValueChange={setSelectedCategoryId}>
|
||||
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat.id} value={String(cat.id)}>
|
||||
{cat.group_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||
설명
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={screenDescription}
|
||||
onChange={(e) => setScreenDescription(e.target.value)}
|
||||
placeholder="화면에 대한 설명"
|
||||
rows={3}
|
||||
className="text-xs sm:text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="icon" className="text-xs sm:text-sm">
|
||||
아이콘
|
||||
</Label>
|
||||
<Input
|
||||
id="icon"
|
||||
value={screenIcon}
|
||||
onChange={(e) => setScreenIcon(e.target.value)}
|
||||
placeholder="lucide 아이콘 이름 (예: Package)"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
lucide-react 아이콘 이름을 입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 하위 화면 탭 */}
|
||||
<TabsContent value="subscreens" className="flex-1 m-0 p-4 overflow-auto">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
이 화면에서 열리는 모달, 드로어 등의 하위 화면을 관리합니다.
|
||||
</p>
|
||||
<Button size="sm" onClick={addSubScreen}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[300px]">
|
||||
{subScreens.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Layers className="h-8 w-8 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">하위 화면이 없습니다.</p>
|
||||
<Button variant="link" className="text-xs" onClick={addSubScreen}>
|
||||
하위 화면 추가하기
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{subScreens.map((subScreen, index) => (
|
||||
<div
|
||||
key={subScreen.id}
|
||||
className="flex items-start gap-3 p-3 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<GripVertical className="h-5 w-5 text-muted-foreground shrink-0 mt-1 cursor-grab" />
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={subScreen.name}
|
||||
onChange={(e) =>
|
||||
updateSubScreen(subScreen.id, "name", e.target.value)
|
||||
}
|
||||
placeholder="화면 이름"
|
||||
className="h-8 text-xs flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={subScreen.type}
|
||||
onValueChange={(v) =>
|
||||
updateSubScreen(subScreen.id, "type", v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs w-[100px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="modal">모달</SelectItem>
|
||||
<SelectItem value="drawer">드로어</SelectItem>
|
||||
<SelectItem value="fullscreen">전체화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
트리거:
|
||||
</span>
|
||||
<Select
|
||||
value={subScreen.triggerFrom || "main"}
|
||||
onValueChange={(v) =>
|
||||
updateSubScreen(subScreen.id, "triggerFrom", v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="main">메인 화면</SelectItem>
|
||||
{subScreens
|
||||
.filter((s) => s.id !== subScreen.id)
|
||||
.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeSubScreen(subScreen.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 화면 흐름 탭 */}
|
||||
<TabsContent value="flow" className="flex-1 m-0 overflow-hidden">
|
||||
<PopScreenFlowView screen={screen} className="h-full" />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="shrink-0 p-4 border-t flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* POP 화면 관리 컴포넌트
|
||||
*/
|
||||
|
||||
export { PopCategoryTree } from "./PopCategoryTree";
|
||||
export { PopScreenPreview } from "./PopScreenPreview";
|
||||
export { PopScreenFlowView } from "./PopScreenFlowView";
|
||||
export { PopScreenSettingModal } from "./PopScreenSettingModal";
|
||||
|
|
@ -26,9 +26,10 @@ interface CreateScreenModalProps {
|
|||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated?: (screen: ScreenDefinition) => void;
|
||||
isPop?: boolean; // POP 화면 생성 모드
|
||||
}
|
||||
|
||||
export default function CreateScreenModal({ open, onOpenChange, onCreated }: CreateScreenModalProps) {
|
||||
export default function CreateScreenModal({ open, onOpenChange, onCreated, isPop = false }: CreateScreenModalProps) {
|
||||
const { user } = useAuth();
|
||||
|
||||
const [screenName, setScreenName] = useState("");
|
||||
|
|
@ -246,6 +247,19 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
|
||||
const created = await screenApi.createScreen(createData);
|
||||
|
||||
// POP 모드일 경우 빈 POP 레이아웃 자동 생성
|
||||
if (isPop && created.screenId) {
|
||||
try {
|
||||
await screenApi.saveLayoutPop(created.screenId, {
|
||||
version: "2.0",
|
||||
components: [],
|
||||
});
|
||||
} catch (popError) {
|
||||
console.error("POP 레이아웃 생성 실패:", popError);
|
||||
// POP 레이아웃 생성 실패해도 화면 생성은 성공으로 처리
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 필드 보정
|
||||
const mapped: ScreenDefinition = {
|
||||
...created,
|
||||
|
|
@ -278,7 +292,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 화면 생성</DialogTitle>
|
||||
<DialogTitle>{isPop ? "새 POP 화면 생성" : "새 화면 생성"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -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,6 +284,39 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
});
|
||||
}, [finalFormData, layers, allComponents, handleLayerAction]);
|
||||
|
||||
// 🆕 Zone 기반 Y 오프셋 계산 (단순화)
|
||||
// Zone 단위로 활성 여부만 판단 → merge 로직 불필요
|
||||
const calculateYOffset = useCallback((componentY: number): number => {
|
||||
// layers에서 Zone 정보 추출 (displayRegion이 있는 레이어들을 zone 단위로 그룹핑)
|
||||
const zoneMap = new Map<number, { y: number; height: number; hasActive: boolean }>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
? useFormValidation(
|
||||
|
|
@ -2198,6 +2231,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
}
|
||||
: component;
|
||||
|
||||
// 🆕 모든 레이어의 컴포넌트를 통합 (조건부 레이어 내 컴포넌트가 기본 레이어 formData 참조 가능하도록)
|
||||
const allLayerComponents = useMemo(() => {
|
||||
return layers.flatMap((layer) => layer.components);
|
||||
}, [layers]);
|
||||
|
||||
// 🆕 레이어별 컴포넌트 렌더링 함수
|
||||
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
|
||||
// 활성화되지 않은 레이어는 렌더링하지 않음
|
||||
|
|
@ -2234,7 +2272,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
>
|
||||
<InteractiveScreenViewer
|
||||
component={comp}
|
||||
allComponents={layer.components}
|
||||
allComponents={allLayerComponents}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
|
|
@ -2306,7 +2344,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
>
|
||||
<InteractiveScreenViewer
|
||||
component={comp}
|
||||
allComponents={layer.components}
|
||||
allComponents={allLayerComponents}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
|
|
@ -2319,37 +2357,83 @@ 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}
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{ zIndex: layer.zIndex }}
|
||||
>
|
||||
{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={layer.components}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{layer.components.map((comp) => {
|
||||
const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0;
|
||||
const adjustedY = comp.position.y - yOffset;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="pointer-events-auto absolute"
|
||||
style={{
|
||||
left: `${comp.position.x}px`,
|
||||
top: `${adjustedY}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>
|
||||
);
|
||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo]);
|
||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]);
|
||||
|
||||
return (
|
||||
<SplitPanelProvider>
|
||||
|
|
|
|||
|
|
@ -10,15 +10,28 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, AlertCircle, Check, X } from "lucide-react";
|
||||
import { Loader2, AlertCircle, Check, X, Database, Code2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentData, LayerCondition, LayerDefinition } from "@/types/screen-management";
|
||||
import { ComponentData, LayerCondition, LayerDefinition, DisplayRegion } from "@/types/screen-management";
|
||||
import { getCodesByCategory, CodeItem } from "@/lib/api/codeManagement";
|
||||
import { EntityReferenceAPI } from "@/lib/api/entityReference";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
// 통합 옵션 타입 (코드/엔티티/카테고리 모두 사용)
|
||||
interface ConditionOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 컴포넌트의 데이터 소스 타입
|
||||
type DataSourceType = "code" | "entity" | "category" | "static" | "none";
|
||||
|
||||
interface LayerConditionPanelProps {
|
||||
layer: LayerDefinition;
|
||||
components: ComponentData[]; // 화면의 모든 컴포넌트
|
||||
baseLayerComponents?: ComponentData[]; // 기본 레이어 컴포넌트 (트리거 우선 대상)
|
||||
onUpdateCondition: (condition: LayerCondition | undefined) => void;
|
||||
onUpdateDisplayRegion: (region: DisplayRegion | undefined) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +47,9 @@ type OperatorType = "eq" | "neq" | "in";
|
|||
export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
||||
layer,
|
||||
components,
|
||||
baseLayerComponents,
|
||||
onUpdateCondition,
|
||||
onUpdateDisplayRegion,
|
||||
onClose,
|
||||
}) => {
|
||||
// 조건 설정 상태
|
||||
|
|
@ -51,75 +66,289 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
Array.isArray(layer.condition?.value) ? layer.condition.value : []
|
||||
);
|
||||
|
||||
// 코드 목록 로딩 상태
|
||||
const [codeOptions, setCodeOptions] = useState<CodeItem[]>([]);
|
||||
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
|
||||
const [codeLoadError, setCodeLoadError] = useState<string | null>(null);
|
||||
// 옵션 목록 로딩 상태 (코드/엔티티 통합)
|
||||
const [options, setOptions] = useState<ConditionOption[]>([]);
|
||||
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// 트리거 가능한 컴포넌트 필터링 (셀렉트, 라디오, 코드 타입 등)
|
||||
// 트리거 가능한 컴포넌트 필터링 (기본 레이어 우선, 셀렉트/라디오/코드 타입 등)
|
||||
const triggerableComponents = useMemo(() => {
|
||||
return components.filter((comp) => {
|
||||
// 기본 레이어 컴포넌트가 전달된 경우 우선 사용, 없으면 전체 컴포넌트 사용
|
||||
const sourceComponents = baseLayerComponents && baseLayerComponents.length > 0
|
||||
? baseLayerComponents
|
||||
: components;
|
||||
|
||||
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"];
|
||||
const isTriggerType = triggerTypes.some((type) =>
|
||||
// 셀렉트, 라디오, 코드, 카테고리, 엔티티 타입 컴포넌트 허용
|
||||
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)
|
||||
);
|
||||
};
|
||||
|
||||
return isTriggerType;
|
||||
});
|
||||
}, [components]);
|
||||
// 기본 레이어 컴포넌트 ID Set (그룹 구분용)
|
||||
const baseLayerIds = new Set(
|
||||
(baseLayerComponents || []).map((c) => c.id)
|
||||
);
|
||||
|
||||
// 기본 레이어 트리거 컴포넌트
|
||||
const baseLayerTriggers = sourceComponents.filter(isTriggerComponent);
|
||||
|
||||
// 기본 레이어가 아닌 다른 레이어의 트리거 컴포넌트도 포함 (하단에 표시)
|
||||
// 단, baseLayerComponents가 별도로 전달된 경우에만 나머지 컴포넌트 추가
|
||||
const otherLayerTriggers = baseLayerComponents && baseLayerComponents.length > 0
|
||||
? components.filter((comp) => !baseLayerIds.has(comp.id) && isTriggerComponent(comp))
|
||||
: [];
|
||||
|
||||
return { baseLayerTriggers, otherLayerTriggers };
|
||||
}, [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 codeCategory = useMemo(() => {
|
||||
if (!selectedComponent) return null;
|
||||
// 선택된 컴포넌트의 데이터 소스 정보 추출
|
||||
const dataSourceInfo = useMemo<{
|
||||
type: DataSourceType;
|
||||
codeCategory?: string;
|
||||
// 엔티티: 원본 테이블.컬럼 (entity-reference API용)
|
||||
originTable?: string;
|
||||
originColumn?: string;
|
||||
// 엔티티: 참조 대상 정보 (직접 조회용 폴백)
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
categoryTable?: string;
|
||||
categoryColumn?: string;
|
||||
staticOptions?: any[];
|
||||
}>(() => {
|
||||
if (!selectedComponent) return { type: "none" };
|
||||
|
||||
// codeCategory 확인 (다양한 위치에 있을 수 있음)
|
||||
const category =
|
||||
(selectedComponent as any).codeCategory ||
|
||||
(selectedComponent as any).componentConfig?.codeCategory ||
|
||||
(selectedComponent as any).webTypeConfig?.codeCategory;
|
||||
const comp = selectedComponent as any;
|
||||
const config = comp.componentConfig || comp.webTypeConfig || {};
|
||||
const detailSettings = comp.detailSettings || {};
|
||||
|
||||
return category || null;
|
||||
// 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") {
|
||||
const categoryTable = config.categoryTable || comp.tableName;
|
||||
const categoryColumn = config.categoryColumn || comp.columnName;
|
||||
return { type: "category", categoryTable, categoryColumn };
|
||||
}
|
||||
|
||||
// 2. 코드 카테고리 확인 (V2: source === "code" + codeGroup, 기존: codeCategory)
|
||||
const codeCategory =
|
||||
config.codeGroup || // V2 컴포넌트
|
||||
config.codeCategory ||
|
||||
comp.codeCategory ||
|
||||
detailSettings.codeCategory;
|
||||
|
||||
if (source === "code" || codeCategory) {
|
||||
return { type: "code", codeCategory };
|
||||
}
|
||||
|
||||
// 3. 엔티티 참조 확인 (V2: source === "entity")
|
||||
// entity-reference API는 원본 테이블.컬럼으로 호출해야 함
|
||||
// (백엔드에서 table_type_columns를 조회하여 참조 테이블/컬럼을 자동 매핑)
|
||||
const originTable = comp.tableName || config.tableName;
|
||||
const originColumn = comp.columnName || config.columnName;
|
||||
|
||||
const referenceTable =
|
||||
config.entityTable ||
|
||||
config.referenceTable ||
|
||||
comp.referenceTable ||
|
||||
detailSettings.referenceTable;
|
||||
|
||||
const referenceColumn =
|
||||
config.entityValueColumn ||
|
||||
config.referenceColumn ||
|
||||
comp.referenceColumn ||
|
||||
detailSettings.referenceColumn;
|
||||
|
||||
if (source === "entity" || referenceTable) {
|
||||
return { type: "entity", originTable, originColumn, referenceTable, referenceColumn };
|
||||
}
|
||||
|
||||
// 4. 정적 옵션 확인 (V2: source === "static" 또는 config.options 존재)
|
||||
const staticOptions = config.options;
|
||||
if (source === "static" || (staticOptions && Array.isArray(staticOptions) && staticOptions.length > 0)) {
|
||||
return { type: "static", staticOptions };
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (!codeCategory) {
|
||||
setCodeOptions([]);
|
||||
// race condition 방지
|
||||
let cancelled = false;
|
||||
|
||||
if (dataSourceInfo.type === "none") {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCodes = async () => {
|
||||
setIsLoadingCodes(true);
|
||||
setCodeLoadError(null);
|
||||
// 정적 옵션은 즉시 설정
|
||||
if (dataSourceInfo.type === "static") {
|
||||
const staticOpts = dataSourceInfo.staticOptions || [];
|
||||
setOptions(staticOpts.map((opt: any) => ({
|
||||
value: opt.value || "",
|
||||
label: opt.label || opt.value || "",
|
||||
})));
|
||||
return;
|
||||
}
|
||||
|
||||
const loadOptions = async () => {
|
||||
setIsLoadingOptions(true);
|
||||
setLoadError(null);
|
||||
|
||||
try {
|
||||
const codes = await getCodesByCategory(codeCategory);
|
||||
setCodeOptions(codes);
|
||||
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[] => {
|
||||
const result: ConditionOption[] = [];
|
||||
for (const item of items) {
|
||||
const prefix = depth > 0 ? " ".repeat(depth) : "";
|
||||
result.push({
|
||||
value: item.valueCode || item.valueLabel,
|
||||
label: `${prefix}${item.valueLabel}`,
|
||||
});
|
||||
if (item.children && item.children.length > 0) {
|
||||
result.push(...flattenTree(item.children, depth + 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
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") {
|
||||
// 엔티티 참조에서 옵션 로드
|
||||
let entityLoaded = false;
|
||||
|
||||
if (dataSourceInfo.originTable && dataSourceInfo.originColumn) {
|
||||
try {
|
||||
const entityData = await EntityReferenceAPI.getEntityReferenceData(
|
||||
dataSourceInfo.originTable,
|
||||
dataSourceInfo.originColumn,
|
||||
{ limit: 100 }
|
||||
);
|
||||
if (cancelled) return;
|
||||
setOptions(entityData.options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})));
|
||||
entityLoaded = true;
|
||||
} catch {
|
||||
console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백");
|
||||
}
|
||||
}
|
||||
|
||||
// 폴백: 참조 테이블에서 직접 조회
|
||||
if (!entityLoaded && dataSourceInfo.referenceTable) {
|
||||
try {
|
||||
const refColumn = dataSourceInfo.referenceColumn || "id";
|
||||
const entityData = await EntityReferenceAPI.getEntityReferenceData(
|
||||
dataSourceInfo.referenceTable,
|
||||
refColumn,
|
||||
{ limit: 100 }
|
||||
);
|
||||
if (cancelled) return;
|
||||
setOptions(entityData.options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})));
|
||||
entityLoaded = true;
|
||||
} catch {
|
||||
console.warn("직접 참조 테이블로도 엔티티 조회 실패");
|
||||
}
|
||||
}
|
||||
|
||||
if (!entityLoaded && !cancelled) {
|
||||
setOptions([]);
|
||||
}
|
||||
} else {
|
||||
if (!cancelled) setOptions([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("코드 목록 로드 실패:", error);
|
||||
setCodeLoadError(error.message || "코드 목록을 불러올 수 없습니다.");
|
||||
setCodeOptions([]);
|
||||
if (!cancelled) {
|
||||
console.error("옵션 목록 로드 실패:", error);
|
||||
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
|
||||
setOptions([]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingCodes(false);
|
||||
if (!cancelled) {
|
||||
setIsLoadingOptions(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadCodes();
|
||||
}, [codeCategory]);
|
||||
loadOptions();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSourceKey]);
|
||||
|
||||
// 조건 저장
|
||||
const handleSave = useCallback(() => {
|
||||
|
|
@ -180,36 +409,91 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
<SelectValue placeholder="컴포넌트 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triggerableComponents.length === 0 ? (
|
||||
{triggerableComponents.baseLayerTriggers.length === 0 &&
|
||||
triggerableComponents.otherLayerTriggers.length === 0 ? (
|
||||
<div className="p-2 text-xs text-muted-foreground text-center">
|
||||
조건 설정 가능한 컴포넌트가 없습니다.
|
||||
<br />
|
||||
(셀렉트, 라디오, 코드 타입)
|
||||
</div>
|
||||
) : (
|
||||
triggerableComponents.map((comp) => (
|
||||
<SelectItem key={comp.id} value={comp.id} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{getComponentLabel(comp)}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{comp.componentType || (comp as any).widgetType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
<>
|
||||
{/* 기본 레이어 컴포넌트 (우선 표시) */}
|
||||
{triggerableComponents.baseLayerTriggers.length > 0 && (
|
||||
<>
|
||||
{triggerableComponents.otherLayerTriggers.length > 0 && (
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground">
|
||||
기본 레이어
|
||||
</div>
|
||||
)}
|
||||
{triggerableComponents.baseLayerTriggers.map((comp) => (
|
||||
<SelectItem key={comp.id} value={comp.id} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{getComponentLabel(comp)}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{comp.componentType || (comp as any).widgetType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* 다른 레이어 컴포넌트 (하단에 구분하여 표시) */}
|
||||
{triggerableComponents.otherLayerTriggers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground border-t mt-1 pt-1">
|
||||
다른 레이어
|
||||
</div>
|
||||
{triggerableComponents.otherLayerTriggers.map((comp) => (
|
||||
<SelectItem key={comp.id} value={comp.id} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{getComponentLabel(comp)}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{comp.componentType || (comp as any).widgetType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 코드 카테고리 표시 */}
|
||||
{codeCategory && (
|
||||
{/* 데이터 소스 표시 */}
|
||||
{dataSourceInfo.type === "code" && dataSourceInfo.codeCategory && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Code2 className="h-3 w-3" />
|
||||
<span>코드:</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{dataSourceInfo.codeCategory}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{dataSourceInfo.type === "entity" && (dataSourceInfo.referenceTable || dataSourceInfo.originTable) && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Database className="h-3 w-3" />
|
||||
<span>엔티티:</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{dataSourceInfo.referenceTable || `${dataSourceInfo.originTable}.${dataSourceInfo.originColumn}`}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Database className="h-3 w-3" />
|
||||
<span>카테고리:</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{codeCategory}
|
||||
{dataSourceInfo.categoryTable}.{dataSourceInfo.categoryColumn}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{dataSourceInfo.type === "static" && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>정적 옵션</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
|
|
@ -241,42 +525,41 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
{operator === "in" ? "값 선택 (복수)" : "값"}
|
||||
</Label>
|
||||
|
||||
{isLoadingCodes ? (
|
||||
{isLoadingOptions ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground p-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
코드 목록 로딩 중...
|
||||
옵션 목록 로딩 중...
|
||||
</div>
|
||||
) : codeLoadError ? (
|
||||
) : loadError ? (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive p-2">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{codeLoadError}
|
||||
{loadError}
|
||||
</div>
|
||||
) : codeOptions.length > 0 ? (
|
||||
// 코드 카테고리가 있는 경우 - 선택 UI
|
||||
) : options.length > 0 ? (
|
||||
// 옵션이 있는 경우 - 선택 UI
|
||||
operator === "in" ? (
|
||||
// 다중 선택 (in 연산자)
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
|
||||
{codeOptions.map((code) => (
|
||||
{options.map((opt) => (
|
||||
<div
|
||||
key={code.codeValue}
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-1.5 rounded cursor-pointer text-xs hover:bg-accent",
|
||||
multiValues.includes(code.codeValue) && "bg-primary/10"
|
||||
multiValues.includes(opt.value) && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => toggleMultiValue(code.codeValue)}
|
||||
onClick={() => toggleMultiValue(opt.value)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center",
|
||||
multiValues.includes(code.codeValue)
|
||||
multiValues.includes(opt.value)
|
||||
? "bg-primary border-primary"
|
||||
: "border-input"
|
||||
)}>
|
||||
{multiValues.includes(code.codeValue) && (
|
||||
{multiValues.includes(opt.value) && (
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span>{code.codeName}</span>
|
||||
<span className="text-muted-foreground">({code.codeValue})</span>
|
||||
<span>{opt.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -287,20 +570,20 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
<SelectValue placeholder="값 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{codeOptions.map((code) => (
|
||||
{options.map((opt) => (
|
||||
<SelectItem
|
||||
key={code.codeValue}
|
||||
value={code.codeValue}
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="text-xs"
|
||||
>
|
||||
{code.codeName} ({code.codeValue})
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
) : (
|
||||
// 코드 카테고리가 없는 경우 - 직접 입력
|
||||
// 옵션이 없는 경우 - 직접 입력
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
|
|
@ -313,14 +596,14 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
{operator === "in" && multiValues.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{multiValues.map((val) => {
|
||||
const code = codeOptions.find((c) => c.codeValue === val);
|
||||
const opt = options.find((o) => o.value === val);
|
||||
return (
|
||||
<Badge
|
||||
key={val}
|
||||
variant="secondary"
|
||||
className="text-[10px] gap-1"
|
||||
>
|
||||
{code?.codeName || val}
|
||||
{opt?.label || val}
|
||||
<X
|
||||
className="h-2.5 w-2.5 cursor-pointer hover:text-destructive"
|
||||
onClick={() => toggleMultiValue(val)}
|
||||
|
|
@ -334,19 +617,65 @@ 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!)}" 값이{" "}
|
||||
{operator === "eq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 같으면`}
|
||||
{operator === "neq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 다르면`}
|
||||
{operator === "in" && `[${multiValues.map(v => codeOptions.find(c => c.codeValue === v)?.codeName || v).join(", ")}] 중 하나이면`}
|
||||
"{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(", ")}] 중 하나이면`}
|
||||
{" "}이 레이어 표시
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 표시 영역 설정 */}
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<Label className="text-xs font-semibold">표시 영역</Label>
|
||||
|
||||
{layer.displayRegion ? (
|
||||
<>
|
||||
{/* 현재 영역 정보 표시 */}
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||
<div className="flex-1 text-[10px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{layer.displayRegion.width} x {layer.displayRegion.height}
|
||||
</span>
|
||||
<span className="ml-1">
|
||||
({layer.displayRegion.x}, {layer.displayRegion.y})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[10px] text-destructive hover:text-destructive"
|
||||
onClick={() => onUpdateDisplayRegion(undefined)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
캔버스에서 점선 영역을 드래그하여 이동/리사이즈할 수 있습니다.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
좌측의 레이어 항목을 캔버스로
|
||||
</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
드래그&드롭하여 영역을 배치하세요
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
영역을 추가하면 조건 미충족 시 해당 영역이 사라지고 아래 컴포넌트가 위로 이동합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Database, Cog } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -132,6 +133,9 @@ interface ScreenDesignerProps {
|
|||
selectedScreen: ScreenDefinition | null;
|
||||
onBackToList: () => void;
|
||||
onScreenUpdate?: (updatedScreen: Partial<ScreenDefinition>) => void;
|
||||
// POP 모드 지원
|
||||
isPop?: boolean;
|
||||
defaultDevicePreview?: "mobile" | "tablet";
|
||||
}
|
||||
|
||||
import { useLayerOptional, LayerProvider, createDefaultLayer } from "@/contexts/LayerContext";
|
||||
|
|
@ -158,7 +162,15 @@ const panelConfigs: PanelConfig[] = [
|
|||
},
|
||||
];
|
||||
|
||||
export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenUpdate }: ScreenDesignerProps) {
|
||||
export default function ScreenDesigner({
|
||||
selectedScreen,
|
||||
onBackToList,
|
||||
onScreenUpdate,
|
||||
isPop = false,
|
||||
defaultDevicePreview = "tablet"
|
||||
}: ScreenDesignerProps) {
|
||||
// POP 모드 여부에 따른 API 분기
|
||||
const USE_POP_API = isPop;
|
||||
const [layout, setLayout] = useState<LayoutData>({
|
||||
components: [],
|
||||
gridSettings: {
|
||||
|
|
@ -500,25 +512,76 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
return lines;
|
||||
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
|
||||
|
||||
// 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리)
|
||||
const [activeLayerId, setActiveLayerIdLocal] = useState<string | null>("default-layer");
|
||||
// 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어)
|
||||
const [activeLayerId, setActiveLayerIdLocal] = useState<number>(1);
|
||||
const activeLayerIdRef = useRef<number>(1);
|
||||
const setActiveLayerIdWithRef = useCallback((id: number) => {
|
||||
setActiveLayerIdLocal(id);
|
||||
activeLayerIdRef.current = id;
|
||||
}, []);
|
||||
|
||||
// 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반)
|
||||
// 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시
|
||||
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
|
||||
const visibleComponents = useMemo(() => {
|
||||
// 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시
|
||||
if (!activeLayerId) {
|
||||
return layout.components;
|
||||
// 🆕 좌측 패널 탭 상태 관리
|
||||
const [leftPanelTab, setLeftPanelTab] = useState<string>("components");
|
||||
|
||||
// 🆕 조건부 영역(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; // 대상 Zone ID (문자열)
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
resizeHandle: string | null; // 리사이즈 핸들 위치
|
||||
originalRegion: { x: number; y: number; width: number; height: number } | null;
|
||||
}>({
|
||||
isDrawing: false,
|
||||
isDragging: false,
|
||||
isResizing: false,
|
||||
targetLayerId: null,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
currentX: 0,
|
||||
currentY: 0,
|
||||
resizeHandle: null,
|
||||
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]);
|
||||
|
||||
// 활성 레이어에 속한 컴포넌트만 필터링
|
||||
return layout.components.filter((comp) => {
|
||||
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
|
||||
const compLayerId = comp.layerId || "default-layer";
|
||||
return compLayerId === activeLayerId;
|
||||
});
|
||||
}, [layout.components, activeLayerId]);
|
||||
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
|
||||
const visibleComponents = useMemo(() => {
|
||||
return layout.components;
|
||||
}, [layout.components]);
|
||||
|
||||
// 이미 배치된 컬럼 목록 계산
|
||||
const placedColumns = useMemo(() => {
|
||||
|
|
@ -1447,9 +1510,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
console.warn("⚠️ 화면에 할당된 메뉴가 없습니다");
|
||||
}
|
||||
|
||||
// V2 API 사용 여부에 따라 분기
|
||||
// V2/POP API 사용 여부에 따라 분기
|
||||
let response: any;
|
||||
if (USE_V2_API) {
|
||||
if (USE_POP_API) {
|
||||
// POP 모드: screen_layouts_pop 테이블 사용
|
||||
const popResponse = await screenApi.getLayoutPop(selectedScreen.screenId);
|
||||
response = popResponse ? convertV2ToLegacy(popResponse) : null;
|
||||
console.log("📱 POP 레이아웃 로드:", popResponse?.components?.length || 0, "개 컴포넌트");
|
||||
} else if (USE_V2_API) {
|
||||
// 데스크톱 V2 모드: screen_layouts_v2 테이블 사용
|
||||
const v2Response = await screenApi.getLayoutV2(selectedScreen.screenId);
|
||||
|
||||
// 🐛 디버깅: API 응답에서 fieldMapping.id 확인
|
||||
|
|
@ -1532,6 +1601,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
// 파일 컴포넌트 데이터 복원 (비동기)
|
||||
restoreFileComponentsData(layoutWithDefaultGrid.components);
|
||||
|
||||
// 🆕 조건부 영역(Zone) 로드
|
||||
try {
|
||||
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
|
||||
setZones(loadedZones);
|
||||
} catch { /* Zone 로드 실패 무시 */ }
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("레이아웃 로드 실패:", error);
|
||||
|
|
@ -1871,17 +1946,33 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
[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(
|
||||
|
|
@ -1953,37 +2044,25 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
// 현재 선택된 테이블을 화면의 기본 테이블로 저장
|
||||
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
|
||||
|
||||
// 🆕 레이어 정보도 함께 저장 (레이어가 있으면 레이어의 컴포넌트로 업데이트)
|
||||
const updatedLayers = layout.layers?.map((layer) => ({
|
||||
...layer,
|
||||
components: layer.components.map((comp) => {
|
||||
// 분할 패널 업데이트 로직 적용
|
||||
const updatedComp = updatedComponents.find((uc) => uc.id === comp.id);
|
||||
return updatedComp || comp;
|
||||
}),
|
||||
}));
|
||||
|
||||
const layoutWithResolution = {
|
||||
...layout,
|
||||
components: updatedComponents,
|
||||
layers: updatedLayers, // 🆕 레이어 정보 포함
|
||||
screenResolution: screenResolution,
|
||||
mainTableName: currentMainTableName, // 화면의 기본 테이블
|
||||
};
|
||||
// 🔍 버튼 컴포넌트들의 action.type 확인
|
||||
const buttonComponents = layoutWithResolution.components.filter(
|
||||
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
|
||||
);
|
||||
// 💾 저장 로그 (디버그 완료 - 간소화)
|
||||
// console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length });
|
||||
// 분할 패널 디버그 로그 (주석 처리)
|
||||
|
||||
// V2 API 사용 여부에 따라 분기
|
||||
if (USE_V2_API) {
|
||||
// 🔧 V2 레이아웃 저장 (디버그 로그 주석 처리)
|
||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||
// console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
|
||||
// V2/POP API 사용 여부에 따라 분기
|
||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||
if (USE_POP_API) {
|
||||
// POP 모드: screen_layouts_pop 테이블에 저장
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||
} else if (USE_V2_API) {
|
||||
// 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
}
|
||||
|
|
@ -2006,6 +2085,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
}
|
||||
}, [selectedScreen, layout, screenResolution, tables, onScreenUpdate]);
|
||||
|
||||
// POP 미리보기 핸들러 (새 창에서 열기)
|
||||
const handlePopPreview = useCallback(() => {
|
||||
if (!selectedScreen?.screenId) {
|
||||
toast.error("화면 정보가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const deviceType = defaultDevicePreview || "tablet";
|
||||
const previewUrl = `/pop/screens/${selectedScreen.screenId}?preview=true&device=${deviceType}`;
|
||||
window.open(previewUrl, "_blank", "width=800,height=900");
|
||||
}, [selectedScreen, defaultDevicePreview]);
|
||||
|
||||
// 다국어 자동 생성 핸들러
|
||||
const handleGenerateMultilang = useCallback(async () => {
|
||||
if (!selectedScreen?.screenId) {
|
||||
|
|
@ -2084,9 +2175,16 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
// 자동 저장 (매핑 정보가 손실되지 않도록)
|
||||
try {
|
||||
if (USE_V2_API) {
|
||||
const v2Layout = convertLegacyToV2(updatedLayout);
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||
const v2Layout = convertLegacyToV2(updatedLayout);
|
||||
if (USE_POP_API) {
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||
} else if (USE_V2_API) {
|
||||
// 현재 활성 레이어 ID 포함 (레이어별 저장)
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
|
||||
}
|
||||
|
|
@ -2505,10 +2603,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
}
|
||||
});
|
||||
|
||||
// 🆕 현재 활성 레이어에 컴포넌트 추가
|
||||
// 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지)
|
||||
const componentsWithLayerId = newComponents.map((comp) => ({
|
||||
...comp,
|
||||
layerId: activeLayerId || "default-layer",
|
||||
layerId: activeLayerIdRef.current || 1,
|
||||
}));
|
||||
|
||||
// 레이아웃에 새 컴포넌트들 추가
|
||||
|
|
@ -2527,7 +2625,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
|
||||
},
|
||||
[layout, selectedScreen, saveToHistory, activeLayerId],
|
||||
[layout, selectedScreen, saveToHistory],
|
||||
);
|
||||
|
||||
// 레이아웃 드래그 처리
|
||||
|
|
@ -2581,7 +2679,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
label: layoutData.label,
|
||||
allowedComponentTypes: layoutData.allowedComponentTypes,
|
||||
dropZoneConfig: layoutData.dropZoneConfig,
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
} as ComponentData;
|
||||
|
||||
// 레이아웃에 새 컴포넌트 추가
|
||||
|
|
@ -2598,7 +2696,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||
},
|
||||
[layout, screenResolution, saveToHistory, zoomLevel, activeLayerId],
|
||||
[layout, screenResolution, saveToHistory, zoomLevel],
|
||||
);
|
||||
|
||||
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
||||
|
|
@ -2990,9 +3088,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
})
|
||||
: 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 =
|
||||
|
|
@ -3189,7 +3291,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
position: snappedPosition,
|
||||
size: componentSize,
|
||||
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
componentConfig: {
|
||||
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
||||
webType: component.webType, // 웹타입 정보 추가
|
||||
|
|
@ -3223,7 +3325,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
|
||||
},
|
||||
[layout, selectedScreen, saveToHistory, activeLayerId],
|
||||
[layout, selectedScreen, saveToHistory],
|
||||
);
|
||||
|
||||
// 드래그 앤 드롭 처리
|
||||
|
|
@ -3232,7 +3334,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const dragData = e.dataTransfer.getData("application/json");
|
||||
|
|
@ -3264,6 +3366,31 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
return;
|
||||
}
|
||||
|
||||
// 🆕 조건부 영역(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);
|
||||
try {
|
||||
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("Zone 생성 실패:", error);
|
||||
toast.error("조건부 영역 생성에 실패했습니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 테이블/컬럼 드래그 처리
|
||||
const { type, table, column } = parsedData;
|
||||
|
||||
|
|
@ -3595,7 +3722,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
tableName: table.tableName,
|
||||
position: { x, y, z: 1 } as Position,
|
||||
size: { width: 300, height: 200 },
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
style: {
|
||||
labelDisplay: true,
|
||||
labelFontSize: "14px",
|
||||
|
|
@ -3846,7 +3973,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
|
|
@ -3913,7 +4040,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
position: { x, y, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
|
|
@ -4171,9 +4298,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
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,
|
||||
};
|
||||
|
||||
|
|
@ -4736,7 +4869,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
z: clipComponent.position.z || 1,
|
||||
} as Position,
|
||||
parentId: undefined, // 붙여넣기 시 부모 관계 해제
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용)
|
||||
};
|
||||
newComponents.push(newComponent);
|
||||
});
|
||||
|
|
@ -4757,7 +4890,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
// console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
|
||||
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
|
||||
}, [clipboard, layout, saveToHistory, activeLayerId]);
|
||||
}, [clipboard, layout, saveToHistory]);
|
||||
|
||||
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
|
||||
// 🆕 플로우 버튼 그룹 다이얼로그 상태
|
||||
|
|
@ -5461,10 +5594,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
gridSettings: layoutWithResolution.gridSettings,
|
||||
screenResolution: layoutWithResolution.screenResolution,
|
||||
});
|
||||
// V2 API 사용 여부에 따라 분기
|
||||
if (USE_V2_API) {
|
||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||
// V2/POP API 사용 여부에 따라 분기
|
||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||
if (USE_POP_API) {
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||
} else if (USE_V2_API) {
|
||||
// 현재 활성 레이어 ID 포함 (레이어별 저장)
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
}
|
||||
|
|
@ -5657,21 +5797,124 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
};
|
||||
}, [layout, selectedComponent]);
|
||||
|
||||
// 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, DB 기반)
|
||||
const handleRegionMouseDown = useCallback((
|
||||
e: React.MouseEvent,
|
||||
layerId: string,
|
||||
mode: "move" | "resize",
|
||||
handle?: string,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
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;
|
||||
|
||||
const x = (e.clientX - canvasRect.left) / zoomLevel;
|
||||
const y = (e.clientY - canvasRect.top) / zoomLevel;
|
||||
|
||||
setRegionDrag({
|
||||
isDrawing: false,
|
||||
isDragging: mode === "move",
|
||||
isResizing: mode === "resize",
|
||||
targetLayerId: String(zoneId),
|
||||
startX: x,
|
||||
startY: y,
|
||||
currentX: x,
|
||||
currentY: y,
|
||||
resizeHandle: handle || null,
|
||||
originalRegion: { x: zone.x, y: zone.y, width: zone.width, height: zone.height },
|
||||
});
|
||||
}, [zones, zoomLevel]);
|
||||
|
||||
// 🆕 캔버스 마우스 이벤트 (영역 이동/리사이즈)
|
||||
const handleRegionCanvasMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!regionDrag.isDragging && !regionDrag.isResizing) return;
|
||||
if (!regionDrag.targetLayerId) return;
|
||||
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!canvasRect) return;
|
||||
|
||||
const x = (e.clientX - canvasRect.left) / zoomLevel;
|
||||
const y = (e.clientY - canvasRect.top) / zoomLevel;
|
||||
|
||||
if (regionDrag.isDragging && regionDrag.originalRegion) {
|
||||
const dx = x - regionDrag.startX;
|
||||
const dy = y - regionDrag.startY;
|
||||
const newRegion = {
|
||||
x: Math.max(0, Math.round(regionDrag.originalRegion.x + dx)),
|
||||
y: Math.max(0, Math.round(regionDrag.originalRegion.y + dy)),
|
||||
width: regionDrag.originalRegion.width,
|
||||
height: regionDrag.originalRegion.height,
|
||||
};
|
||||
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;
|
||||
const orig = regionDrag.originalRegion;
|
||||
const newRegion = { ...orig };
|
||||
|
||||
const handle = regionDrag.resizeHandle;
|
||||
if (handle?.includes("e")) newRegion.width = Math.max(50, Math.round(orig.width + dx));
|
||||
if (handle?.includes("s")) newRegion.height = Math.max(30, Math.round(orig.height + dy));
|
||||
if (handle?.includes("w")) {
|
||||
newRegion.x = Math.max(0, Math.round(orig.x + dx));
|
||||
newRegion.width = Math.max(50, Math.round(orig.width - dx));
|
||||
}
|
||||
if (handle?.includes("n")) {
|
||||
newRegion.y = Math.max(0, Math.round(orig.y + dy));
|
||||
newRegion.height = Math.max(30, Math.round(orig.height - dy));
|
||||
}
|
||||
|
||||
const zoneId = Number(regionDrag.targetLayerId);
|
||||
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
|
||||
}
|
||||
}, [regionDrag, zoomLevel]);
|
||||
|
||||
const handleRegionCanvasMouseUp = useCallback(async () => {
|
||||
// 드래그 완료 시 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 {
|
||||
await screenApi.updateZone(zoneId, {
|
||||
x: zone.x, y: zone.y, width: zone.width, height: zone.height,
|
||||
});
|
||||
} catch {
|
||||
console.error("Zone 저장 실패");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 드래그 상태 초기화
|
||||
setRegionDrag({
|
||||
isDrawing: false,
|
||||
isDragging: false,
|
||||
isResizing: false,
|
||||
targetLayerId: null,
|
||||
startX: 0, startY: 0, currentX: 0, currentY: 0,
|
||||
resizeHandle: null,
|
||||
originalRegion: null,
|
||||
});
|
||||
}, [regionDrag, zones]);
|
||||
|
||||
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
|
||||
// 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음
|
||||
// Zone 기반이므로 displayRegion 보존 불필요
|
||||
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
|
||||
setLayout((prevLayout) => ({
|
||||
...prevLayout,
|
||||
layers: newLayers,
|
||||
// components는 그대로 유지 - layerId 속성으로 레이어 구분
|
||||
// components: prevLayout.components (기본값으로 유지됨)
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 🆕 활성 레이어 변경 핸들러
|
||||
const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => {
|
||||
setActiveLayerIdLocal(newActiveLayerId);
|
||||
}, []);
|
||||
const handleActiveLayerChange = useCallback((newActiveLayerId: number) => {
|
||||
setActiveLayerIdWithRef(newActiveLayerId);
|
||||
}, [setActiveLayerIdWithRef]);
|
||||
|
||||
// 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성
|
||||
// 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠
|
||||
|
|
@ -5721,6 +5964,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
onBack={onBackToList}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
onPreview={isPop ? handlePopPreview : undefined}
|
||||
onResolutionChange={setScreenResolution}
|
||||
gridSettings={layout.gridSettings}
|
||||
onGridSettingsChange={updateGridSettings}
|
||||
|
|
@ -5751,7 +5995,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col">
|
||||
<Tabs value={leftPanelTab} onValueChange={setLeftPanelTab} className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-3 gap-1">
|
||||
<TabsTrigger value="components" className="text-xs">
|
||||
컴포넌트
|
||||
|
|
@ -5784,9 +6028,43 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 🆕 레이어 관리 탭 */}
|
||||
{/* 🆕 레이어 관리 탭 (DB 기반) */}
|
||||
<TabsContent value="layers" className="mt-0 flex-1 overflow-hidden">
|
||||
<LayerManagerPanel components={layout.components} />
|
||||
<LayerManagerPanel
|
||||
screenId={selectedScreen?.screenId || null}
|
||||
activeLayerId={Number(activeLayerIdRef.current) || 1}
|
||||
onLayerChange={async (layerId) => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
try {
|
||||
// 1. 현재 레이어 저장
|
||||
const curId = Number(activeLayerIdRef.current) || 1;
|
||||
const v2Layout = convertLegacyToV2({ ...layout, screenResolution });
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: curId });
|
||||
|
||||
// 2. 새 레이어 로드
|
||||
const data = await screenApi.getLayerLayout(selectedScreen.screenId, layerId);
|
||||
if (data && data.components) {
|
||||
const legacy = convertV2ToLegacy(data);
|
||||
if (legacy) {
|
||||
setLayout((prev) => ({ ...prev, components: legacy.components }));
|
||||
} else {
|
||||
setLayout((prev) => ({ ...prev, components: [] }));
|
||||
}
|
||||
} else {
|
||||
setLayout((prev) => ({ ...prev, components: [] }));
|
||||
}
|
||||
|
||||
setActiveLayerIdWithRef(layerId);
|
||||
setSelectedComponent(null);
|
||||
} catch (error) {
|
||||
console.error("레이어 전환 실패:", error);
|
||||
toast.error("레이어 전환에 실패했습니다.");
|
||||
}
|
||||
}}
|
||||
components={layout.components}
|
||||
zones={zones}
|
||||
onZonesChange={setZones}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
|
||||
|
|
@ -6359,24 +6637,54 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */}
|
||||
{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} 편집 중
|
||||
{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", // 중앙 기준으로 스케일
|
||||
|
|
@ -6399,6 +6707,22 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
startSelectionDrag(e);
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
// 영역 이동/리사이즈 처리
|
||||
if (regionDrag.isDragging || regionDrag.isResizing) {
|
||||
handleRegionCanvasMouseMove(e);
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (regionDrag.isDragging || regionDrag.isResizing) {
|
||||
handleRegionCanvasMouseUp();
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (regionDrag.isDragging || regionDrag.isResizing) {
|
||||
handleRegionCanvasMouseUp();
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
|
|
@ -6467,6 +6791,106 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* 조건부 영역(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",
|
||||
n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize",
|
||||
};
|
||||
const handlePositions: Record<string, React.CSSProperties> = {
|
||||
nw: { top: -4, left: -4 }, ne: { top: -4, right: -4 },
|
||||
sw: { bottom: -4, left: -4 }, se: { bottom: -4, right: -4 },
|
||||
n: { top: -4, left: "50%", transform: "translateX(-50%)" },
|
||||
s: { bottom: -4, left: "50%", transform: "translateX(-50%)" },
|
||||
e: { top: "50%", right: -4, transform: "translateY(-50%)" },
|
||||
w: { top: "50%", left: -4, transform: "translateY(-50%)" },
|
||||
};
|
||||
// 테두리 두께 (이동 핸들 영역)
|
||||
const borderWidth = 6;
|
||||
return (
|
||||
<div
|
||||
key={`region-${layerId}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${region.x}px`,
|
||||
top: `${region.y}px`,
|
||||
width: `${region.width}px`,
|
||||
height: `${region.height}px`,
|
||||
border: "2px dashed hsl(var(--primary))",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "hsl(var(--primary) / 0.05)",
|
||||
zIndex: 50,
|
||||
pointerEvents: "none", // 내부 클릭은 아래 컴포넌트로 통과
|
||||
}}
|
||||
>
|
||||
{/* 테두리 이동 핸들: 상/하/좌/우 얇은 영역만 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], 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 {
|
||||
await screenApi.deleteZone(zone.zone_id);
|
||||
setZones((prev) => prev.filter(z => z.zone_id !== zone.zone_id));
|
||||
toast.success("조건부 영역이 삭제되었습니다.");
|
||||
} catch { toast.error("Zone 삭제 실패"); }
|
||||
}}
|
||||
title="영역 삭제"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{regularComponents.map((component) => {
|
||||
const children =
|
||||
|
|
@ -7006,8 +7430,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>{" "}
|
||||
{/* 🔥 줌 래퍼 닫기 */}
|
||||
</div>
|
||||
); /* 🔥 줌 래퍼 닫기 */
|
||||
})()}
|
||||
</div>
|
||||
</div>{" "}
|
||||
{/* 메인 컨테이너 닫기 */}
|
||||
|
|
|
|||
|
|
@ -1872,6 +1872,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
id: screenToPreview!.screenId,
|
||||
tableName: screenToPreview?.tableName,
|
||||
}}
|
||||
layers={previewLayout.layers || []}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ interface ScreenRelationFlowProps {
|
|||
screen: ScreenDefinition | null;
|
||||
selectedGroup?: { id: number; name: string; company_code?: string } | null;
|
||||
initialFocusedScreenId?: number | null;
|
||||
isPop?: boolean;
|
||||
}
|
||||
|
||||
// 노드 타입 (Record<string, unknown> 확장)
|
||||
|
|
@ -69,7 +70,7 @@ type TableNodeType = Node<TableNodeData & Record<string, unknown>>;
|
|||
type AllNodeType = ScreenNodeType | TableNodeType;
|
||||
|
||||
// 내부 컴포넌트 (useReactFlow 사용 가능)
|
||||
function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId }: ScreenRelationFlowProps) {
|
||||
function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId, isPop = false }: ScreenRelationFlowProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<AllNodeType>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -2352,6 +2353,7 @@ function ScreenRelationFlowInner({ screen, selectedGroup, initialFocusedScreenId
|
|||
fieldMappings={settingModalNode.existingConfig?.fieldMappings}
|
||||
componentCount={0}
|
||||
onSaveSuccess={handleRefreshVisualization}
|
||||
isPop={isPop}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -450,8 +450,8 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
|||
|
||||
{onPreview && (
|
||||
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
|
||||
<Smartphone className="h-4 w-4" />
|
||||
<span>반응형 미리보기</span>
|
||||
<Eye className="h-4 w-4" />
|
||||
<span>POP 미리보기</span>
|
||||
</Button>
|
||||
)}
|
||||
{onGenerateMultilang && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel;
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
|
||||
// ========================================
|
||||
// 타입 정의
|
||||
// ========================================
|
||||
export type DeviceType = "mobile" | "tablet";
|
||||
export type OrientationType = "landscape" | "portrait";
|
||||
|
||||
export interface ResponsiveMode {
|
||||
device: DeviceType;
|
||||
orientation: OrientationType;
|
||||
isLandscape: boolean;
|
||||
modeKey: "tablet_landscape" | "tablet_portrait" | "mobile_landscape" | "mobile_portrait";
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 브레이크포인트 (화면 너비 기준)
|
||||
// GRID_BREAKPOINTS와 일치해야 함!
|
||||
// ========================================
|
||||
const BREAKPOINTS = {
|
||||
// mobile_portrait: ~479px (4칸)
|
||||
// mobile_landscape: 480~767px (6칸)
|
||||
// tablet_portrait: 768~1023px (8칸)
|
||||
// tablet_landscape: 1024px~ (12칸)
|
||||
TABLET_MIN: 768, // 768px 이상이면 tablet
|
||||
};
|
||||
|
||||
/**
|
||||
* 반응형 모드 자동 감지 훅
|
||||
*
|
||||
* - 화면 크기와 방향에 따라 4가지 모드 자동 전환
|
||||
* - tablet_landscape, tablet_portrait, mobile_landscape, mobile_portrait
|
||||
* - resize 이벤트와 orientation 변경 모두 감지
|
||||
*
|
||||
* @returns ResponsiveMode 객체
|
||||
*/
|
||||
export function useResponsiveMode(): ResponsiveMode {
|
||||
const [mode, setMode] = useState<ResponsiveMode>({
|
||||
device: "tablet",
|
||||
orientation: "landscape",
|
||||
isLandscape: true,
|
||||
modeKey: "tablet_landscape",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const detectMode = (): ResponsiveMode => {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
// 디바이스 타입 결정 (화면 너비 기준)
|
||||
const device: DeviceType = width >= BREAKPOINTS.TABLET_MIN ? "tablet" : "mobile";
|
||||
|
||||
// 방향 결정 (가로/세로 비율)
|
||||
const isLandscape = width > height;
|
||||
const orientation: OrientationType = isLandscape ? "landscape" : "portrait";
|
||||
|
||||
// 모드 키 생성
|
||||
const modeKey = `${device}_${orientation}` as ResponsiveMode["modeKey"];
|
||||
|
||||
return { device, orientation, isLandscape, modeKey };
|
||||
};
|
||||
|
||||
// 초기값 설정
|
||||
setMode(detectMode());
|
||||
|
||||
const handleChange = () => {
|
||||
setTimeout(() => {
|
||||
setMode(detectMode());
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 이벤트 리스너 등록
|
||||
window.addEventListener("resize", handleChange);
|
||||
window.addEventListener("orientationchange", handleChange);
|
||||
|
||||
// matchMedia로 orientation 변경 감지
|
||||
const landscapeQuery = window.matchMedia("(orientation: landscape)");
|
||||
if (landscapeQuery.addEventListener) {
|
||||
landscapeQuery.addEventListener("change", handleChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleChange);
|
||||
window.removeEventListener("orientationchange", handleChange);
|
||||
if (landscapeQuery.removeEventListener) {
|
||||
landscapeQuery.removeEventListener("change", handleChange);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디바이스 방향(orientation) 감지 커스텀 훅
|
||||
*
|
||||
* - 실제 디바이스에서 가로/세로 방향 변경을 감지
|
||||
* - window.matchMedia와 orientationchange 이벤트 활용
|
||||
* - SSR 호환성 고려 (typeof window !== 'undefined')
|
||||
*
|
||||
* @returns isLandscape - true: 가로 모드, false: 세로 모드
|
||||
*/
|
||||
export function useDeviceOrientation(): boolean {
|
||||
const [isLandscape, setIsLandscape] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const detectOrientation = (): boolean => {
|
||||
if (window.matchMedia) {
|
||||
const landscapeQuery = window.matchMedia("(orientation: landscape)");
|
||||
return landscapeQuery.matches;
|
||||
}
|
||||
return window.innerWidth > window.innerHeight;
|
||||
};
|
||||
|
||||
setIsLandscape(detectOrientation());
|
||||
|
||||
const handleOrientationChange = () => {
|
||||
setTimeout(() => {
|
||||
setIsLandscape(detectOrientation());
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const landscapeQuery = window.matchMedia("(orientation: landscape)");
|
||||
|
||||
if (landscapeQuery.addEventListener) {
|
||||
landscapeQuery.addEventListener("change", handleOrientationChange);
|
||||
} else if (landscapeQuery.addListener) {
|
||||
landscapeQuery.addListener(handleOrientationChange);
|
||||
}
|
||||
|
||||
window.addEventListener("orientationchange", handleOrientationChange);
|
||||
window.addEventListener("resize", handleOrientationChange);
|
||||
|
||||
return () => {
|
||||
if (landscapeQuery.removeEventListener) {
|
||||
landscapeQuery.removeEventListener("change", handleOrientationChange);
|
||||
} else if (landscapeQuery.removeListener) {
|
||||
landscapeQuery.removeListener(handleOrientationChange);
|
||||
}
|
||||
window.removeEventListener("orientationchange", handleOrientationChange);
|
||||
window.removeEventListener("resize", handleOrientationChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isLandscape;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수동 방향 전환을 지원하는 확장 훅
|
||||
* 프리뷰 모드에서 테스트 목적으로 사용
|
||||
*
|
||||
* @param initialOverride - 초기 수동 설정값 (undefined면 자동 감지)
|
||||
* @returns [isLandscape, setIsLandscape, isAutoDetect]
|
||||
*/
|
||||
export function useDeviceOrientationWithOverride(
|
||||
initialOverride?: boolean
|
||||
): [boolean, (value: boolean | undefined) => void, boolean] {
|
||||
const autoDetectedIsLandscape = useDeviceOrientation();
|
||||
const [manualOverride, setManualOverride] = useState<boolean | undefined>(initialOverride);
|
||||
|
||||
const isLandscape = manualOverride !== undefined ? manualOverride : autoDetectedIsLandscape;
|
||||
const isAutoDetect = manualOverride === undefined;
|
||||
|
||||
const setOrientation = (value: boolean | undefined) => {
|
||||
setManualOverride(value);
|
||||
};
|
||||
|
||||
return [isLandscape, setOrientation, isAutoDetect];
|
||||
}
|
||||
|
||||
/**
|
||||
* 반응형 모드 + 수동 오버라이드 지원 훅
|
||||
* 프리뷰 모드에서 디바이스/방향을 수동으로 변경할 때 사용
|
||||
*/
|
||||
export function useResponsiveModeWithOverride(
|
||||
initialDeviceOverride?: DeviceType,
|
||||
initialOrientationOverride?: boolean
|
||||
): {
|
||||
mode: ResponsiveMode;
|
||||
setDevice: (device: DeviceType | undefined) => void;
|
||||
setOrientation: (isLandscape: boolean | undefined) => void;
|
||||
isAutoDetect: boolean;
|
||||
} {
|
||||
const autoMode = useResponsiveMode();
|
||||
const [deviceOverride, setDeviceOverride] = useState<DeviceType | undefined>(initialDeviceOverride);
|
||||
const [orientationOverride, setOrientationOverride] = useState<boolean | undefined>(initialOrientationOverride);
|
||||
|
||||
const mode = useMemo((): ResponsiveMode => {
|
||||
const device = deviceOverride ?? autoMode.device;
|
||||
const isLandscape = orientationOverride ?? autoMode.isLandscape;
|
||||
const orientation: OrientationType = isLandscape ? "landscape" : "portrait";
|
||||
const modeKey = `${device}_${orientation}` as ResponsiveMode["modeKey"];
|
||||
|
||||
return { device, orientation, isLandscape, modeKey };
|
||||
}, [autoMode, deviceOverride, orientationOverride]);
|
||||
|
||||
const isAutoDetect = deviceOverride === undefined && orientationOverride === undefined;
|
||||
|
||||
return {
|
||||
mode,
|
||||
setDevice: setDeviceOverride,
|
||||
setOrientation: setOrientationOverride,
|
||||
isAutoDetect,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
|
||||
// ========================================
|
||||
// 레이아웃 히스토리 훅
|
||||
// Undo/Redo 기능 제공
|
||||
// ========================================
|
||||
|
||||
interface HistoryState<T> {
|
||||
past: T[];
|
||||
present: T;
|
||||
future: T[];
|
||||
}
|
||||
|
||||
interface UseLayoutHistoryReturn<T> {
|
||||
// 현재 상태
|
||||
state: T;
|
||||
// 상태 설정 (히스토리에 기록)
|
||||
setState: (newState: T | ((prev: T) => T)) => void;
|
||||
// Undo
|
||||
undo: () => void;
|
||||
// Redo
|
||||
redo: () => void;
|
||||
// Undo 가능 여부
|
||||
canUndo: boolean;
|
||||
// Redo 가능 여부
|
||||
canRedo: boolean;
|
||||
// 히스토리 초기화 (새 레이아웃 로드 시)
|
||||
reset: (initialState: T) => void;
|
||||
// 히스토리 크기
|
||||
historySize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 히스토리 관리 훅
|
||||
* @param initialState 초기 상태
|
||||
* @param maxHistory 최대 히스토리 개수 (기본 50)
|
||||
*/
|
||||
export function useLayoutHistory<T>(
|
||||
initialState: T,
|
||||
maxHistory: number = 50
|
||||
): UseLayoutHistoryReturn<T> {
|
||||
const [history, setHistory] = useState<HistoryState<T>>({
|
||||
past: [],
|
||||
present: initialState,
|
||||
future: [],
|
||||
});
|
||||
|
||||
// 배치 업데이트를 위한 타이머
|
||||
const batchTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pendingStateRef = useRef<T | null>(null);
|
||||
|
||||
/**
|
||||
* 상태 설정 (히스토리에 기록)
|
||||
* 연속적인 드래그 등의 작업 시 배치 처리
|
||||
*/
|
||||
const setState = useCallback(
|
||||
(newState: T | ((prev: T) => T)) => {
|
||||
setHistory((prev) => {
|
||||
const resolvedState =
|
||||
typeof newState === "function"
|
||||
? (newState as (prev: T) => T)(prev.present)
|
||||
: newState;
|
||||
|
||||
// 같은 상태면 무시
|
||||
if (JSON.stringify(resolvedState) === JSON.stringify(prev.present)) {
|
||||
console.log("[History] 상태 동일, 무시");
|
||||
return prev;
|
||||
}
|
||||
|
||||
// 히스토리에 현재 상태 추가
|
||||
const newPast = [...prev.past, prev.present];
|
||||
|
||||
// 최대 히스토리 개수 제한
|
||||
if (newPast.length > maxHistory) {
|
||||
newPast.shift();
|
||||
}
|
||||
|
||||
console.log("[History] 상태 저장, past 크기:", newPast.length);
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
present: resolvedState,
|
||||
future: [], // Redo 히스토리 초기화
|
||||
};
|
||||
});
|
||||
},
|
||||
[maxHistory]
|
||||
);
|
||||
|
||||
/**
|
||||
* 배치 상태 설정 (드래그 중 연속 업데이트용)
|
||||
* 마지막 상태만 히스토리에 기록
|
||||
*/
|
||||
const setStateBatched = useCallback(
|
||||
(newState: T | ((prev: T) => T), batchDelay: number = 300) => {
|
||||
// 현재 상태 업데이트 (히스토리에는 바로 기록하지 않음)
|
||||
setHistory((prev) => {
|
||||
const resolvedState =
|
||||
typeof newState === "function"
|
||||
? (newState as (prev: T) => T)(prev.present)
|
||||
: newState;
|
||||
|
||||
pendingStateRef.current = prev.present;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
present: resolvedState,
|
||||
};
|
||||
});
|
||||
|
||||
// 배치 타이머 리셋
|
||||
if (batchTimerRef.current) {
|
||||
clearTimeout(batchTimerRef.current);
|
||||
}
|
||||
|
||||
// 일정 시간 후 히스토리에 기록
|
||||
batchTimerRef.current = setTimeout(() => {
|
||||
if (pendingStateRef.current !== null) {
|
||||
setHistory((prev) => {
|
||||
const newPast = [...prev.past, pendingStateRef.current as T];
|
||||
if (newPast.length > maxHistory) {
|
||||
newPast.shift();
|
||||
}
|
||||
pendingStateRef.current = null;
|
||||
return {
|
||||
...prev,
|
||||
past: newPast,
|
||||
future: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
}, batchDelay);
|
||||
},
|
||||
[maxHistory]
|
||||
);
|
||||
|
||||
/**
|
||||
* Undo - 이전 상태로 복원
|
||||
*/
|
||||
const undo = useCallback(() => {
|
||||
console.log("[History] Undo 호출");
|
||||
setHistory((prev) => {
|
||||
console.log("[History] Undo 실행, past 크기:", prev.past.length);
|
||||
if (prev.past.length === 0) {
|
||||
console.log("[History] Undo 불가 - past 비어있음");
|
||||
return prev;
|
||||
}
|
||||
|
||||
const newPast = [...prev.past];
|
||||
const previousState = newPast.pop()!;
|
||||
|
||||
console.log("[History] Undo 성공, 남은 past 크기:", newPast.length);
|
||||
|
||||
return {
|
||||
past: newPast,
|
||||
present: previousState,
|
||||
future: [prev.present, ...prev.future],
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Redo - 되돌린 상태 다시 적용
|
||||
*/
|
||||
const redo = useCallback(() => {
|
||||
setHistory((prev) => {
|
||||
if (prev.future.length === 0) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const newFuture = [...prev.future];
|
||||
const nextState = newFuture.shift()!;
|
||||
|
||||
return {
|
||||
past: [...prev.past, prev.present],
|
||||
present: nextState,
|
||||
future: newFuture,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 히스토리 초기화 (새 레이아웃 로드 시)
|
||||
*/
|
||||
const reset = useCallback((initialState: T) => {
|
||||
setHistory({
|
||||
past: [],
|
||||
present: initialState,
|
||||
future: [],
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state: history.present,
|
||||
setState,
|
||||
undo,
|
||||
redo,
|
||||
canUndo: history.past.length > 0,
|
||||
canRedo: history.future.length > 0,
|
||||
reset,
|
||||
historySize: history.past.length,
|
||||
};
|
||||
}
|
||||
|
||||
export default useLayoutHistory;
|
||||
|
|
@ -157,7 +157,7 @@ const refreshToken = async (): Promise<string | null> => {
|
|||
headers: {
|
||||
Authorization: `Bearer ${currentToken}`,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (response.data?.success && response.data?.data?.token) {
|
||||
|
|
@ -192,13 +192,16 @@ const startAutoRefresh = (): void => {
|
|||
}
|
||||
|
||||
// 10분마다 토큰 상태 확인
|
||||
tokenRefreshTimer = setInterval(async () => {
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
|
||||
await refreshToken();
|
||||
}
|
||||
}, 10 * 60 * 1000); // 10분
|
||||
tokenRefreshTimer = setInterval(
|
||||
async () => {
|
||||
const token = TokenManager.getToken();
|
||||
if (token && TokenManager.isTokenExpiringSoon(token)) {
|
||||
console.log("[TokenManager] 토큰 만료 임박, 자동 갱신 시작...");
|
||||
await refreshToken();
|
||||
}
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
); // 10분
|
||||
|
||||
// 페이지 로드 시 즉시 확인
|
||||
const token = TokenManager.getToken();
|
||||
|
|
@ -230,14 +233,18 @@ const setupActivityBasedRefresh = (): void => {
|
|||
["click", "keydown", "scroll", "mousemove"].forEach((event) => {
|
||||
// 너무 잦은 호출 방지를 위해 throttle 적용
|
||||
let throttleTimer: NodeJS.Timeout | null = null;
|
||||
window.addEventListener(event, () => {
|
||||
if (!throttleTimer) {
|
||||
throttleTimer = setTimeout(() => {
|
||||
handleActivity();
|
||||
throttleTimer = null;
|
||||
}, 1000); // 1초 throttle
|
||||
}
|
||||
}, { passive: true });
|
||||
window.addEventListener(
|
||||
event,
|
||||
() => {
|
||||
if (!throttleTimer) {
|
||||
throttleTimer = setTimeout(() => {
|
||||
handleActivity();
|
||||
throttleTimer = null;
|
||||
}, 1000); // 1초 throttle
|
||||
}
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue