chpark-sync #425

Merged
kjs merged 293 commits from chpark-sync into main 2026-03-23 09:36:36 +09:00
174 changed files with 45648 additions and 3934 deletions
Showing only changes of commit 022c61423c - Show all commits

1041
POPUPDATE.md Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 정보 (참고용으로만 사용, 필터링하지 않음)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 트리거)

View File

@ -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 정보를 맵으로 변환

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1728
docs/DB_WORKFLOW_ANALYSIS.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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. [사용] 사용자가 메뉴 클릭 → 업무 시작!
```

View File

@ -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 추가 시

View File

@ -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": []
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 />
&apos; &apos; .
</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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export * from "./dnd";

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
// POP 디자이너 패널 export (v5 그리드 시스템)
export { default as ComponentEditorPanel } from "./ComponentEditorPanel";
export { default as ComponentPalette } from "./ComponentPalette";

View File

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

View File

@ -0,0 +1,4 @@
// POP 레이아웃 렌더러 모듈 (v5 그리드 시스템)
// 디자이너와 뷰어에서 동일한 렌더링을 보장하기 위한 공용 렌더러
export { default as PopRenderer } from "./PopRenderer";

View File

@ -0,0 +1,2 @@
// POP 디자이너 타입 export
export * from "./pop-layout";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
/**
* POP
*/
export { PopCategoryTree } from "./PopCategoryTree";
export { PopScreenPreview } from "./PopScreenPreview";
export { PopScreenFlowView } from "./PopScreenFlowView";
export { PopScreenSettingModal } from "./PopScreenSettingModal";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>{" "}
{/* 메인 컨테이너 닫기 */}

View File

@ -1872,6 +1872,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
id: screenToPreview!.screenId,
tableName: screenToPreview?.tableName,
}}
layers={previewLayout.layers || []}
/>
</div>
))}

View File

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

View File

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

View File

@ -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} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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