jskim-node #388

Merged
kjs merged 58 commits from jskim-node into main 2026-02-13 09:59:55 +09:00
96 changed files with 20692 additions and 3660 deletions

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

@ -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
@ -2839,4 +2839,4 @@ export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Respons
logger.error("POP 루트 그룹 확보 실패:", error);
res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message });
}
};
};

View File

@ -787,6 +787,78 @@ export const updateLayerCondition = async (req: AuthenticatedRequest, res: Respo
}
};
// ========================================
// 조건부 영역(Zone) 관리
// ========================================
// Zone 목록 조회
export const getScreenZones = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const zones = await screenManagementService.getScreenZones(parseInt(screenId), companyCode);
res.json({ success: true, data: zones });
} catch (error) {
console.error("Zone 목록 조회 실패:", error);
res.status(500).json({ success: false, message: "Zone 목록 조회에 실패했습니다." });
}
};
// Zone 생성
export const createZone = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId } = req.params;
const { companyCode } = req.user as any;
const zone = await screenManagementService.createZone(parseInt(screenId), companyCode, req.body);
res.json({ success: true, data: zone });
} catch (error) {
console.error("Zone 생성 실패:", error);
res.status(500).json({ success: false, message: "Zone 생성에 실패했습니다." });
}
};
// Zone 업데이트 (위치/크기/트리거)
export const updateZone = async (req: AuthenticatedRequest, res: Response) => {
try {
const { zoneId } = req.params;
const { companyCode } = req.user as any;
await screenManagementService.updateZone(parseInt(zoneId), companyCode, req.body);
res.json({ success: true, message: "Zone이 업데이트되었습니다." });
} catch (error) {
console.error("Zone 업데이트 실패:", error);
res.status(500).json({ success: false, message: "Zone 업데이트에 실패했습니다." });
}
};
// Zone 삭제
export const deleteZone = async (req: AuthenticatedRequest, res: Response) => {
try {
const { zoneId } = req.params;
const { companyCode } = req.user as any;
await screenManagementService.deleteZone(parseInt(zoneId), companyCode);
res.json({ success: true, message: "Zone이 삭제되었습니다." });
} catch (error) {
console.error("Zone 삭제 실패:", error);
res.status(500).json({ success: false, message: "Zone 삭제에 실패했습니다." });
}
};
// Zone에 레이어 추가
export const addLayerToZone = async (req: AuthenticatedRequest, res: Response) => {
try {
const { screenId, zoneId } = req.params;
const { companyCode } = req.user as any;
const { conditionValue, layerName } = req.body;
const result = await screenManagementService.addLayerToZone(
parseInt(screenId), companyCode, parseInt(zoneId), conditionValue, layerName
);
res.json({ success: true, data: result });
} catch (error) {
console.error("Zone 레이어 추가 실패:", error);
res.status(500).json({ success: false, message: "Zone에 레이어를 추가하지 못했습니다." });
}
};
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// ========================================

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

@ -46,6 +46,11 @@ import {
getLayerLayout,
deleteLayer,
updateLayerCondition,
getScreenZones,
createZone,
updateZone,
deleteZone,
addLayerToZone,
} from "../controllers/screenManagementController";
const router = express.Router();
@ -98,6 +103,13 @@ router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특
router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제
router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정
// 조건부 영역(Zone) 관리
router.get("/screens/:screenId/zones", getScreenZones); // Zone 목록
router.post("/screens/:screenId/zones", createZone); // Zone 생성
router.put("/zones/:zoneId", updateZone); // Zone 업데이트
router.delete("/zones/:zoneId", deleteZone); // Zone 삭제
router.post("/screens/:screenId/zones/:zoneId/layers", addLayerToZone); // Zone에 레이어 추가
// POP 레이아웃 관리 (모바일/태블릿)
router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회
router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장

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. 데이터를 마스터 키로 그룹화
const groupedData = new Map<string, Record<string, any>[]>();
for (const row of data) {
const masterKey = row[masterKeyColumn];
if (!masterKey) {
result.errors.push(`마스터 키(${masterKeyColumn}) 값이 없는 행이 있습니다.`);
continue;
}
// 마스터/디테일 테이블의 실제 컬럼 존재 여부 확인 (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));
if (!groupedData.has(masterKey)) {
groupedData.set(masterKey, []);
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>[]>();
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);
}
groupedData.get(masterKey)!.push(row);
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);
}
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

@ -1739,7 +1739,7 @@ export class ScreenManagementService {
// V2 레이아웃이 있으면 V2 형식으로 반환
if (v2Layout && v2Layout.layout_data) {
console.log(`V2 레이아웃 발견, V2 형식으로 반환`);
const layoutData = v2Layout.layout_data;
// URL에서 컴포넌트 타입 추출하는 헬퍼 함수
@ -1799,7 +1799,7 @@ export class ScreenManagementService {
};
}
console.log(`V2 레이아웃 없음, V1 테이블 조회`);
const layouts = await query<any>(
`SELECT * FROM screen_layouts
@ -4254,7 +4254,7 @@ export class ScreenManagementService {
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
);
console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`);
} catch (error) {
console.error("V2 레이아웃 복사 중 오류:", error);
// 레이아웃 복사 실패해도 화면 생성은 유지
@ -5045,8 +5045,7 @@ export class ScreenManagementService {
companyCode: string,
userType?: string,
): Promise<any | null> {
console.log(`=== V2 레이아웃 로드 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`);
// SUPER_ADMIN 여부 확인
const isSuperAdmin = userType === "SUPER_ADMIN";
@ -5136,13 +5135,11 @@ export class ScreenManagementService {
}
if (!layout) {
console.log(`V2 레이아웃 없음: screen_id=${screenId}`);
return null;
}
console.log(
`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
);
return layout.layout_data;
}
@ -5158,11 +5155,11 @@ export class ScreenManagementService {
): Promise<void> {
const layerId = layoutData.layerId || 1;
const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`);
// conditionConfig가 명시적으로 전달되었는지 확인 (undefined = 미전달, null/object = 명시적 전달)
const hasConditionConfig = 'conditionConfig' in layoutData;
const conditionConfig = layoutData.conditionConfig || null;
console.log(`=== V2 레이아웃 저장 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`);
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
// 권한 확인
const screens = await query<{ company_code: string | null }>(
@ -5187,16 +5184,27 @@ export class ScreenManagementService {
...pureLayoutData,
};
// UPSERT (레이어별 저장)
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
);
if (hasConditionConfig) {
// conditionConfig가 명시적으로 전달된 경우: condition_config도 함께 저장
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, condition_config, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $6, layer_name = $4, condition_config = $5, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, conditionConfig ? JSON.stringify(conditionConfig) : null, JSON.stringify(dataToSave)],
);
} else {
// conditionConfig가 전달되지 않은 경우: 기존 condition_config 유지, layout_data만 업데이트
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (screen_id, company_code, layer_id)
DO UPDATE SET layout_data = $5, layer_name = $4, updated_at = NOW()`,
[screenId, companyCode, layerId, layerName, JSON.stringify(dataToSave)],
);
}
console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId})`);
}
/**
@ -5349,6 +5357,172 @@ export class ScreenManagementService {
);
}
// ========================================
// 조건부 영역(Zone) 관리
// ========================================
/**
* (Zone)
*/
async getScreenZones(screenId: number, companyCode: string): Promise<any[]> {
let zones;
if (companyCode === "*") {
// 최고 관리자: 모든 회사 Zone 조회 가능
zones = await query<any>(
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1 ORDER BY zone_id`,
[screenId],
);
} else {
// 일반 회사: 자사 Zone + 공통(*) Zone 조회
zones = await query<any>(
`SELECT * FROM screen_conditional_zones
WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*')
ORDER BY zone_id`,
[screenId, companyCode],
);
}
return zones;
}
/**
* (Zone)
*/
async createZone(
screenId: number,
companyCode: string,
zoneData: {
zone_name?: string;
x: number;
y: number;
width: number;
height: number;
trigger_component_id?: string;
trigger_operator?: string;
},
): Promise<any> {
const result = await queryOne<any>(
`INSERT INTO screen_conditional_zones
(screen_id, company_code, zone_name, x, y, width, height, trigger_component_id, trigger_operator)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[
screenId,
companyCode,
zoneData.zone_name || '조건부 영역',
zoneData.x,
zoneData.y,
zoneData.width,
zoneData.height,
zoneData.trigger_component_id || null,
zoneData.trigger_operator || 'eq',
],
);
return result;
}
/**
* (Zone) (//)
*/
async updateZone(
zoneId: number,
companyCode: string,
updates: {
zone_name?: string;
x?: number;
y?: number;
width?: number;
height?: number;
trigger_component_id?: string;
trigger_operator?: string;
},
): Promise<void> {
const setClauses: string[] = ['updated_at = NOW()'];
const params: any[] = [zoneId, companyCode];
let paramIdx = 3;
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) {
setClauses.push(`${key} = $${paramIdx}`);
params.push(value);
paramIdx++;
}
}
await query(
`UPDATE screen_conditional_zones SET ${setClauses.join(', ')}
WHERE zone_id = $1 AND company_code = $2`,
params,
);
}
/**
* (Zone) + condition_config
*/
async deleteZone(zoneId: number, companyCode: string): Promise<void> {
// Zone에 소속된 레이어들의 condition_config에서 zone_id 제거
await query(
`UPDATE screen_layouts_v2 SET condition_config = NULL, updated_at = NOW()
WHERE company_code = $1 AND condition_config->>'zone_id' = $2::text`,
[companyCode, String(zoneId)],
);
await query(
`DELETE FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
[zoneId, companyCode],
);
}
/**
* Zone에 ( + zone_id )
*/
async addLayerToZone(
screenId: number,
companyCode: string,
zoneId: number,
conditionValue: string,
layerName?: string,
): Promise<{ layerId: number }> {
// 다음 layer_id 계산
const maxResult = await queryOne<{ max_id: number }>(
`SELECT COALESCE(MAX(layer_id), 1) as max_id FROM screen_layouts_v2
WHERE screen_id = $1 AND company_code = $2`,
[screenId, companyCode],
);
const newLayerId = (maxResult?.max_id || 1) + 1;
// Zone 정보로 캔버스 크기 결정 (company_code 필터링 필수)
const zone = await queryOne<any>(
`SELECT * FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
[zoneId, companyCode],
);
const layoutData = {
version: "2.1",
components: [],
screenResolution: zone
? { width: zone.width, height: zone.height }
: { width: 800, height: 200 },
};
const conditionConfig = {
zone_id: zoneId,
condition_value: conditionValue,
};
await query(
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, layer_id, layer_name, condition_config)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE
SET layout_data = EXCLUDED.layout_data,
layer_name = EXCLUDED.layer_name,
condition_config = EXCLUDED.condition_config,
updated_at = NOW()`,
[screenId, companyCode, JSON.stringify(layoutData), newLayerId, layerName || `레이어 ${newLayerId}`, JSON.stringify(conditionConfig)],
);
return { layerId: newLayerId };
}
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// v2.0: 4모드 레이아웃 지원 (태블릿 가로/세로, 모바일 가로/세로)

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

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

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

@ -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];
// 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외)
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;
}
});
// UPDATE 모드 - PUT (전체 업데이트)
// originalData 비교 없이 formData 전체를 보냄
const recordId = formData.id;
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]);
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;
}
});
// UPDATE 액션 실행
const response = await dynamicFormApi.updateFormDataPartial(
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,59 +284,38 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
});
}, [finalFormData, layers, allComponents, handleLayerAction]);
// 🆕 모든 조건부 레이어의 displayRegion 정보 (활성/비활성 모두)
const conditionalRegionInfos = useMemo(() => {
return layers
.filter((layer) => layer.type === "conditional" && layer.displayRegion)
.map((layer) => ({
layerId: layer.id,
region: layer.displayRegion!,
isActive: activeLayerIds.includes(layer.id),
}))
.sort((a, b) => a.region.y - b.region.y); // Y 좌표 기준 정렬
}, [layers, activeLayerIds]);
// 🆕 접힌 조건부 영역 (비활성 상태인 것만)
const collapsedRegions = useMemo(() => {
return conditionalRegionInfos
.filter((info) => !info.isActive)
.map((info) => info.region);
}, [conditionalRegionInfos]);
// 🆕 Y 오프셋 계산 함수 (다중 조건부 영역 지원)
// 컴포넌트의 원래 Y 좌표보다 위에 있는 접힌 영역들의 높이를 누적하여 빼줌
// 겹치는 영역은 중복 계산하지 않도록 병합(merge) 처리
// 🆕 Zone 기반 Y 오프셋 계산 (단순화)
// Zone 단위로 활성 여부만 판단 → merge 로직 불필요
const calculateYOffset = useCallback((componentY: number): number => {
if (collapsedRegions.length === 0) return 0;
// 컴포넌트보다 위에 있는 접힌 영역만 필터링
const relevantRegions = collapsedRegions.filter(
(region) => region.y + region.height <= componentY
);
if (relevantRegions.length === 0) return 0;
// 겹치는 영역 병합 (다중 조건부 영역이 겹치는 경우 중복 높이 제거)
const mergedRegions: { y: number; bottom: number }[] = [];
for (const region of relevantRegions) {
const bottom = region.y + region.height;
if (mergedRegions.length === 0) {
mergedRegions.push({ y: region.y, bottom });
} else {
const last = mergedRegions[mergedRegions.length - 1];
if (region.y <= last.bottom) {
// 겹치는 영역 - 병합 (더 큰 하단으로 확장)
last.bottom = Math.max(last.bottom, bottom);
} else {
// 겹치지 않는 영역 - 새로 추가
mergedRegions.push({ y: region.y, bottom });
}
// 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;
}
}
// 병합된 영역들의 높이 합산
return mergedRegions.reduce((offset, merged) => offset + (merged.bottom - merged.y), 0);
}, [collapsedRegions]);
let totalOffset = 0;
for (const [, zone] of zoneMap) {
const zoneBottom = zone.y + zone.height;
// 컴포넌트가 Zone 하단보다 아래에 있고, Zone에 활성 레이어가 없으면 접힘
if (componentY >= zoneBottom && !zone.hasActive) {
totalOffset += zone.height;
}
}
return totalOffset;
}, [layers, activeLayerIds]);
// 개선된 검증 시스템 (선택적 활성화)
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
@ -2378,7 +2357,48 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
);
}
// 일반/조건부 레이어 (base, conditional)
// 조건부 레이어: Zone 기반 영역 내에 컴포넌트 렌더링
if (layer.type === "conditional" && layer.displayRegion) {
const region = layer.displayRegion;
return (
<div
key={layer.id}
className="pointer-events-none absolute"
style={{
left: `${region.x}px`,
top: `${region.y}px`,
width: `${region.width}px`,
height: `${region.height}px`,
zIndex: layer.zIndex,
overflow: "hidden",
}}
>
{layer.components.map((comp) => (
<div
key={comp.id}
className="pointer-events-auto absolute"
style={{
left: `${comp.position.x}px`,
top: `${comp.position.y}px`,
width: comp.style?.width || `${comp.size.width}px`,
height: comp.style?.height || `${comp.size.height}px`,
zIndex: comp.position.z || 1,
}}
>
<InteractiveScreenViewer
component={comp}
allComponents={allLayerComponents}
formData={externalFormData}
onFormDataChange={onFormDataChange}
screenInfo={screenInfo}
/>
</div>
))}
</div>
);
}
// 기본/기타 레이어 (base)
return (
<div
key={layer.id}
@ -2386,7 +2406,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
style={{ zIndex: layer.zIndex }}
>
{layer.components.map((comp) => {
// 기본 레이어 컴포넌트만 Y 오프셋 적용 (조건부 레이어 컴포넌트는 자체 영역 내 표시)
const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0;
const adjustedY = comp.position.y - yOffset;
@ -2414,7 +2433,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
})}
</div>
);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents]);
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, allLayerComponents, layers]);
return (
<SplitPanelProvider>

View File

@ -81,16 +81,18 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
const isTriggerComponent = (comp: ComponentData): boolean => {
const componentType = (comp.componentType || "").toLowerCase();
const widgetType = ((comp as any).widgetType || "").toLowerCase();
const webType = ((comp as any).webType || "").toLowerCase();
const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase();
const webType = ((comp as any).webType || comp.componentConfig?.webType || "").toLowerCase();
const inputType = ((comp as any).inputType || comp.componentConfig?.inputType || "").toLowerCase();
const source = ((comp as any).source || comp.componentConfig?.source || "").toLowerCase();
// 셀렉트, 라디오, 코드 타입 컴포넌트 허용
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity"];
// 셀렉트, 라디오, 코드, 카테고리, 엔티티 타입 컴포넌트 허용
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity", "category"];
return triggerTypes.some((type) =>
componentType.includes(type) ||
widgetType.includes(type) ||
webType.includes(type) ||
inputType.includes(type)
inputType.includes(type) ||
source.includes(type)
);
};
@ -112,9 +114,21 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
}, [components, baseLayerComponents]);
// 선택된 컴포넌트 정보
// 기본 레이어 + 현재 레이어 통합 컴포넌트 목록 (트리거 컴포넌트 검색용)
const allAvailableComponents = useMemo(() => {
const merged = [...(baseLayerComponents || []), ...components];
// 중복 제거 (id 기준)
const seen = new Set<string>();
return merged.filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
});
}, [components, baseLayerComponents]);
const selectedComponent = useMemo(() => {
return components.find((c) => c.id === targetComponentId);
}, [components, targetComponentId]);
return allAvailableComponents.find((c) => c.id === targetComponentId);
}, [allAvailableComponents, targetComponentId]);
// 선택된 컴포넌트의 데이터 소스 정보 추출
const dataSourceInfo = useMemo<{
@ -136,8 +150,17 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
const config = comp.componentConfig || comp.webTypeConfig || {};
const detailSettings = comp.detailSettings || {};
// V2 컴포넌트: config.source 확인
const source = config.source;
// V2 컴포넌트: source 확인 (componentConfig, 상위 레벨, inputType 모두 체크)
const source = config.source || comp.source;
const inputType = config.inputType || comp.inputType;
const webType = config.webType || comp.webType;
// inputType/webType이 category면 카테고리로 판단
if (inputType === "category" || webType === "category") {
const categoryTable = config.categoryTable || comp.tableName || config.tableName;
const categoryColumn = config.categoryColumn || comp.columnName || config.columnName;
return { type: "category", categoryTable, categoryColumn };
}
// 1. 카테고리 소스 (V2: source === "category", category_values 테이블)
if (source === "category") {
@ -188,8 +211,17 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
return { type: "none" };
}, [selectedComponent]);
// 의존성 안정화를 위한 직렬화 키
const dataSourceKey = useMemo(() => {
const { type, categoryTable, categoryColumn, codeCategory, originTable, originColumn, referenceTable, referenceColumn } = dataSourceInfo;
return `${type}|${categoryTable || ""}|${categoryColumn || ""}|${codeCategory || ""}|${originTable || ""}|${originColumn || ""}|${referenceTable || ""}|${referenceColumn || ""}`;
}, [dataSourceInfo]);
// 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적)
useEffect(() => {
// race condition 방지
let cancelled = false;
if (dataSourceInfo.type === "none") {
setOptions([]);
return;
@ -212,10 +244,13 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
try {
if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) {
// 카테고리 값에서 옵션 로드 (category_values 테이블)
console.log("[LayerCondition] 카테고리 옵션 로드:", dataSourceInfo.categoryTable, dataSourceInfo.categoryColumn);
const response = await apiClient.get(
`/table-categories/${dataSourceInfo.categoryTable}/${dataSourceInfo.categoryColumn}/values`
);
if (cancelled) return;
const data = response.data;
console.log("[LayerCondition] 카테고리 API 응답:", data?.success, "항목수:", Array.isArray(data?.data) ? data.data.length : 0);
if (data.success && data.data) {
// 트리 구조를 평탄화
const flattenTree = (items: any[], depth = 0): ConditionOption[] => {
@ -232,22 +267,22 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
}
return result;
};
setOptions(flattenTree(Array.isArray(data.data) ? data.data : []));
const loadedOptions = flattenTree(Array.isArray(data.data) ? data.data : []);
console.log("[LayerCondition] 카테고리 옵션 설정:", loadedOptions.length, "개");
setOptions(loadedOptions);
} else {
setOptions([]);
}
} else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) {
// 코드 카테고리에서 옵션 로드
const codes = await getCodesByCategory(dataSourceInfo.codeCategory);
if (cancelled) return;
setOptions(codes.map((code) => ({
value: code.code,
label: code.name,
})));
} else if (dataSourceInfo.type === "entity") {
// 엔티티 참조에서 옵션 로드
// 방법 1: 원본 테이블.컬럼으로 entity-reference API 호출
// (백엔드에서 table_type_columns를 통해 참조 테이블/컬럼을 자동 매핑)
// 방법 2: 직접 참조 테이블로 폴백
let entityLoaded = false;
if (dataSourceInfo.originTable && dataSourceInfo.originColumn) {
@ -257,13 +292,13 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
dataSourceInfo.originColumn,
{ limit: 100 }
);
if (cancelled) return;
setOptions(entityData.options.map((opt) => ({
value: opt.value,
label: opt.label,
})));
entityLoaded = true;
} catch {
// 원본 테이블.컬럼으로 실패 시 폴백
console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백");
}
}
@ -277,6 +312,7 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
refColumn,
{ limit: 100 }
);
if (cancelled) return;
setOptions(entityData.options.map((opt) => ({
value: opt.value,
label: opt.label,
@ -287,25 +323,32 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
}
}
// 모든 방법 실패 시 빈 옵션으로 설정하고 에러 표시하지 않음
if (!entityLoaded) {
// 엔티티 소스이지만 테이블 조회 불가 시, 직접 입력 모드로 전환
if (!entityLoaded && !cancelled) {
setOptions([]);
}
} else {
setOptions([]);
if (!cancelled) setOptions([]);
}
} catch (error: any) {
console.error("옵션 목록 로드 실패:", error);
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
setOptions([]);
if (!cancelled) {
console.error("옵션 목록 로드 실패:", error);
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
setOptions([]);
}
} finally {
setIsLoadingOptions(false);
if (!cancelled) {
setIsLoadingOptions(false);
}
}
};
loadOptions();
}, [dataSourceInfo]);
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSourceKey]);
// 조건 저장
const handleSave = useCallback(() => {
@ -574,11 +617,11 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
)}
{/* 현재 조건 요약 */}
{targetComponentId && (value || multiValues.length > 0) && (
{targetComponentId && selectedComponent && (value || multiValues.length > 0) && (
<div className="p-2 bg-muted rounded-md text-xs">
<span className="font-medium">: </span>
<span className="text-muted-foreground">
"{getComponentLabel(selectedComponent!)}" {" "}
"{getComponentLabel(selectedComponent)}" {" "}
{operator === "eq" && `"${options.find(o => o.value === value)?.label || value}"와 같으면`}
{operator === "neq" && `"${options.find(o => o.value === value)?.label || value}"와 다르면`}
{operator === "in" && `[${multiValues.map(v => options.find(o => o.value === v)?.label || v).join(", ")}] 중 하나이면`}

View File

@ -1,5 +1,7 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import {
@ -12,13 +14,14 @@ import {
ChevronRight,
Zap,
Loader2,
Box,
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { screenApi } from "@/lib/api/screen";
import { convertV2ToLegacy } from "@/lib/utils/layoutV2Converter";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
import { LayerConditionPanel } from "./LayerConditionPanel";
import { ComponentData, LayerCondition, DisplayRegion } from "@/types/screen-management";
import { ComponentData, ConditionalZone } from "@/types/screen-management";
// DB 레이어 타입
interface DBLayer {
@ -34,6 +37,8 @@ interface LayerManagerPanelProps {
activeLayerId: number; // 현재 활성 레이어 ID (DB layer_id)
onLayerChange: (layerId: number) => void; // 레이어 전환
components?: ComponentData[]; // 현재 활성 레이어의 컴포넌트 (폴백용)
zones?: ConditionalZone[]; // Zone 목록 (ScreenDesigner에서 전달)
onZonesChange?: (zones: ConditionalZone[]) => void; // Zone 목록 변경 콜백
}
export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
@ -41,13 +46,23 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
activeLayerId,
onLayerChange,
components = [],
zones: externalZones,
onZonesChange,
}) => {
const [layers, setLayers] = useState<DBLayer[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [conditionOpenLayerId, setConditionOpenLayerId] = useState<number | null>(null);
// 기본 레이어(layer_id=1)의 컴포넌트 (조건 설정 시 트리거 대상)
// 펼침/접힘 상태: zone_id별
const [expandedZones, setExpandedZones] = useState<Set<number>>(new Set());
// Zone에 레이어 추가 시 조건값 입력 상태
const [addingToZoneId, setAddingToZoneId] = useState<number | null>(null);
const [newConditionValue, setNewConditionValue] = useState("");
// Zone 트리거 설정 열기 상태
const [triggerEditZoneId, setTriggerEditZoneId] = useState<number | null>(null);
// 기본 레이어 컴포넌트 (트리거 선택용)
const [baseLayerComponents, setBaseLayerComponents] = useState<ComponentData[]>([]);
const zones = externalZones || [];
// 레이어 목록 로드
const loadLayers = useCallback(async () => {
if (!screenId) return;
@ -62,60 +77,65 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
}
}, [screenId]);
// 기본 레이어 컴포넌트 로드 (조건 설정 패널에서 트리거 컴포넌트 선택용)
// 기본 레이어 컴포넌트 로드
const loadBaseLayerComponents = useCallback(async () => {
if (!screenId) return;
// 현재 활성 레이어가 기본 레이어(1)이면 props의 실시간 컴포넌트 사용
if (activeLayerId === 1 && components.length > 0) {
setBaseLayerComponents(components);
return;
}
try {
const data = await screenApi.getLayerLayout(screenId, 1);
if (data && data.components) {
const legacy = convertV2ToLegacy(data);
if (legacy) {
setBaseLayerComponents(legacy.components as ComponentData[]);
return;
}
if (data?.components) {
setBaseLayerComponents(data.components as ComponentData[]);
}
setBaseLayerComponents([]);
} catch {
// 기본 레이어가 없거나 로드 실패 시 현재 컴포넌트 사용
setBaseLayerComponents(components);
}
}, [screenId, components]);
}, [screenId, components, activeLayerId]);
useEffect(() => {
loadLayers();
}, [loadLayers]);
loadBaseLayerComponents();
}, [loadLayers, loadBaseLayerComponents]);
// 조건 설정 패널이 열릴 때 기본 레이어 컴포넌트 로드
useEffect(() => {
if (conditionOpenLayerId !== null) {
loadBaseLayerComponents();
}
}, [conditionOpenLayerId, loadBaseLayerComponents]);
// Zone별 레이어 그룹핑
const getLayersForZone = useCallback((zoneId: number): DBLayer[] => {
return layers.filter(l => {
const cc = l.condition_config;
return cc && cc.zone_id === zoneId;
});
}, [layers]);
// 새 레이어 추가
const handleAddLayer = useCallback(async () => {
if (!screenId) return;
// 다음 layer_id 계산
const maxLayerId = layers.length > 0 ? Math.max(...layers.map((l) => l.layer_id)) : 0;
const newLayerId = maxLayerId + 1;
// Zone에 속하지 않는 조건부 레이어 (레거시)
const orphanLayers = layers.filter(l => {
if (l.layer_id === 1) return false;
const cc = l.condition_config;
return !cc || !cc.zone_id;
});
// 기본 레이어
const baseLayer = layers.find(l => l.layer_id === 1);
// Zone에 레이어 추가
const handleAddLayerToZone = useCallback(async (zoneId: number) => {
if (!screenId || !newConditionValue.trim()) return;
try {
// 빈 레이아웃으로 새 레이어 저장
await screenApi.saveLayoutV2(screenId, {
version: "2.0",
components: [],
layerId: newLayerId,
layerName: `조건부 레이어 ${newLayerId}`,
});
toast.success(`조건부 레이어 ${newLayerId}가 생성되었습니다.`);
const result = await screenApi.addLayerToZone(
screenId, zoneId, newConditionValue.trim(),
`레이어 (${newConditionValue.trim()})`,
);
toast.success(`레이어가 Zone에 추가되었습니다. (ID: ${result.layerId})`);
setAddingToZoneId(null);
setNewConditionValue("");
await loadLayers();
// 새 레이어로 전환
onLayerChange(newLayerId);
onLayerChange(result.layerId);
} catch (error) {
console.error("레이어 추가 실패:", error);
console.error("Zone 레이어 추가 실패:", error);
toast.error("레이어 추가에 실패했습니다.");
}
}, [screenId, layers, loadLayers, onLayerChange]);
}, [screenId, newConditionValue, loadLayers, onLayerChange]);
// 레이어 삭제
const handleDeleteLayer = useCallback(async (layerId: number) => {
@ -124,28 +144,203 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
await screenApi.deleteLayer(screenId, layerId);
toast.success("레이어가 삭제되었습니다.");
await loadLayers();
// 기본 레이어로 전환
if (activeLayerId === layerId) {
onLayerChange(1);
}
if (activeLayerId === layerId) onLayerChange(1);
} catch (error) {
console.error("레이어 삭제 실패:", error);
toast.error("레이어 삭제에 실패했습니다.");
}
}, [screenId, activeLayerId, loadLayers, onLayerChange]);
// 조건 업데이트
const handleUpdateCondition = useCallback(async (layerId: number, condition: LayerCondition | undefined) => {
// Zone 삭제
const handleDeleteZone = useCallback(async (zoneId: number) => {
if (!screenId) return;
try {
await screenApi.updateLayerCondition(screenId, layerId, condition || null);
toast.success("조건이 저장되었습니다.");
await screenApi.deleteZone(zoneId);
toast.success("조건부 영역이 삭제되었습니다.");
// Zone 목록 새로고침
const loadedZones = await screenApi.getScreenZones(screenId);
onZonesChange?.(loadedZones);
await loadLayers();
onLayerChange(1);
} catch (error) {
console.error("조건 업데이트 실패:", error);
toast.error("조건 저장에 실패했습니다.");
console.error("Zone 삭제 실패:", error);
toast.error("Zone 삭제에 실패했습니다.");
}
}, [screenId, loadLayers]);
}, [screenId, loadLayers, onLayerChange, onZonesChange]);
// 동적 소스 옵션 캐시 (trigger_component_id → 옵션 배열)
const [dynamicOptionsCache, setDynamicOptionsCache] = useState<Record<string, { value: string; label: string }[]>>({});
const [loadingDynamicOptions, setLoadingDynamicOptions] = useState<Set<string>>(new Set());
// 이미 로드 시도한 키를 추적 (중복 요청 방지)
const loadedKeysRef = useRef<Set<string>>(new Set());
// 동적 소스 옵션 로드 함수
const loadDynamicOptions = useCallback(async (triggerCompId: string, comp: ComponentData) => {
const cacheKey = triggerCompId;
// 이미 로드 완료 또는 로드 중이면 스킵
if (loadedKeysRef.current.has(cacheKey)) return;
loadedKeysRef.current.add(cacheKey);
setLoadingDynamicOptions(prev => new Set(prev).add(cacheKey));
try {
const config = comp.componentConfig || {};
const isCategory = (comp as any).inputType === "category" || (comp as any).webType === "category";
const source = isCategory ? "category" : config.source;
const compTableName = (comp as any).tableName || config.tableName;
const compColumnName = (comp as any).columnName || config.columnName;
let fetchedOptions: { value: string; label: string }[] = [];
if (source === "category" || isCategory) {
// 카테고리 소스: /table-categories/:tableName/:columnName/values
const catTable = config.categoryTable || compTableName;
const catColumn = config.categoryColumn || compColumnName;
if (catTable && catColumn) {
const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`);
const data = response.data;
if (data.success && data.data) {
// 트리 구조를 평탄화 (valueCode/valueLabel 사용)
const flattenTree = (items: any[]): { value: string; label: string }[] => {
const result: { value: string; label: string }[] = [];
for (const item of items) {
result.push({ value: item.valueCode, label: item.valueLabel });
if (item.children && item.children.length > 0) {
result.push(...flattenTree(item.children));
}
}
return result;
};
fetchedOptions = flattenTree(data.data);
}
}
} else if (source === "code" && config.codeGroup) {
// 공통코드 소스
const response = await apiClient.get(`/common-codes/categories/${config.codeGroup}/options`);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
value: item.value,
label: item.label,
}));
}
} else if (source === "entity" && config.entityTable) {
// 엔티티 소스
const valueCol = config.entityValueColumn || "id";
const labelCol = config.entityLabelColumn || "name";
const response = await apiClient.get(`/entity/${config.entityTable}/options`, {
params: { value: valueCol, label: labelCol },
});
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data;
}
} else if ((source === "distinct" || source === "select") && compTableName && compColumnName) {
// DISTINCT 소스
const isValidCol = compColumnName && !compColumnName.startsWith("comp_");
if (isValidCol) {
const response = await apiClient.get(`/entity/${compTableName}/distinct/${compColumnName}`);
const data = response.data;
if (data.success && data.data) {
fetchedOptions = data.data.map((item: { value: string; label: string }) => ({
value: String(item.value),
label: String(item.label),
}));
}
}
}
setDynamicOptionsCache(prev => ({ ...prev, [cacheKey]: fetchedOptions }));
} catch (error) {
console.error("트리거 옵션 동적 로드 실패:", error);
setDynamicOptionsCache(prev => ({ ...prev, [cacheKey]: [] }));
} finally {
setLoadingDynamicOptions(prev => {
const next = new Set(prev);
next.delete(cacheKey);
return next;
});
}
}, []);
// Zone 트리거 컴포넌트 업데이트
const handleUpdateZoneTrigger = useCallback(async (zoneId: number, triggerComponentId: string, operator: string = "eq") => {
try {
await screenApi.updateZone(zoneId, {
trigger_component_id: triggerComponentId,
trigger_operator: operator,
});
const loadedZones = await screenApi.getScreenZones(screenId!);
onZonesChange?.(loadedZones);
// 트리거 변경 시 해당 컴포넌트의 동적 옵션 캐시 초기화 → 새로 로드
loadedKeysRef.current.delete(triggerComponentId);
const triggerComp = baseLayerComponents.find(c => c.id === triggerComponentId);
if (triggerComp) {
loadDynamicOptions(triggerComponentId, triggerComp);
}
toast.success("트리거가 설정되었습니다.");
} catch (error) {
console.error("Zone 트리거 업데이트 실패:", error);
toast.error("트리거 설정에 실패했습니다.");
}
}, [screenId, onZonesChange, baseLayerComponents, loadDynamicOptions]);
// Zone 접힘/펼침 토글
const toggleZone = (zoneId: number) => {
setExpandedZones(prev => {
const next = new Set(prev);
next.has(zoneId) ? next.delete(zoneId) : next.add(zoneId);
return next;
});
};
// 트리거로 사용 가능한 컴포넌트 (select, combobox 등)
const triggerableComponents = baseLayerComponents.filter(c =>
["select", "combobox", "radio-group"].some(t => c.componentType?.includes(t))
);
// Zone 트리거가 변경되면 동적 옵션 로드
useEffect(() => {
for (const zone of zones) {
if (!zone.trigger_component_id) continue;
const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id);
if (!triggerComp) continue;
const config = triggerComp.componentConfig || {};
const source = config.source;
const isCategory = (triggerComp as any).inputType === "category" || (triggerComp as any).webType === "category";
// 정적 옵션이 아닌 경우에만 동적 로드
const hasStaticOptions = config.options && Array.isArray(config.options) && config.options.length > 0;
if (!hasStaticOptions && (source === "category" || source === "code" || source === "entity" || source === "distinct" || source === "select" || isCategory)) {
loadDynamicOptions(zone.trigger_component_id, triggerComp);
}
}
}, [zones, baseLayerComponents, loadDynamicOptions]);
// Zone의 트리거 컴포넌트에서 옵션 목록 가져오기 (정적 + 동적 지원)
const getTriggerOptions = useCallback((zone: ConditionalZone): { value: string; label: string }[] => {
if (!zone.trigger_component_id) return [];
const triggerComp = baseLayerComponents.find(c => c.id === zone.trigger_component_id);
if (!triggerComp) return [];
const config = triggerComp.componentConfig || {};
// 1. 정적 옵션 우선 확인
if (config.options && Array.isArray(config.options) && config.options.length > 0) {
return config.options
.filter((opt: any) => opt.value)
.map((opt: any) => ({ value: opt.value, label: opt.label || opt.value }));
}
// 2. 동적 소스 옵션 (캐시에서 가져오기)
const cached = dynamicOptionsCache[zone.trigger_component_id];
if (cached && cached.length > 0) {
return cached;
}
return [];
}, [baseLayerComponents, dynamicOptionsCache]);
return (
<div className="flex h-full flex-col bg-background">
@ -158,19 +353,9 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
{layers.length}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 px-2"
onClick={handleAddLayer}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{/* 레이어 목록 */}
{/* 레이어 + Zone 목록 */}
<ScrollArea className="flex-1">
<div className="space-y-1 p-2">
{isLoading ? (
@ -178,146 +363,343 @@ export const LayerManagerPanel: React.FC<LayerManagerPanelProps> = ({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<span className="text-sm"> ...</span>
</div>
) : layers.length === 0 ? (
<div className="space-y-2 py-4 text-center">
<p className="text-sm text-muted-foreground"> ...</p>
<p className="text-[10px] text-muted-foreground"> .</p>
</div>
) : (
layers
.slice()
.reverse()
.map((layer) => {
const isActive = activeLayerId === layer.layer_id;
const isBase = layer.layer_id === 1;
const hasCondition = !!layer.condition_config;
const isConditionOpen = conditionOpenLayerId === layer.layer_id;
<>
{/* 기본 레이어 */}
{baseLayer && (
<div
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
activeLayerId === 1
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-muted",
)}
onClick={() => onLayerChange(1)}
>
<span className="shrink-0 rounded bg-blue-100 p-1 text-blue-700 dark:bg-blue-900 dark:text-blue-300">
<Layers className="h-3 w-3" />
</span>
<div className="min-w-0 flex-1">
<span className="truncate font-medium">{baseLayer.layer_name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]"></Badge>
<span className="text-[10px] text-muted-foreground">{baseLayer.component_count} </span>
</div>
</div>
</div>
)}
{/* 조건부 영역(Zone) 목록 */}
{zones.map((zone) => {
const zoneLayers = getLayersForZone(zone.zone_id);
const isExpanded = expandedZones.has(zone.zone_id);
const isTriggerEdit = triggerEditZoneId === zone.zone_id;
return (
<div key={layer.layer_id} className="space-y-0">
<div key={`zone-${zone.zone_id}`} className="space-y-0">
{/* Zone 헤더 */}
<div
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-muted",
isConditionOpen && "rounded-b-none border-b-0",
"border-amber-200 bg-amber-50/50 hover:bg-amber-100/50 dark:border-amber-800 dark:bg-amber-950/20",
isExpanded && "rounded-b-none border-b-0",
)}
onClick={() => onLayerChange(layer.layer_id)}
// 조건부 레이어를 캔버스로 드래그 (영역 배치용)
draggable={!isBase}
onDragStart={(e) => {
if (isBase) return;
e.dataTransfer.setData("application/json", JSON.stringify({
type: "layer-region",
layerId: layer.layer_id,
layerName: layer.layer_name,
}));
e.dataTransfer.effectAllowed = "copy";
}}
onClick={() => toggleZone(zone.zone_id)}
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
{isExpanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-amber-600" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-amber-600" />
)}
<Box className="h-3.5 w-3.5 shrink-0 text-amber-600" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium text-amber-800 dark:text-amber-300">{zone.zone_name}</span>
<div className="flex items-center gap-2">
<span className={cn(
"shrink-0 rounded p-1",
isBase
? "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300"
: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
)}>
{isBase ? <Layers className="h-3 w-3" /> : <SplitSquareVertical className="h-3 w-3" />}
</span>
<span className="flex-1 truncate font-medium">{layer.layer_name}</span>
</div>
<div className="mt-0.5 flex items-center gap-2">
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]">
{isBase ? "기본" : "조건부"}
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px] border-amber-300 text-amber-700">
Zone
</Badge>
<span className="text-[10px] text-muted-foreground">
{layer.component_count}
{zoneLayers.length} | {zone.width}x{zone.height}
</span>
{hasCondition && (
{zone.trigger_component_id && (
<Badge variant="secondary" className="h-4 gap-0.5 px-1 py-0 text-[10px]">
<Zap className="h-2.5 w-2.5" />
<Zap className="h-2.5 w-2.5" />
</Badge>
)}
</div>
</div>
{/* 액션 버튼 */}
{/* Zone 액션 버튼 */}
<div className="flex shrink-0 items-center gap-0.5">
{!isBase && (
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", hasCondition && "text-amber-600")}
title="조건 설정"
onClick={(e) => {
e.stopPropagation();
setConditionOpenLayerId(isConditionOpen ? null : layer.layer_id);
}}
>
{isConditionOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)}
{!isBase && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-destructive"
title="레이어 삭제"
onClick={(e) => {
e.stopPropagation();
handleDeleteLayer(layer.layer_id);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost" size="icon"
className="h-6 w-6 text-amber-600 hover:text-amber-800"
title="트리거 설정"
onClick={(e) => { e.stopPropagation(); setTriggerEditZoneId(isTriggerEdit ? null : zone.zone_id); }}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost" size="icon"
className="h-6 w-6 hover:text-destructive"
title="Zone 삭제"
onClick={(e) => { e.stopPropagation(); handleDeleteZone(zone.zone_id); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 조건 설정 패널 */}
{!isBase && isConditionOpen && (
{/* 펼쳐진 Zone 내용 */}
{isExpanded && (
<div className={cn(
"rounded-b-md border border-t-0 bg-muted/30",
isActive ? "border-primary" : "border-border",
"rounded-b-md border border-t-0 border-amber-200 bg-amber-50/20 p-2 space-y-1",
"dark:border-amber-800 dark:bg-amber-950/10",
)}>
<LayerConditionPanel
layer={{
id: String(layer.layer_id),
name: layer.layer_name,
type: "conditional",
zIndex: layer.layer_id,
isVisible: true,
isLocked: false,
condition: layer.condition_config || undefined,
components: [],
}}
components={baseLayerComponents}
baseLayerComponents={baseLayerComponents}
onUpdateCondition={(condition) => handleUpdateCondition(layer.layer_id, condition)}
onUpdateDisplayRegion={() => {}}
onClose={() => setConditionOpenLayerId(null)}
/>
{/* 트리거 설정 패널 */}
{isTriggerEdit && (
<div className="mb-2 rounded border bg-background p-2 space-y-2">
<p className="text-[10px] font-medium text-muted-foreground"> </p>
{triggerableComponents.length === 0 ? (
<p className="text-[10px] text-muted-foreground"> Select/Combobox/Radio .</p>
) : (
<div className="space-y-1">
{triggerableComponents.map(c => (
<button
key={c.id}
className={cn(
"w-full text-left rounded px-2 py-1 text-[11px] transition-colors",
zone.trigger_component_id === c.id
? "bg-primary/10 text-primary font-medium"
: "hover:bg-muted",
)}
onClick={() => handleUpdateZoneTrigger(zone.zone_id, c.id!)}
>
{c.componentConfig?.label || c.id} ({c.componentType})
</button>
))}
</div>
)}
</div>
)}
{/* Zone 소속 레이어 목록 */}
{zoneLayers.map((layer) => {
const isActive = activeLayerId === layer.layer_id;
const triggerOpts = getTriggerOptions(zone);
const currentCondValue = layer.condition_config?.condition_value || "";
return (
<div
key={layer.layer_id}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-1.5 text-sm transition-all",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-background",
)}
onClick={() => onLayerChange(layer.layer_id)}
>
<SplitSquareVertical className="h-3 w-3 shrink-0 text-amber-600" />
<div className="min-w-0 flex-1">
<span className="text-xs font-medium truncate">{layer.layer_name}</span>
<div className="flex items-center gap-1 mt-0.5">
{triggerOpts.length > 0 ? (
<Select
value={currentCondValue || "_none_"}
onValueChange={async (val) => {
if (!screenId) return;
const newVal = val === "_none_" ? "" : val;
try {
await screenApi.updateLayerCondition(
screenId,
layer.layer_id,
{ ...layer.condition_config, condition_value: newVal },
layer.layer_name,
);
await loadLayers();
toast.success("조건값이 변경되었습니다.");
} catch {
toast.error("조건값 변경에 실패했습니다.");
}
}}
>
<SelectTrigger
className="h-5 text-[10px] w-auto min-w-[80px] max-w-[140px] px-1.5"
onClick={(e) => e.stopPropagation()}
>
<SelectValue placeholder="조건값 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_" className="text-xs text-muted-foreground"></SelectItem>
{triggerOpts.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span className="text-[10px] text-muted-foreground">
: {currentCondValue || "미설정"}
</span>
)}
<span className="text-[10px] text-muted-foreground">
| {layer.component_count}
</span>
</div>
</div>
<Button
variant="ghost" size="icon"
className="h-5 w-5 hover:text-destructive"
title="레이어 삭제"
onClick={(e) => { e.stopPropagation(); handleDeleteLayer(layer.layer_id); }}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
{/* 레이어 추가 */}
{addingToZoneId === zone.zone_id ? (
<div className="flex items-center gap-1 rounded border bg-background p-1.5">
{(() => {
// 동적 옵션 로딩 중 표시
const isLoadingOpts = zone.trigger_component_id ? loadingDynamicOptions.has(zone.trigger_component_id) : false;
if (isLoadingOpts) {
return (
<div className="flex items-center gap-1 flex-1 text-[11px] text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
);
}
const triggerOpts = getTriggerOptions(zone);
// 이미 사용된 조건값 제외
const usedValues = new Set(
zoneLayers.map(l => l.condition_config?.condition_value).filter(Boolean)
);
const availableOpts = triggerOpts.filter(o => !usedValues.has(o.value));
if (availableOpts.length > 0) {
return (
<Select value={newConditionValue} onValueChange={setNewConditionValue}>
<SelectTrigger className="h-6 text-[11px] flex-1">
<SelectValue placeholder="조건값 선택" />
</SelectTrigger>
<SelectContent>
{availableOpts.map(opt => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return (
<Input
value={newConditionValue}
onChange={(e) => setNewConditionValue(e.target.value)}
placeholder="조건값 입력"
className="h-6 text-[11px] flex-1"
autoFocus
onKeyDown={(e) => { if (e.key === "Enter") handleAddLayerToZone(zone.zone_id); }}
/>
);
})()}
<Button
variant="default" size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => handleAddLayerToZone(zone.zone_id)}
disabled={!newConditionValue.trim()}
>
</Button>
<Button
variant="ghost" size="sm"
className="h-6 px-1 text-[10px]"
onClick={() => { setAddingToZoneId(null); setNewConditionValue(""); }}
>
</Button>
</div>
) : (
<Button
variant="outline" size="sm"
className="h-6 w-full gap-1 text-[10px] border-dashed"
onClick={() => setAddingToZoneId(zone.zone_id)}
>
<Plus className="h-3 w-3" />
</Button>
)}
</div>
)}
</div>
);
})
})}
{/* 고아 레이어 (Zone에 소속되지 않은 조건부 레이어) */}
{orphanLayers.length > 0 && (
<div className="mt-2 space-y-1">
<p className="text-[10px] font-medium text-muted-foreground px-1">Zone </p>
{orphanLayers.map((layer) => {
const isActive = activeLayerId === layer.layer_id;
return (
<div
key={layer.layer_id}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-all",
isActive
? "border-primary bg-primary/5 shadow-sm"
: "border-transparent hover:bg-muted",
)}
onClick={() => onLayerChange(layer.layer_id)}
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
<div className="min-w-0 flex-1">
<span className="truncate font-medium">{layer.layer_name}</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px]"></Badge>
<span className="text-[10px] text-muted-foreground">{layer.component_count}</span>
</div>
</div>
<Button
variant="ghost" size="icon"
className="h-6 w-6 hover:text-destructive"
onClick={(e) => { e.stopPropagation(); handleDeleteLayer(layer.layer_id); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
);
})}
</div>
)}
</>
)}
</div>
</ScrollArea>
{/* 도움말 */}
<div className="border-t px-3 py-2 text-[10px] text-muted-foreground">
<p> | </p>
{/* Zone 생성 드래그 영역 */}
<div className="border-t px-3 py-2 space-y-1">
<div
className="flex cursor-grab items-center gap-2 rounded border border-dashed border-amber-300 bg-amber-50/50 px-2 py-1.5 text-xs text-amber-700 dark:border-amber-700 dark:bg-amber-950/20 dark:text-amber-400"
draggable
onDragStart={(e) => {
e.dataTransfer.setData("application/json", JSON.stringify({ type: "create-zone" }));
e.dataTransfer.effectAllowed = "copy";
}}
>
<GripVertical className="h-3.5 w-3.5" />
<Box className="h-3.5 w-3.5" />
<span> ( )</span>
</div>
<p className="text-[10px] text-muted-foreground">
Zone을 , Zone
</p>
</div>
</div>
);

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

@ -523,15 +523,15 @@ export default function ScreenDesigner({
// 🆕 좌측 패널 탭 상태 관리
const [leftPanelTab, setLeftPanelTab] = useState<string>("components");
// 🆕 레이어 영역 (기본 레이어에서 조건부 레이어들의 displayRegion 표시)
const [layerRegions, setLayerRegions] = useState<Record<number, { x: number; y: number; width: number; height: number; layerName: string }>>({});
// 🆕 조건부 영역(Zone) 목록 (DB screen_conditional_zones 기반)
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
// 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정)
const [regionDrag, setRegionDrag] = useState<{
isDrawing: boolean; // 새 영역 그리기 모드
isDragging: boolean; // 기존 영역 이동 모드
isResizing: boolean; // 기존 영역 리사이즈 모드
targetLayerId: string | null; // 대상 레이어 ID
targetLayerId: string | null; // 대상 Zone ID (문자열)
startX: number;
startY: number;
currentX: number;
@ -551,6 +551,33 @@ export default function ScreenDesigner({
originalRegion: null,
});
// 🆕 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
useEffect(() => {
if (activeLayerId <= 1 || !selectedScreen?.screenId) {
setActiveLayerZone(null);
return;
}
// 레이어의 condition_config에서 zone_id를 가져와서 zones에서 찾기
const findZone = async () => {
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, activeLayerId);
const zoneId = layerData?.conditionConfig?.zone_id;
if (zoneId) {
const zone = zones.find(z => z.zone_id === zoneId);
setActiveLayerZone(zone || null);
} else {
setActiveLayerZone(null);
}
} catch {
setActiveLayerZone(null);
}
};
findZone();
}, [activeLayerId, selectedScreen?.screenId, zones]);
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
const visibleComponents = useMemo(() => {
return layout.components;
@ -1575,20 +1602,11 @@ export default function ScreenDesigner({
// 파일 컴포넌트 데이터 복원 (비동기)
restoreFileComponentsData(layoutWithDefaultGrid.components);
// 🆕 레이어 영역 로드 (조건부 레이어의 displayRegion)
// 🆕 조건부 영역(Zone) 로드
try {
const layers = await screenApi.getScreenLayers(selectedScreen.screenId);
const regions: Record<number, any> = {};
for (const layer of layers) {
if (layer.layer_id > 1 && layer.condition_config?.displayRegion) {
regions[layer.layer_id] = {
...layer.condition_config.displayRegion,
layerName: layer.layer_name,
};
}
}
setLayerRegions(regions);
} catch { /* 레이어 로드 실패 무시 */ }
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
setZones(loadedZones);
} catch { /* Zone 로드 실패 무시 */ }
}
} catch (error) {
// console.error("레이아웃 로드 실패:", error);
@ -1928,17 +1946,33 @@ export default function ScreenDesigner({
[groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory]
);
// 라벨 일괄 토글
// 라벨 일괄 토글 (선택된 컴포넌트가 있으면 선택된 것만, 없으면 전체)
const handleToggleAllLabels = useCallback(() => {
saveToHistory(layout);
const newComponents = toggleAllLabels(layout.components);
const selectedIds = groupState.selectedComponents;
const isPartial = selectedIds.length > 0;
// 토글 대상 컴포넌트 필터
const targetComponents = layout.components.filter((c) => {
if (!c.label || ["group", "datatable"].includes(c.type)) return false;
if (isPartial) return selectedIds.includes(c.id);
return true;
});
const hadHidden = targetComponents.some(
(c) => (c.style as any)?.labelDisplay === false
);
const newComponents = toggleAllLabels(layout.components, selectedIds);
setLayout((prev) => ({ ...prev, components: newComponents }));
const hasHidden = layout.components.some(
(c) => c.type === "widget" && (c.style as any)?.labelDisplay === false
);
toast.success(hasHidden ? "모든 라벨 표시" : "모든 라벨 숨기기");
}, [layout, saveToHistory]);
// 강제 리렌더링 트리거
setForceRenderTrigger((prev) => prev + 1);
const scope = isPartial ? `선택된 ${targetComponents.length}` : "모든";
toast.success(hadHidden ? `${scope} 라벨 표시` : `${scope} 라벨 숨기기`);
}, [layout, saveToHistory, groupState.selectedComponents]);
// Nudge (화살표 키 이동)
const handleNudge = useCallback(
@ -2145,7 +2179,12 @@ export default function ScreenDesigner({
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
// 현재 활성 레이어 ID 포함 (레이어별 저장)
const currentLayerId = activeLayerIdRef.current || 1;
await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout,
layerId: currentLayerId,
});
} else {
await screenApi.saveLayout(selectedScreen.screenId, updatedLayout);
}
@ -3049,9 +3088,13 @@ export default function ScreenDesigner({
})
: null;
// 캔버스 경계 내로 위치 제한
const boundedX = Math.max(0, Math.min(dropX, screenResolution.width - componentWidth));
const boundedY = Math.max(0, Math.min(dropY, screenResolution.height - componentHeight));
// 캔버스 경계 내로 위치 제한 (조건부 레이어 편집 시 Zone 크기 기준)
const currentLayerId = activeLayerIdRef.current || 1;
const activeLayerRegion = currentLayerId > 1 ? activeLayerZone : null;
const canvasBoundW = activeLayerRegion ? activeLayerRegion.width : screenResolution.width;
const canvasBoundH = activeLayerRegion ? activeLayerRegion.height : screenResolution.height;
const boundedX = Math.max(0, Math.min(dropX, canvasBoundW - componentWidth));
const boundedY = Math.max(0, Math.min(dropY, canvasBoundH - componentHeight));
// 격자 스냅 적용
const snappedPosition =
@ -3323,37 +3366,27 @@ export default function ScreenDesigner({
return;
}
// 🆕 조건부 레이어 영역 드래그인 경우 → DB condition_config에 displayRegion 저장
if (parsedData.type === "layer-region" && parsedData.layerId && selectedScreen?.screenId) {
// 🆕 조건부 영역(Zone) 생성 드래그인 경우 → DB screen_conditional_zones에 저장
if (parsedData.type === "create-zone" && selectedScreen?.screenId) {
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
const dropX = Math.round((e.clientX - canvasRect.left) / zoomLevel);
const dropY = Math.round((e.clientY - canvasRect.top) / zoomLevel);
const newRegion = {
x: Math.max(0, dropX - 400),
y: Math.max(0, dropY),
width: Math.min(800, screenResolution.width),
height: 200,
};
// DB에 displayRegion 저장 (condition_config에 포함)
try {
// 기존 condition_config를 가져와서 displayRegion만 추가/업데이트
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, parsedData.layerId);
const existingCondition = layerData?.conditionConfig || {};
await screenApi.updateLayerCondition(
selectedScreen.screenId,
parsedData.layerId,
{ ...existingCondition, displayRegion: newRegion }
);
// 레이어 영역 state에 반영 (캔버스에 즉시 표시)
setLayerRegions((prev) => ({
...prev,
[parsedData.layerId]: { ...newRegion, layerName: parsedData.layerName },
}));
toast.success(`"${parsedData.layerName}" 영역이 배치되었습니다.`);
await screenApi.createZone(selectedScreen.screenId, {
zone_name: "조건부 영역",
x: Math.max(0, dropX - 400),
y: Math.max(0, dropY),
width: Math.min(800, screenResolution.width),
height: 200,
});
// Zone 목록 새로고침
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
setZones(loadedZones);
toast.success("조건부 영역이 생성되었습니다.");
} catch (error) {
console.error("레이어 영역 저장 실패:", error);
toast.error("레이어 영역 저장에 실패했습니다.");
console.error("Zone 생성 실패:", error);
toast.error("조건부 영역 생성에 실패했습니다.");
}
return;
}
@ -4265,9 +4298,15 @@ export default function ScreenDesigner({
const rawX = relativeMouseX - dragState.grabOffset.x;
const rawY = relativeMouseY - dragState.grabOffset.y;
// 조건부 레이어 편집 시 Zone 크기 기준 경계 제한
const dragLayerId = activeLayerIdRef.current || 1;
const dragLayerRegion = dragLayerId > 1 ? activeLayerZone : null;
const dragBoundW = dragLayerRegion ? dragLayerRegion.width : screenResolution.width;
const dragBoundH = dragLayerRegion ? dragLayerRegion.height : screenResolution.height;
const newPosition = {
x: Math.max(0, Math.min(rawX, screenResolution.width - componentWidth)),
y: Math.max(0, Math.min(rawY, screenResolution.height - componentHeight)),
x: Math.max(0, Math.min(rawX, dragBoundW - componentWidth)),
y: Math.max(0, Math.min(rawY, dragBoundH - componentHeight)),
z: (dragState.draggedComponent.position as Position).z || 1,
};
@ -5560,7 +5599,12 @@ export default function ScreenDesigner({
if (USE_POP_API) {
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
} else if (USE_V2_API) {
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
// 현재 활성 레이어 ID 포함 (레이어별 저장)
const currentLayerId = activeLayerIdRef.current || 1;
await screenApi.saveLayoutV2(selectedScreen.screenId, {
...v2Layout,
layerId: currentLayerId,
});
} else {
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
}
@ -5762,9 +5806,9 @@ export default function ScreenDesigner({
) => {
e.stopPropagation();
e.preventDefault();
const lid = Number(layerId);
const region = layerRegions[lid];
if (!region) return;
const zoneId = Number(layerId); // layerId는 실제로 zoneId
const zone = zones.find(z => z.zone_id === zoneId);
if (!zone) return;
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
@ -5776,15 +5820,15 @@ export default function ScreenDesigner({
isDrawing: false,
isDragging: mode === "move",
isResizing: mode === "resize",
targetLayerId: layerId,
targetLayerId: String(zoneId),
startX: x,
startY: y,
currentX: x,
currentY: y,
resizeHandle: handle || null,
originalRegion: { x: region.x, y: region.y, width: region.width, height: region.height },
originalRegion: { x: zone.x, y: zone.y, width: zone.width, height: zone.height },
});
}, [layerRegions, zoomLevel]);
}, [zones, zoomLevel]);
// 🆕 캔버스 마우스 이벤트 (영역 이동/리사이즈)
const handleRegionCanvasMouseMove = useCallback((e: React.MouseEvent) => {
@ -5806,11 +5850,8 @@ export default function ScreenDesigner({
width: regionDrag.originalRegion.width,
height: regionDrag.originalRegion.height,
};
const lid = Number(regionDrag.targetLayerId);
setLayerRegions((prev) => ({
...prev,
[lid]: { ...prev[lid], ...newRegion },
}));
const zoneId = Number(regionDrag.targetLayerId);
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
} else if (regionDrag.isResizing && regionDrag.originalRegion) {
const dx = x - regionDrag.startX;
const dy = y - regionDrag.startY;
@ -5829,29 +5870,23 @@ export default function ScreenDesigner({
newRegion.height = Math.max(30, Math.round(orig.height - dy));
}
const lid = Number(regionDrag.targetLayerId);
setLayerRegions((prev) => ({
...prev,
[lid]: { ...prev[lid], ...newRegion },
}));
const zoneId = Number(regionDrag.targetLayerId);
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
}
}, [regionDrag, zoomLevel]);
const handleRegionCanvasMouseUp = useCallback(async () => {
// 드래그 완료 시 DB에 영역 저장
if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId && selectedScreen?.screenId) {
const lid = Number(regionDrag.targetLayerId);
const region = layerRegions[lid];
if (region) {
// 드래그 완료 시 DB에 Zone 저장
if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId) {
const zoneId = Number(regionDrag.targetLayerId);
const zone = zones.find(z => z.zone_id === zoneId);
if (zone) {
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, lid);
const existingCondition = layerData?.conditionConfig || {};
await screenApi.updateLayerCondition(
selectedScreen.screenId, lid,
{ ...existingCondition, displayRegion: { x: region.x, y: region.y, width: region.width, height: region.height } }
);
await screenApi.updateZone(zoneId, {
x: zone.x, y: zone.y, width: zone.width, height: zone.height,
});
} catch {
console.error("영역 저장 실패");
console.error("Zone 저장 실패");
}
}
}
@ -5865,34 +5900,15 @@ export default function ScreenDesigner({
resizeHandle: null,
originalRegion: null,
});
}, [regionDrag, layerRegions, selectedScreen]);
}, [regionDrag, zones]);
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
// 주의: layout.layers에 직접 설정된 displayRegion 등 메타데이터를 보존
// Zone 기반이므로 displayRegion 보존 불필요
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
setLayout((prevLayout) => {
// 기존 layout.layers의 메타데이터(displayRegion 등)를 보존하며 병합
const mergedLayers = newLayers.map((newLayer) => {
const existingLayer = prevLayout.layers?.find((l) => l.id === newLayer.id);
if (!existingLayer) return newLayer;
// LayerContext에서 온 데이터(condition 등)를 우선하되,
// layout.layers에만 있는 데이터(캔버스에서 직접 수정한 displayRegion)도 보존
return {
...existingLayer, // 기존 메타데이터 보존 (displayRegion 등)
...newLayer, // LayerContext 데이터 우선 (condition, name, isVisible 등)
// displayRegion: 양쪽 모두 있을 수 있으므로 최신 값 우선
displayRegion: newLayer.displayRegion !== undefined
? newLayer.displayRegion
: existingLayer.displayRegion,
};
});
return {
...prevLayout,
layers: mergedLayers,
};
});
setLayout((prevLayout) => ({
...prevLayout,
layers: newLayers,
}));
}, []);
// 🆕 활성 레이어 변경 핸들러
@ -6046,6 +6062,8 @@ export default function ScreenDesigner({
}
}}
components={layout.components}
zones={zones}
onZonesChange={setZones}
/>
</TabsContent>
@ -6623,28 +6641,50 @@ export default function ScreenDesigner({
{activeLayerId > 1 && (
<div className="sticky top-0 z-30 flex items-center justify-center gap-2 border-b bg-amber-50 px-4 py-1.5 backdrop-blur-sm dark:bg-amber-950/30">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<span className="text-xs font-medium"> {activeLayerId} </span>
<span className="text-xs font-medium">
{activeLayerId}
{activeLayerZone && (
<span className="ml-2 text-amber-600">
(: {activeLayerZone.width} x {activeLayerZone.height}px - {activeLayerZone.zone_name})
</span>
)}
{!activeLayerZone && (
<span className="ml-2 text-red-500">
( - Zone을 )
</span>
)}
</span>
</div>
)}
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
{(() => {
// 🆕 조건부 레이어 편집 시 캔버스 크기를 Zone에 맞춤
const activeRegion = activeLayerId > 1 ? activeLayerZone : null;
const canvasW = activeRegion ? activeRegion.width : screenResolution.width;
const canvasH = activeRegion ? activeRegion.height : screenResolution.height;
return (
<div
className="flex justify-center"
style={{
width: "100%",
minHeight: screenResolution.height * zoomLevel,
minHeight: canvasH * zoomLevel,
contain: "layout style", // 레이아웃 재계산 범위 제한
}}
>
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
{/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */}
<div
className="bg-background border-border border shadow-lg"
className={cn(
"bg-background border shadow-lg",
activeRegion ? "border-amber-400 border-2" : "border-border"
)}
style={{
width: `${screenResolution.width}px`,
height: `${screenResolution.height}px`,
minWidth: `${screenResolution.width}px`,
maxWidth: `${screenResolution.width}px`,
minHeight: `${screenResolution.height}px`,
width: `${canvasW}px`,
height: `${canvasH}px`,
minWidth: `${canvasW}px`,
maxWidth: `${canvasW}px`,
minHeight: `${canvasH}px`,
flexShrink: 0,
transform: `scale3d(${zoomLevel}, ${zoomLevel}, 1)`,
transformOrigin: "top center", // 중앙 기준으로 스케일
@ -6751,9 +6791,11 @@ export default function ScreenDesigner({
return (
<>
{/* 조건부 레이어 영역 (기본 레이어에서만 표시, DB 기반) */}
{activeLayerId === 1 && Object.entries(layerRegions).map(([layerIdStr, region]) => {
const layerId = Number(layerIdStr);
{/* 조건부 영역(Zone) (기본 레이어에서만 표시, DB 기반) */}
{/* 내부는 pointerEvents: none으로 아래 컴포넌트 클릭/드래그 통과 */}
{activeLayerId === 1 && zones.map((zone) => {
const layerId = zone.zone_id; // 렌더링용 ID
const region = zone;
const resizeHandles = ["nw", "ne", "sw", "se", "n", "s", "e", "w"];
const handleCursors: Record<string, string> = {
nw: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", se: "nwse-resize",
@ -6767,6 +6809,8 @@ export default function ScreenDesigner({
e: { top: "50%", right: -4, transform: "translateY(-50%)" },
w: { top: "50%", left: -4, transform: "translateY(-50%)" },
};
// 테두리 두께 (이동 핸들 영역)
const borderWidth = 6;
return (
<div
key={`region-${layerId}`}
@ -6779,41 +6823,64 @@ export default function ScreenDesigner({
border: "2px dashed hsl(var(--primary))",
borderRadius: "4px",
backgroundColor: "hsl(var(--primary) / 0.05)",
zIndex: 9999,
cursor: "move",
pointerEvents: "auto",
zIndex: 50,
pointerEvents: "none", // 내부 클릭은 아래 컴포넌트로 통과
}}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
>
<span className="pointer-events-none absolute left-2 top-1 select-none text-[10px] font-medium text-primary">
{layerId} - {region.layerName}
{/* 테두리 이동 핸들: 상/하/좌/우 얇은 영역만 pointerEvents 활성 */}
{/* 상단 */}
<div
className="absolute left-0 right-0 top-0"
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
/>
{/* 하단 */}
<div
className="absolute bottom-0 left-0 right-0"
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
/>
{/* 좌측 */}
<div
className="absolute bottom-0 left-0 top-0"
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
/>
{/* 우측 */}
<div
className="absolute bottom-0 right-0 top-0"
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
/>
{/* 라벨 */}
<span
className="absolute left-2 top-1 select-none text-[10px] font-medium text-primary"
style={{ pointerEvents: "auto", cursor: "move" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
>
Zone {zone.zone_id} - {zone.zone_name}
</span>
{/* 리사이즈 핸들 */}
{resizeHandles.map((handle) => (
<div
key={handle}
className="absolute z-10 h-2 w-2 rounded-sm border border-primary bg-background"
style={{ ...handlePositions[handle], cursor: handleCursors[handle] }}
style={{ ...handlePositions[handle], cursor: handleCursors[handle], pointerEvents: "auto" }}
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "resize", handle)}
/>
))}
{/* 삭제 버튼 */}
<button
className="absolute -right-1 -top-3 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[8px] text-destructive-foreground hover:bg-destructive/80"
style={{ pointerEvents: "auto" }}
onClick={async (e) => {
e.stopPropagation();
if (!selectedScreen?.screenId) return;
try {
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerId);
const cond = layerData?.conditionConfig || {};
delete cond.displayRegion;
await screenApi.updateLayerCondition(selectedScreen.screenId, layerId, Object.keys(cond).length > 0 ? cond : null);
setLayerRegions((prev) => {
const next = { ...prev };
delete next[layerId];
return next;
});
} catch { toast.error("영역 삭제 실패"); }
await screenApi.deleteZone(zone.zone_id);
setZones((prev) => prev.filter(z => z.zone_id !== zone.zone_id));
toast.success("조건부 영역이 삭제되었습니다.");
} catch { toast.error("Zone 삭제 실패"); }
}}
title="영역 삭제"
>
@ -7363,8 +7430,9 @@ export default function ScreenDesigner({
)}
</div>
</div>
</div>{" "}
{/* 🔥 줌 래퍼 닫기 */}
</div>
); /* 🔥 줌 래퍼 닫기 */
})()}
</div>
</div>{" "}
{/* 메인 컨테이너 닫기 */}
@ -7448,4 +7516,4 @@ export default function ScreenDesigner({
</LayerProvider>
</ScreenPreviewProvider>
);
}
}

View File

@ -134,7 +134,6 @@ interface ScreenSettingModalProps {
fieldMappings?: FieldMappingInfo[];
componentCount?: number;
onSaveSuccess?: () => void;
isPop?: boolean; // POP 화면 여부
}
// 검색 가능한 Select 컴포넌트
@ -240,7 +239,6 @@ export function ScreenSettingModal({
fieldMappings = [],
componentCount = 0,
onSaveSuccess,
isPop = false,
}: ScreenSettingModalProps) {
const [activeTab, setActiveTab] = useState("overview");
const [loading, setLoading] = useState(false);
@ -521,7 +519,6 @@ export function ScreenSettingModal({
iframeKey={iframeKey}
canvasWidth={canvasSize.width}
canvasHeight={canvasSize.height}
isPop={isPop}
/>
</div>
</div>
@ -4634,10 +4631,9 @@ interface PreviewTabProps {
iframeKey?: number; // iframe 새로고침용 키
canvasWidth?: number; // 화면 캔버스 너비
canvasHeight?: number; // 화면 캔버스 높이
isPop?: boolean; // POP 화면 여부
}
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight, isPop = false }: PreviewTabProps) {
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight }: PreviewTabProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
@ -4691,18 +4687,12 @@ function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWi
if (companyCode) {
params.set("company_code", companyCode);
}
// POP 화면일 경우 디바이스 타입 추가
if (isPop) {
params.set("device", "tablet");
}
// POP 화면과 데스크톱 화면 경로 분기
const screenPath = isPop ? `/pop/screens/${screenId}` : `/screens/${screenId}`;
if (typeof window !== "undefined") {
const baseUrl = window.location.origin;
return `${baseUrl}${screenPath}?${params.toString()}`;
return `${baseUrl}/screens/${screenId}?${params.toString()}`;
}
return `${screenPath}?${params.toString()}`;
}, [screenId, companyCode, isPop]);
return `/screens/${screenId}?${params.toString()}`;
}, [screenId, companyCode]);
const handleIframeLoad = () => {
setLoading(false);

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

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

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

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

View File

@ -235,6 +235,57 @@ export const screenApi = {
await apiClient.put(`/screen-management/screens/${screenId}/layers/${layerId}/condition`, { conditionConfig, layerName });
},
// ========================================
// 조건부 영역(Zone) 관리
// ========================================
// Zone 목록 조회
getScreenZones: async (screenId: number): Promise<any[]> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/zones`);
return response.data.data || [];
},
// Zone 생성
createZone: async (screenId: number, zoneData: {
zone_name?: string;
x: number;
y: number;
width: number;
height: number;
trigger_component_id?: string;
trigger_operator?: string;
}): Promise<any> => {
const response = await apiClient.post(`/screen-management/screens/${screenId}/zones`, zoneData);
return response.data.data;
},
// Zone 업데이트 (위치/크기/트리거)
updateZone: async (zoneId: number, updates: {
zone_name?: string;
x?: number;
y?: number;
width?: number;
height?: number;
trigger_component_id?: string;
trigger_operator?: string;
}): Promise<void> => {
await apiClient.put(`/screen-management/zones/${zoneId}`, updates);
},
// Zone 삭제
deleteZone: async (zoneId: number): Promise<void> => {
await apiClient.delete(`/screen-management/zones/${zoneId}`);
},
// Zone에 레이어 추가
addLayerToZone: async (screenId: number, zoneId: number, conditionValue: string, layerName?: string): Promise<{ layerId: number }> => {
const response = await apiClient.post(`/screen-management/screens/${screenId}/zones/${zoneId}/layers`, {
conditionValue,
layerName,
});
return response.data.data;
},
// ========================================
// POP 레이아웃 관리 (모바일/태블릿)
// ========================================

View File

@ -105,7 +105,7 @@ export function ConditionalSectionViewer({
return (
<div
className={cn(
"relative w-full transition-all",
"relative w-full",
isDesignMode && showBorder && "border-muted-foreground/30 bg-muted/20 rounded-lg border-2 border-dashed",
!isDesignMode && !isActive && "hidden",
)}

View File

@ -275,6 +275,9 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
return ["", ""];
}, [webType, rawValue]);
// 입력 필드에 직접 적용할 폰트 크기
const inputFontSize = component.style?.fontSize;
// daterange 타입 전용 UI
if (webType === "daterange") {
return (
@ -312,6 +315,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
{/* 구분자 */}
@ -341,6 +345,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
</div>
</div>
@ -385,6 +390,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
</div>
);
@ -421,6 +427,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}

View File

@ -643,8 +643,8 @@ export function RepeaterTable({
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div ref={containerRef} className="border border-gray-200 bg-white">
<div className="max-h-[400px] overflow-x-auto overflow-y-auto">
<div ref={containerRef} className="flex h-full flex-col border border-gray-200 bg-white">
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto">
<table
className="border-collapse text-xs"
style={{

View File

@ -109,6 +109,9 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
return value.replace(/,/g, "");
};
// 입력 필드에 직접 적용할 폰트 크기
const inputFontSize = component.style?.fontSize;
// Currency 타입 전용 UI
if (webType === "currency") {
return (
@ -141,6 +144,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
}
}}
className={`h-full flex-1 rounded-md border px-3 py-2 text-right text-base font-semibold transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-green-600"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
</div>
</div>
@ -179,6 +183,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
}
}}
className={`h-full flex-1 rounded-md border px-3 py-2 text-right text-base font-semibold transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-blue-600"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
{/* 퍼센트 기호 */}
@ -218,6 +223,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
max={componentConfig.max}
step={step}
className={`box-border h-full w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} placeholder:text-gray-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}

View File

@ -54,6 +54,10 @@ export interface FieldGroup {
description?: string;
/** 그룹 표시 순서 */
order?: number;
/** 🆕 최대 항목 수 (1이면 1:1 관계 - 자동 생성, + 추가 버튼 숨김) */
maxEntries?: number;
/** 🆕 이 그룹의 소스 테이블 (카테고리 옵션 로드 시 사용) */
sourceTable?: string;
/** 🆕 이 그룹의 항목 표시 설정 (그룹별로 다른 표시 형식 가능) */
displayItems?: DisplayItem[];
}

View File

@ -192,6 +192,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
}
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
// 입력 필드에 직접 적용할 폰트 크기
const inputFontSize = component.style?.fontSize;
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
@ -412,6 +415,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
{/* @ 구분자 */}
@ -528,6 +532,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
<span className="text-muted-foreground text-base font-medium">-</span>
@ -558,6 +563,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
<span className="text-muted-foreground text-base font-medium">-</span>
@ -588,6 +594,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
</div>
</div>
@ -659,6 +666,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
</div>
</div>
@ -712,6 +720,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
</div>
);
@ -791,6 +800,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground",
"disabled:cursor-not-allowed",
)}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
onClick={(e) => {
handleClick(e);
}}

View File

@ -102,7 +102,7 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
border: "1px solid #d1d5db",
borderRadius: "8px",
padding: "8px 12px",
fontSize: "14px",
fontSize: component.style?.fontSize || "14px",
outline: "none",
resize: "none",
transition: "all 0.2s ease-in-out",

View File

@ -1283,13 +1283,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
width: buttonWidth,
height: buttonHeight,
minHeight: "32px", // 🔧 최소 높이를 32px로 줄임
border: "none",
borderRadius: "0.5rem",
// 🔧 커스텀 테두리 스타일 (StyleEditor에서 설정한 값 우선)
border: style?.border || (style?.borderWidth ? undefined : "none"),
borderWidth: style?.borderWidth || undefined,
borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || undefined,
borderColor: style?.borderColor || undefined,
borderRadius: style?.borderRadius || "0.5rem",
backgroundColor: finalDisabled ? "#e5e7eb" : buttonColor,
color: finalDisabled ? "#9ca3af" : buttonTextColor, // 🔧 webTypeConfig.textColor 지원
// 🔧 크기 설정 적용 (sm/md/lg)
fontSize: componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem",
fontWeight: "600",
color: finalDisabled ? "#9ca3af" : (style?.color || buttonTextColor), // 🔧 StyleEditor 텍스트 색상도 지원
// 🔧 크기 설정 적용 (sm/md/lg), StyleEditor fontSize 우선
fontSize: style?.fontSize || (componentConfig.size === "sm" ? "0.75rem" : componentConfig.size === "lg" ? "1rem" : "0.875rem"),
fontWeight: style?.fontWeight || "600",
cursor: finalDisabled ? "not-allowed" : "pointer",
outline: "none",
boxSizing: "border-box",

View File

@ -233,8 +233,8 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
return;
}
// tableName 확인 (props에서 전달받은 tableName 사용)
const tableNameToUse = tableName || component.componentConfig?.tableName || 'user_info'; // 기본 테이블명 설정
// tableName 확인 (props에서 전달받은 tableName 또는 componentConfig에서 추출)
const tableNameToUse = tableName || component.componentConfig?.tableName;
if (!tableNameToUse) {
setLoading(false);

View File

@ -5,7 +5,7 @@ import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { uploadFiles, downloadFile, deleteFile, getComponentFiles, getFileInfoByObjid, getFilePreviewUrl } from "@/lib/api/file";
import { GlobalFileManager } from "@/lib/api/globalFile";
import { formatFileSize } from "@/lib/utils";
import { formatFileSize, cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { FileViewerModal } from "./FileViewerModal";
import { FileManagerModal } from "./FileManagerModal";
@ -492,11 +492,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 파일 업로드 설정 - componentConfig가 undefined일 수 있으므로 안전하게 처리
const safeComponentConfig = componentConfig || {};
const fileConfig = {
...safeComponentConfig,
accept: safeComponentConfig.accept || "*/*",
multiple: safeComponentConfig.multiple || false,
maxSize: safeComponentConfig.maxSize || 10 * 1024 * 1024, // 10MB
maxFiles: safeComponentConfig.maxFiles || 5,
...safeComponentConfig,
} as FileUploadConfig;
// 파일 선택 핸들러
@ -513,7 +513,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
}, []);
// 파일 업로드 처리
// 백엔드 multer 제한에 맞춘 1회 요청당 최대 파일 수
const CHUNK_SIZE = 10;
// 파일 업로드 처리 (10개 초과 시 자동 분할 업로드)
const handleFileUpload = useCallback(
async (files: File[]) => {
if (!files.length) return;
@ -548,7 +551,17 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const filesToUpload = uniqueFiles.length > 0 ? uniqueFiles : files;
setUploadStatus("uploading");
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
// 분할 업로드 여부 판단
const totalFiles = filesToUpload.length;
const totalChunks = Math.ceil(totalFiles / CHUNK_SIZE);
const isChunked = totalChunks > 1;
if (isChunked) {
toast.loading(`파일 업로드 준비 중... (총 ${totalFiles}개, ${totalChunks}회 분할)`, { id: "file-upload" });
} else {
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
}
try {
// 🔑 레코드 모드 우선 사용
@ -585,13 +598,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
// 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용
// formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시
const finalLinkedTable = effectiveIsRecordMode
? effectiveTableName
: (formData?.linkedTable || effectiveTableName);
const uploadData = {
// 🎯 formData에서 백엔드 API 설정 가져오기
autoLink: formData?.autoLink || true,
linkedTable: finalLinkedTable,
recordId: effectiveRecordId || `temp_${component.id}`,
@ -599,143 +610,163 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
docType: component.fileConfig?.docType || "DOCUMENT",
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달
// 호환성을 위한 기존 필드들
companyCode: userCompanyCode,
tableName: effectiveTableName,
fieldName: effectiveColumnName,
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
// 🆕 레코드 모드 플래그
targetObjid: targetObjid,
isRecordMode: effectiveIsRecordMode,
};
const response = await uploadFiles({
files: filesToUpload,
...uploadData,
});
if (response.success) {
// FileUploadResponse 타입에 맞게 files 배열 사용
const fileData = response.files || (response as any).data || [];
// 🔄 파일을 CHUNK_SIZE(10개)씩 나눠서 순차 업로드
const allNewFiles: any[] = [];
let failedChunks = 0;
if (fileData.length === 0) {
throw new Error("업로드된 파일 데이터를 받지 못했습니다.");
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, totalFiles);
const chunk = filesToUpload.slice(start, end);
// 분할 업로드 시 진행 상태 토스트 업데이트
if (isChunked) {
toast.loading(
`업로드 중... ${chunkIndex + 1}/${totalChunks} 배치 (${start + 1}~${end}번째 파일)`,
{ id: "file-upload" }
);
}
const newFiles = fileData.map((file: any) => ({
objid: file.objid || file.id,
savedFileName: file.saved_file_name || file.savedFileName,
realFileName: file.real_file_name || file.realFileName || file.name,
fileSize: file.file_size || file.fileSize || file.size,
fileExt: file.file_ext || file.fileExt || file.extension,
filePath: file.file_path || file.filePath || file.path,
docType: file.doc_type || file.docType,
docTypeName: file.doc_type_name || file.docTypeName,
targetObjid: file.target_objid || file.targetObjid,
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
companyCode: file.company_code || file.companyCode,
writer: file.writer,
regdate: file.regdate,
status: file.status || "ACTIVE",
uploadedAt: new Date().toISOString(),
...file,
}));
const updatedFiles = [...uploadedFiles, ...newFiles];
setUploadedFiles(updatedFiles);
setUploadStatus("success");
// localStorage 백업 (레코드별 고유 키 사용)
try {
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
const response = await uploadFiles({
files: chunk,
...uploadData,
});
if (response.success) {
const fileData = response.files || (response as any).data || [];
const chunkFiles = fileData.map((file: any) => ({
objid: file.objid || file.id,
savedFileName: file.saved_file_name || file.savedFileName,
realFileName: file.real_file_name || file.realFileName || file.name,
fileSize: file.file_size || file.fileSize || file.size,
fileExt: file.file_ext || file.fileExt || file.extension,
filePath: file.file_path || file.filePath || file.path,
docType: file.doc_type || file.docType,
docTypeName: file.doc_type_name || file.docTypeName,
targetObjid: file.target_objid || file.targetObjid,
parentTargetObjid: file.parent_target_objid || file.parentTargetObjid,
companyCode: file.company_code || file.companyCode,
writer: file.writer,
regdate: file.regdate,
status: file.status || "ACTIVE",
uploadedAt: new Date().toISOString(),
...file,
}));
allNewFiles.push(...chunkFiles);
} else {
console.error(`${chunkIndex + 1}번째 배치 업로드 실패:`, response);
failedChunks++;
}
} catch (chunkError) {
console.error(`${chunkIndex + 1}번째 배치 업로드 오류:`, chunkError);
failedChunks++;
}
}
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
if (typeof window !== "undefined") {
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
const globalFileState = (window as any).globalFileState || {};
const uniqueKey = getUniqueKey();
globalFileState[uniqueKey] = updatedFiles;
(window as any).globalFileState = globalFileState;
// 모든 배치 처리 완료 후 결과 처리
if (allNewFiles.length === 0) {
throw new Error("업로드된 파일 데이터를 받지 못했습니다.");
}
// 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용)
GlobalFileManager.registerFiles(newFiles, {
uploadPage: window.location.pathname,
const updatedFiles = [...uploadedFiles, ...allNewFiles];
setUploadedFiles(updatedFiles);
setUploadStatus("success");
// localStorage 백업 (레코드별 고유 키 사용)
try {
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
}
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
if (typeof window !== "undefined") {
const globalFileState = (window as any).globalFileState || {};
const uniqueKey = getUniqueKey();
globalFileState[uniqueKey] = updatedFiles;
(window as any).globalFileState = globalFileState;
GlobalFileManager.registerFiles(allNewFiles, {
uploadPage: window.location.pathname,
componentId: component.id,
screenId: formData?.screenId,
recordId: recordId,
});
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: component.id,
screenId: formData?.screenId,
recordId: recordId, // 🆕 레코드 ID 추가
});
eventColumnName: columnName,
uniqueKey: uniqueKey,
recordId: recordId,
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now(),
},
});
window.dispatchEvent(syncEvent);
}
// 모든 파일 컴포넌트에 동기화 이벤트 발생
// 🆕 columnName 추가하여 같은 화면의 다른 파일 업로드 컴포넌트와 구분
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: component.id,
eventColumnName: columnName, // 🆕 컬럼명 추가
uniqueKey: uniqueKey, // 🆕 고유 키 추가
recordId: recordId, // 🆕 레코드 ID 추가
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now(),
},
});
window.dispatchEvent(syncEvent);
}
// 컴포넌트 업데이트
if (onUpdate) {
const timestamp = Date.now();
onUpdate({
uploadedFiles: updatedFiles,
lastFileUpdate: timestamp,
});
} else {
console.warn("⚠️ onUpdate 콜백이 없습니다!");
}
// 🆕 이미지/파일 컬럼에 objid 저장 (formData 업데이트)
if (onFormDataChange && effectiveColumnName) {
// 🎯 이미지/파일 타입 컬럼: 첫 번째 파일의 objid를 저장 (그리드에서 표시용)
// 단일 파일인 경우 단일 값, 복수 파일인 경우 콤마 구분 문자열
const fileObjids = updatedFiles.map(file => file.objid);
const columnValue = fileConfig.multiple
? fileObjids.join(',') // 복수 파일: 콤마 구분
: (fileObjids[0] || ''); // 단일 파일: 첫 번째 파일 ID
// onFormDataChange를 (fieldName, value) 형태로 호출 (SaveModal 호환)
onFormDataChange(effectiveColumnName, columnValue);
}
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== "undefined") {
const refreshEvent = new CustomEvent("refreshFileStatus", {
detail: {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid: targetObjid,
fileCount: updatedFiles.length,
},
});
window.dispatchEvent(refreshEvent);
}
// 컴포넌트 설정 콜백
if (safeComponentConfig.onFileUpload) {
safeComponentConfig.onFileUpload(newFiles);
}
// 성공 시 토스트 처리
setUploadStatus("idle");
toast.dismiss("file-upload");
toast.success(`${newFiles.length}개 파일 업로드 완료`);
// 컴포넌트 업데이트
if (onUpdate) {
const timestamp = Date.now();
onUpdate({
uploadedFiles: updatedFiles,
lastFileUpdate: timestamp,
});
} else {
console.error("❌ 파일 업로드 실패:", response);
throw new Error(response.message || (response as any).error || "파일 업로드에 실패했습니다.");
console.warn("⚠️ onUpdate 콜백이 없습니다!");
}
// 이미지/파일 컬럼에 objid 저장 (formData 업데이트)
if (onFormDataChange && effectiveColumnName) {
const fileObjids = updatedFiles.map(file => file.objid);
const columnValue = fileConfig.multiple
? fileObjids.join(',')
: (fileObjids[0] || '');
onFormDataChange(effectiveColumnName, columnValue);
}
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== "undefined") {
const refreshEvent = new CustomEvent("refreshFileStatus", {
detail: {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid: targetObjid,
fileCount: updatedFiles.length,
},
});
window.dispatchEvent(refreshEvent);
}
// 컴포넌트 설정 콜백
if (safeComponentConfig.onFileUpload) {
safeComponentConfig.onFileUpload(allNewFiles);
}
// 성공/부분 성공 토스트 처리
setUploadStatus("idle");
toast.dismiss("file-upload");
if (failedChunks > 0) {
toast.warning(
`${allNewFiles.length}개 업로드 완료, 일부 파일 실패`,
{ description: "일부 파일이 업로드되지 않았습니다. 다시 시도해주세요." }
);
} else {
toast.success(`${allNewFiles.length}개 파일 업로드 완료`);
}
} catch (error) {
console.error("파일 업로드 오류:", error);
@ -991,19 +1022,26 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
[safeComponentConfig.readonly, safeComponentConfig.disabled, handleFileSelect, onClick],
);
// 🔧 커스텀 스타일 감지 (StyleEditor에서 설정한 값)
const customStyle = component.style || {};
const hasCustomBorder = !!(customStyle.borderWidth || customStyle.borderColor || customStyle.borderStyle || customStyle.border);
const hasCustomBackground = !!customStyle.backgroundColor;
const hasCustomRadius = !!customStyle.borderRadius;
return (
<div
style={{
...componentStyle,
width: "100%", // 🆕 부모 컨테이너 너비에 맞춤
height: "100%", // 🆕 부모 컨테이너 높이에 맞춤
border: "none !important",
boxShadow: "none !important",
outline: "none !important",
backgroundColor: "transparent !important",
padding: "0px !important",
borderRadius: "0px !important",
marginBottom: "8px !important",
width: "100%",
height: "100%",
// 🔧 !important 제거 - 커스텀 스타일이 없을 때만 기본값 적용
border: hasCustomBorder ? undefined : "none",
boxShadow: "none",
outline: "none",
backgroundColor: hasCustomBackground ? undefined : "transparent",
padding: "0px",
borderRadius: hasCustomRadius ? undefined : "0px",
marginBottom: "8px",
}}
className={`${className} file-upload-container`}
>
@ -1014,15 +1052,15 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
position: "absolute",
top: "-20px",
left: "0px",
fontSize: "12px",
color: "rgb(107, 114, 128)",
fontWeight: "400",
background: "transparent !important",
border: "none !important",
boxShadow: "none !important",
outline: "none !important",
padding: "0px !important",
margin: "0px !important"
fontSize: customStyle.labelFontSize || "12px",
color: customStyle.labelColor || "rgb(107, 114, 128)",
fontWeight: customStyle.labelFontWeight || "400",
background: "transparent",
border: "none",
boxShadow: "none",
outline: "none",
padding: "0px",
margin: "0px"
}}
>
{component.label}
@ -1033,7 +1071,13 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
)}
<div
className="border-border bg-card relative flex h-full w-full flex-col rounded-lg border overflow-hidden"
className={cn(
"relative flex h-full w-full flex-col overflow-hidden",
// 커스텀 테두리가 없을 때만 기본 테두리 표시
!hasCustomBorder && "border-border rounded-lg border",
// 커스텀 배경이 없을 때만 기본 배경 표시
!hasCustomBackground && "bg-card",
)}
>
{/* 대표 이미지 전체 화면 표시 */}
{uploadedFiles.length > 0 ? (() => {
@ -1117,7 +1161,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
onFileDelete={handleFileDelete}
onFileView={handleFileView}
onSetRepresentative={handleSetRepresentative}
config={safeComponentConfig}
config={fileConfig}
isDesignMode={isDesignMode}
/>
</div>

View File

@ -121,8 +121,8 @@ export class V2SelectRenderer extends AutoRegisteringComponentRenderer {
onChange={handleChange}
config={{
mode: config.mode || "dropdown",
// 🔧 카테고리 타입이면 source를 "category"로 설정
source: config.source || (isCategoryType ? "category" : "distinct"),
// 🔧 카테고리 타입이면 source를 무조건 "category"로 강제 (테이블 타입 관리 설정 우선)
source: isCategoryType ? "category" : (config.source || "distinct"),
multiple: config.multiple || false,
searchable: config.searchable ?? true,
placeholder: config.placeholder || "선택하세요",

View File

@ -42,6 +42,8 @@ export interface AdditionalTabConfig {
sortable?: boolean;
align?: "left" | "center" | "right";
bold?: boolean;
showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true)
showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true)
format?: {
type?: "number" | "currency" | "date" | "text";
thousandSeparator?: boolean;
@ -50,6 +52,14 @@ export interface AdditionalTabConfig {
suffix?: string;
dateFormat?: string;
};
// Entity 조인 컬럼 정보
isEntityJoin?: boolean;
joinInfo?: {
sourceTable: string;
sourceColumn: string;
referenceTable: string;
joinAlias: string;
};
}>;
addModalColumns?: Array<{
@ -105,6 +115,14 @@ export interface AdditionalTabConfig {
groupByColumns?: string[];
};
// 추가 버튼 설정 (모달 화면 연결 지원)
addButton?: {
enabled: boolean;
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
};
deleteButton?: {
enabled: boolean;
buttonLabel?: string;
@ -131,6 +149,23 @@ export interface SplitPanelLayoutConfig {
showAdd?: boolean;
showEdit?: boolean; // 수정 버튼
showDelete?: boolean; // 삭제 버튼
// 수정 버튼 설정 (모달 화면 연결 지원)
editButton?: {
enabled: boolean;
mode: "auto" | "modal"; // auto: 내장 편집, modal: 커스텀 모달 화면
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
buttonLabel?: string; // 버튼 라벨 (기본: "수정")
};
// 추가 버튼 설정 (모달 화면 연결 지원)
addButton?: {
enabled: boolean;
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
};
columns?: Array<{
name: string;
label: string;
@ -145,6 +180,14 @@ export interface SplitPanelLayoutConfig {
suffix?: string; // 접미사 (예: "원", "개")
dateFormat?: string; // 날짜 포맷 (type: "date")
};
// Entity 조인 컬럼 정보
isEntityJoin?: boolean;
joinInfo?: {
sourceTable: string;
sourceColumn: string;
referenceTable: string;
joinAlias: string;
};
}>;
// 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{
@ -209,6 +252,8 @@ export interface SplitPanelLayoutConfig {
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드)
showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true)
showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true)
format?: {
type?: "number" | "currency" | "date" | "text"; // 포맷 타입
thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency")
@ -217,6 +262,14 @@ export interface SplitPanelLayoutConfig {
suffix?: string; // 접미사 (예: "원", "개")
dateFormat?: string; // 날짜 포맷 (type: "date")
};
// Entity 조인 컬럼 정보
isEntityJoin?: boolean;
joinInfo?: {
sourceTable: string;
sourceColumn: string;
referenceTable: string;
joinAlias: string;
};
}>;
// 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{
@ -279,6 +332,14 @@ export interface SplitPanelLayoutConfig {
groupByColumns?: string[]; // 🆕 그룹핑 기준 컬럼들 (예: ["customer_id", "item_id"])
};
// 🆕 추가 버튼 설정 (모달 화면 연결 지원)
addButton?: {
enabled: boolean; // 추가 버튼 표시 여부 (기본: true)
mode: "auto" | "modal"; // auto: 내장 폼, modal: 커스텀 모달 화면
modalScreenId?: number; // 모달로 열 화면 ID (mode: "modal"일 때)
buttonLabel?: string; // 버튼 라벨 (기본: "추가")
};
// 🆕 삭제 버튼 설정
deleteButton?: {
enabled: boolean; // 삭제 버튼 표시 여부 (기본: true)

View File

@ -6,6 +6,8 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react";
import { CardDisplayConfig, ColumnConfig } from "./types";
import { getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file";
interface CardModeRendererProps {
data: Record<string, any>[];
@ -168,12 +170,25 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
{imageValue && (
<div className="mb-3">
<img
src={imageValue}
src={(() => {
const strValue = String(imageValue);
const isObjid = /^\d+$/.test(strValue);
return isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
})()}
alt={titleValue}
className="h-24 w-full rounded-md bg-gray-100 object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
// 이미지 로드 실패 시 폴백 표시
target.style.display = "none";
const parent = target.parentElement;
if (parent && !parent.querySelector("[data-image-fallback]")) {
const fallback = document.createElement("div");
fallback.setAttribute("data-image-fallback", "true");
fallback.className = "flex items-center justify-center h-24 w-full rounded-md bg-muted text-muted-foreground";
fallback.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>`;
parent.appendChild(fallback);
}
}}
/>
</div>

View File

@ -24,7 +24,7 @@ interface SingleTableWithStickyProps {
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
renderCheckboxHeader?: () => React.ReactNode;
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string;
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => React.ReactNode;
getColumnWidth: (column: ColumnConfig) => number;
containerWidth?: string; // 컨테이너 너비 설정
loading?: boolean;
@ -264,25 +264,34 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
// formatCellValue 결과 (이미지 등 JSX 반환 가능)
const rawCellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
// 이미지 등 JSX 반환 여부 확인
const isReactElement = typeof rawCellValue === "object" && React.isValidElement(rawCellValue);
// 셀 값에서 검색어 하이라이트 렌더링
const renderCellContent = () => {
const cellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return cellValue;
// ReactNode(JSX)가 반환된 경우 (이미지 등) 그대로 렌더링
if (isReactElement) {
return rawCellValue;
}
// 검색어 하이라이트 처리
const lowerValue = String(cellValue).toLowerCase();
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return rawCellValue;
}
// 검색어 하이라이트 처리 (문자열만)
const strValue = String(rawCellValue);
const lowerValue = strValue.toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm);
if (startIndex === -1) return cellValue;
if (startIndex === -1) return rawCellValue;
const before = String(cellValue).slice(0, startIndex);
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
const after = String(cellValue).slice(startIndex + searchTerm.length);
const before = strValue.slice(0, startIndex);
const match = strValue.slice(startIndex, startIndex + searchTerm.length);
const after = strValue.slice(startIndex + searchTerm.length);
return (
<>
@ -307,7 +316,9 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cn(
"text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm",
"text-foreground h-10 px-3 py-1.5 align-middle text-xs transition-colors sm:px-4 sm:py-2 sm:text-sm",
// 이미지 셀은 overflow/ellipsis 제외 (이미지 잘림 방지)
!isReactElement && "whitespace-nowrap",
`text-${column.align}`,
// 고정 컬럼 스타일
column.fixed === "left" &&
@ -322,9 +333,8 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
// 이미지 셀은 overflow 허용
...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }),
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),

View File

@ -12,6 +12,96 @@ import { getFilePreviewUrl } from "@/lib/api/file";
import { Button } from "@/components/ui/button";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
// 🖼️ 테이블 셀 이미지 썸네일 컴포넌트
// objid인 경우 인증된 API로 blob URL 생성, 경로인 경우 직접 URL 사용
const TableCellImage: React.FC<{ value: string }> = React.memo(({ value }) => {
const [imgSrc, setImgSrc] = React.useState<string | null>(null);
const [error, setError] = React.useState(false);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
let mounted = true;
const strValue = String(value);
const isObjid = /^\d+$/.test(strValue);
if (isObjid) {
// objid인 경우: 인증된 API로 blob 다운로드
const loadImage = async () => {
try {
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.get(`/files/preview/${strValue}`, {
responseType: "blob",
});
if (mounted) {
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
setImgSrc(url);
setLoading(false);
}
} catch {
if (mounted) {
setError(true);
setLoading(false);
}
}
};
loadImage();
} else {
// 경로인 경우: 직접 URL 사용
setImgSrc(getFullImageUrl(strValue));
setLoading(false);
}
return () => {
mounted = false;
// blob URL 해제
if (imgSrc && imgSrc.startsWith("blob:")) {
window.URL.revokeObjectURL(imgSrc);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
if (loading) {
return (
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
<div className="h-8 w-8 animate-pulse rounded bg-muted sm:h-10 sm:w-10" />
</div>
);
}
if (error || !imgSrc) {
return (
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
<div className="bg-muted text-muted-foreground flex h-8 w-8 items-center justify-center rounded sm:h-10 sm:w-10" title="이미지를 불러올 수 없습니다">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="2" y1="2" x2="22" y2="22"/><path d="M10.41 10.41a2 2 0 1 1-2.83-2.83"/><line x1="13.5" y1="13.5" x2="6" y2="21"/><line x1="18" y1="12" x2="21" y2="15"/><path d="M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59"/><path d="M21 15V5a2 2 0 0 0-2-2H9"/></svg>
</div>
</div>
);
}
return (
<div className="flex items-center justify-center" style={{ minHeight: "32px" }}>
<img
src={imgSrc}
alt="이미지"
className="h-8 w-8 cursor-pointer rounded object-cover transition-opacity hover:opacity-80 sm:h-10 sm:w-10"
style={{ maxWidth: "40px", maxHeight: "40px" }}
onClick={(e) => {
e.stopPropagation();
// objid인 경우 preview URL로 열기, 아니면 full URL로 열기
const strValue = String(value);
const isObjid = /^\d+$/.test(strValue);
const openUrl = isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
window.open(openUrl, "_blank");
}}
onError={() => setError(true)}
/>
</div>
);
});
TableCellImage.displayName = "TableCellImage";
// 🆕 RelatedDataButtons 전역 레지스트리 타입 선언
declare global {
interface Window {
@ -4061,35 +4151,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선)
const inputType = meta?.inputType || column.inputType;
// 🖼️ 이미지 타입: 작은 썸네일 표시
// 🖼️ 이미지 타입: 작은 썸네일 표시 (TableCellImage 컴포넌트 사용)
if (inputType === "image" && value) {
// value가 objid (숫자 또는 숫자 문자열)인 경우 파일 API URL 사용
// 🔑 download 대신 preview 사용 (공개 접근 허용)
const strValue = String(value);
const isObjid = /^\d+$/.test(strValue);
// 🔑 상대 경로(/api/...) 대신 전체 URL 사용 (Docker 환경에서 Next.js rewrite 의존 방지)
const imageUrl = isObjid
? getFilePreviewUrl(strValue)
: getFullImageUrl(strValue);
return (
<div className="flex justify-center">
<img
src={imageUrl}
alt="이미지"
className="h-10 w-10 rounded object-cover cursor-pointer hover:opacity-80 transition-opacity"
style={{ maxWidth: "40px", maxHeight: "40px" }}
onClick={(e) => {
e.stopPropagation();
// 이미지 클릭 시 새 탭에서 크게 보기
window.open(imageUrl, "_blank");
}}
onError={(e) => {
// 이미지 로드 실패 시 기본 아이콘 표시
(e.target as HTMLImageElement).style.display = "none";
}}
/>
</div>
);
return <TableCellImage value={String(value)} />;
}
// 📎 첨부파일 타입: 파일 아이콘과 개수 표시
@ -5945,7 +6009,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<td
key={column.columnName}
className={cn(
"text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm",
"text-foreground text-xs font-normal sm:text-sm",
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
column.columnName === "__checkbox__"
? "px-0 py-1"
: "px-2 py-1 sm:px-4 sm:py-1.5",
@ -6112,7 +6178,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
data-row={index}
data-col={colIndex}
className={cn(
"text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm",
"text-foreground text-xs font-normal sm:text-sm",
// 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지)
inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap",
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
// 🆕 포커스된 셀 스타일

View File

@ -186,9 +186,10 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
return;
}
// 🆕 customTableName이 설정된 경우 반드시 API에서 가져오기
// tableColumns prop은 화면의 기본 테이블 컬럼이므로, customTableName 사용 시 무시
const shouldUseTableColumnsProp = !config.useCustomTable && tableColumns && tableColumns.length > 0;
// tableColumns prop은 화면의 기본 테이블 컬럼이므로,
// 다른 테이블을 선택한 경우 반드시 API에서 가져오기
const isUsingDifferentTable = config.selectedTable && screenTableName && config.selectedTable !== screenTableName;
const shouldUseTableColumnsProp = !config.useCustomTable && !isUsingDifferentTable && tableColumns && tableColumns.length > 0;
if (shouldUseTableColumnsProp) {
const mappedColumns = tableColumns.map((column: any) => ({
@ -772,11 +773,113 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
handleChange("columns", columns);
};
// 테이블 변경 핸들러 - 테이블 변경 시 컬럼 설정 초기화
const handleTableChange = (newTableName: string) => {
if (newTableName === targetTableName) return;
const updatedConfig = {
...config,
selectedTable: newTableName,
// 테이블이 변경되면 컬럼 설정 초기화
columns: [],
};
onChange(updatedConfig);
setTableComboboxOpen(false);
};
return (
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
<div className="space-y-6">
{/* 테이블 선택 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]">
. .
</p>
</div>
<hr className="border-border" />
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tableComboboxOpen}
className="h-8 w-full justify-between text-xs"
disabled={loadingTables}
>
<div className="flex items-center gap-2 truncate">
<Table2 className="h-3 w-3 shrink-0" />
<span className="truncate">
{loadingTables
? "테이블 로딩 중..."
: targetTableName
? availableTables.find((t) => t.tableName === targetTableName)?.displayName ||
targetTableName
: "테이블 선택"}
</span>
</div>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName}`}
onSelect={() => handleTableChange(table.tableName)}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
targetTableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span>{table.displayName}</span>
{table.displayName !== table.tableName && (
<span className="text-[10px] text-gray-400">{table.tableName}</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{screenTableName && targetTableName !== screenTableName && (
<div className="flex items-center justify-between rounded bg-amber-50 px-2 py-1">
<span className="text-[10px] text-amber-700">
({screenTableName})
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-[10px] text-amber-700 hover:text-amber-900"
onClick={() => handleTableChange(screenTableName)}
>
</Button>
</div>
)}
</div>
</div>
{/* 툴바 버튼 설정 */}
<div className="space-y-3">
<div>
@ -1167,11 +1270,11 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div>
)}
{!screenTableName ? (
{!targetTableName ? (
<div className="space-y-3">
<div className="text-center text-gray-500">
<p> .</p>
<p className="text-sm"> .</p>
<p> .</p>
<p className="text-sm"> .</p>
</div>
</div>
) : availableColumns.length === 0 ? (

View File

@ -154,15 +154,23 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 대상 패널의 첫 번째 테이블 자동 선택
useEffect(() => {
if (!autoSelectFirstTable || tableList.length === 0) {
if (!autoSelectFirstTable) {
return;
}
// 탭 전환 감지: 활성 탭이 변경되었는지 확인
const tabChanged = prevActiveTabIdsRef.current !== activeTabIdsStr;
if (tabChanged) {
// 탭이 변경되면 항상 ref를 갱신 (tableList가 비어 있어도)
// 이렇게 해야 비동기로 tableList가 나중에 채워질 때 중복 감지하지 않음
prevActiveTabIdsRef.current = activeTabIdsStr;
if (tableList.length === 0) {
// 테이블이 아직 등록되지 않은 상태 (비동기 로드 중)
// tableList가 나중에 채워지면 아래 폴백 로직에서 처리됨
return;
}
// 탭 전환 시: 해당 탭에 속한 테이블 중 첫 번째 강제 선택
const activeTabTable = tableList.find((t) => t.parentTabId && activeTabIds.includes(t.parentTabId));
const targetTable = activeTabTable || tableList[0];
@ -173,11 +181,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
return; // 탭 전환 시에는 여기서 종료
}
// 현재 선택된 테이블이 대상 패널에 있는지 확인
const isCurrentTableInTarget = selectedTableId && tableList.some((t) => t.tableId === selectedTableId);
// tableList가 비어있으면 아래 로직 스킵
if (tableList.length === 0) {
return;
}
// 현재 선택된 테이블이 대상 패널에 없으면 첫 번째 테이블 선택
if (!selectedTableId || !isCurrentTableInTarget) {
// 현재 선택된 테이블이 활성 탭에 속하는지 확인
const isCurrentTableInActiveTab = selectedTableId && tableList.some((t) => {
if (t.tableId !== selectedTableId) return false;
// parentTabId가 있는 테이블이면 활성 탭에 속하는지 확인
if (t.parentTabId) return activeTabIds.includes(t.parentTabId);
return true; // parentTabId 없는 전역 테이블은 항상 유효
});
// 현재 선택된 테이블이 활성 탭에 없거나 미선택이면 첫 번째 테이블 선택
if (!selectedTableId || !isCurrentTableInActiveTab) {
const activeTabTable = tableList.find((t) => t.parentTabId && activeTabIds.includes(t.parentTabId));
const targetTable = activeTabTable || tableList[0];
@ -223,6 +241,102 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
}
}, [currentTableTabId, currentTable?.tableName]);
// 탭 전환 플래그 (탭 복귀 시 필터 재적용을 위해)
const needsFilterReapplyRef = useRef(false);
const prevActiveTabIdsForReapplyRef = useRef<string>(activeTabIdsStr);
// 탭 전환 감지: 플래그만 설정 (실제 적용은 currentTable이 준비된 후)
useEffect(() => {
if (prevActiveTabIdsForReapplyRef.current !== activeTabIdsStr) {
prevActiveTabIdsForReapplyRef.current = activeTabIdsStr;
needsFilterReapplyRef.current = true;
}
}, [activeTabIdsStr]);
// 탭 복귀 시 기존 필터값 재적용
// currentTable이 준비되고 필터값이 있을 때 실행
useEffect(() => {
if (!needsFilterReapplyRef.current) return;
if (!currentTable?.onFilterChange) return;
// 플래그 즉시 해제 (중복 실행 방지)
needsFilterReapplyRef.current = false;
// activeFilters와 filterValues가 있으면 직접 onFilterChange 호출
// applyFilters 클로저 의존성을 피하고 직접 계산
if (activeFilters.length === 0) return;
const hasValues = Object.values(filterValues).some(
(v) => v !== "" && v !== undefined && v !== null,
);
if (!hasValues) return;
const filtersWithValues = activeFilters
.map((filter) => {
let filterValue = filterValues[filter.columnName];
// 날짜 범위 객체 처리
if (
filter.filterType === "date" &&
filterValue &&
typeof filterValue === "object" &&
(filterValue.from || filterValue.to)
) {
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const fromStr = filterValue.from ? formatDate(filterValue.from) : "";
const toStr = filterValue.to ? formatDate(filterValue.to) : "";
if (fromStr && toStr) filterValue = `${fromStr}|${toStr}`;
else if (fromStr) filterValue = `${fromStr}|`;
else if (toStr) filterValue = `|${toStr}`;
else filterValue = "";
}
// 배열 처리
if (Array.isArray(filterValue)) {
filterValue = filterValue.join("|");
}
let operator = "contains";
if (filter.filterType === "select") operator = "equals";
else if (filter.filterType === "number") operator = "equals";
return {
...filter,
value: filterValue || "",
operator,
};
})
.filter((f) => {
if (!f.value) return false;
if (typeof f.value === "string" && f.value === "") return false;
return true;
});
// 직접 onFilterChange 호출 (applyFilters 클로저 우회)
currentTable.onFilterChange(filtersWithValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTable?.onFilterChange, currentTable?.tableName, activeFilters, filterValues]);
// 필터 적용을 다음 렌더 사이클로 지연 (activeFilters 업데이트 후 적용 보장)
const pendingFilterApplyRef = useRef<{ values: Record<string, any>; tableName: string } | null>(null);
useEffect(() => {
if (pendingFilterApplyRef.current) {
const { values, tableName } = pendingFilterApplyRef.current;
// 현재 테이블이 요청된 테이블과 일치하는지 확인 (탭이 빠르게 전환된 경우 방지)
if (currentTable?.tableName === tableName) {
applyFilters(values);
}
pendingFilterApplyRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeFilters, currentTable?.tableName]);
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
useEffect(() => {
if (!currentTable?.tableName) return;
@ -246,8 +360,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
try {
const parsedValues = JSON.parse(savedValues);
setFilterValues(parsedValues);
// 즉시 필터 적용
setTimeout(() => applyFilters(parsedValues), 100);
// 다음 렌더 사이클에서 필터 적용 (activeFilters 업데이트 후)
pendingFilterApplyRef.current = { values: parsedValues, tableName: currentTable.tableName };
} catch {
setFilterValues({});
}
@ -297,8 +411,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
try {
const parsedValues = JSON.parse(savedValues);
setFilterValues(parsedValues);
// 즉시 필터 적용
setTimeout(() => applyFilters(parsedValues), 100);
// 다음 렌더 사이클에서 필터 적용 (activeFilters 업데이트 후)
pendingFilterApplyRef.current = { values: parsedValues, tableName: currentTable.tableName };
} catch {
setFilterValues({});
}

View File

@ -78,6 +78,9 @@ const TabsDesignEditor: React.FC<{
[activeTabId, component, onUpdateComponent, tabs]
);
// 10px 단위 스냅 함수
const snapTo10 = useCallback((value: number) => Math.round(value / 10) * 10, []);
// 컴포넌트 드래그 시작
const handleDragStart = useCallback(
(e: React.MouseEvent, comp: TabInlineComponent) => {
@ -104,9 +107,9 @@ const TabsDesignEditor: React.FC<{
const deltaX = moveEvent.clientX - startMouseX;
const deltaY = moveEvent.clientY - startMouseY;
// 새 위치 = 시작 위치 + 이동량
const newX = Math.max(0, startLeft + deltaX);
const newY = Math.max(0, startTop + deltaY);
// 새 위치 = 시작 위치 + 이동량 (10px 단위 스냅 적용)
const newX = snapTo10(Math.max(0, startLeft + deltaX));
const newY = snapTo10(Math.max(0, startTop + deltaY));
// React 상태로 위치 업데이트 (리렌더링 트리거)
setDragPosition({ x: newX, y: newY });
@ -126,9 +129,9 @@ const TabsDesignEditor: React.FC<{
const deltaX = upEvent.clientX - startMouseX;
const deltaY = upEvent.clientY - startMouseY;
// 새 위치 = 시작 위치 + 이동량
const newX = Math.max(0, startLeft + deltaX);
const newY = Math.max(0, startTop + deltaY);
// 새 위치 = 시작 위치 + 이동량 (10px 단위 스냅 적용)
const newX = snapTo10(Math.max(0, startLeft + deltaX));
const newY = snapTo10(Math.max(0, startTop + deltaY));
setDraggingCompId(null);
setDragPosition(null);
@ -144,8 +147,8 @@ const TabsDesignEditor: React.FC<{
? {
...c,
position: {
x: Math.max(0, Math.round(newX)),
y: Math.max(0, Math.round(newY)),
x: newX,
y: newY,
},
}
: c
@ -172,12 +175,9 @@ const TabsDesignEditor: React.FC<{
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[activeTabId, component, onUpdateComponent, tabs]
[activeTabId, component, onUpdateComponent, tabs, snapTo10]
);
// 10px 단위 스냅 함수
const snapTo10 = useCallback((value: number) => Math.round(value / 10) * 10, []);
// 리사이즈 시작 핸들러
const handleResizeStart = useCallback(
(e: React.MouseEvent, comp: TabInlineComponent, direction: "e" | "s" | "se") => {

View File

@ -56,16 +56,19 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
// DOM props 필터링 (React 관련 props 제거)
const domProps = filterDOMProps(props);
// 🔧 StyleEditor(component.style) 값 우선, 없으면 componentConfig 폴백
const customStyle = component.style || {};
// 텍스트 스타일 계산
const textStyle: React.CSSProperties = {
fontSize: componentConfig.fontSize || "14px",
fontWeight: componentConfig.fontWeight || "normal",
color: componentConfig.color || "#212121",
textAlign: componentConfig.textAlign || "left",
backgroundColor: componentConfig.backgroundColor || "transparent",
fontSize: customStyle.fontSize || componentConfig.fontSize || "14px",
fontWeight: customStyle.fontWeight || componentConfig.fontWeight || "normal",
color: customStyle.color || componentConfig.color || "#212121",
textAlign: (customStyle.textAlign || componentConfig.textAlign || "left") as React.CSSProperties["textAlign"],
backgroundColor: customStyle.backgroundColor || componentConfig.backgroundColor || "transparent",
padding: componentConfig.padding || "0",
borderRadius: componentConfig.borderRadius || "0",
border: componentConfig.border || "none",
borderRadius: customStyle.borderRadius || componentConfig.borderRadius || "0",
border: customStyle.border || (customStyle.borderWidth ? `${customStyle.borderWidth} ${customStyle.borderStyle || "solid"} ${customStyle.borderColor || "transparent"}` : componentConfig.border || "none"),
width: "100%",
height: "100%",
display: "flex",

View File

@ -214,19 +214,53 @@ export function matchComponentSize(
* / .
* ,
*/
export function toggleAllLabels(components: ComponentData[], forceShow?: boolean): ComponentData[] {
// 현재 라벨이 숨겨진(labelDisplay === false) 컴포넌트가 있는지 확인
const hasHiddenLabel = components.some(
(c) => c.type === "widget" && (c.style as any)?.labelDisplay === false
/**
*
* label , style.labelDisplay를
*/
function hasLabelSupport(component: ComponentData): boolean {
// 라벨이 없는 컴포넌트는 제외
if (!component.label) return false;
// 그룹, datatable 등은 라벨 토글 대상에서 제외
const excludedTypes = ["group", "datatable"];
if (excludedTypes.includes(component.type)) return false;
// 나머지 (widget, component, container, file, flow 등)는 대상
return true;
}
/**
* @param components -
* @param selectedIds - ID ( )
* @param forceShow - / ( )
*/
export function toggleAllLabels(
components: ComponentData[],
selectedIds: string[] = [],
forceShow?: boolean
): ComponentData[] {
// 대상 컴포넌트 필터: selectedIds가 있으면 선택된 것만, 없으면 전체
const targetComponents = components.filter((c) => {
if (!hasLabelSupport(c)) return false;
if (selectedIds.length > 0) return selectedIds.includes(c.id);
return true;
});
// 대상 중 라벨이 숨겨진 컴포넌트가 있는지 확인
const hasHiddenLabel = targetComponents.some(
(c) => (c.style as any)?.labelDisplay === false
);
// forceShow가 지정되면 그 값 사용, 아니면 자동 판단
// 숨겨진 라벨이 있으면 모두 표시, 아니면 모두 숨기기
const shouldShow = forceShow !== undefined ? forceShow : hasHiddenLabel;
// 대상 ID Set (빠른 조회용)
const targetIdSet = new Set(targetComponents.map((c) => c.id));
return components.map((c) => {
// 위젯 타입만 라벨 토글 대상
if (c.type !== "widget") return c;
// 대상이 아닌 컴포넌트는 건드리지 않음
if (!targetIdSet.has(c.id)) return c;
return {
...c,

View File

@ -541,6 +541,23 @@ export class ButtonActionExecutor {
return false;
}
// ✅ 입력 형식 검증 (이메일, 전화번호, URL 등)
const formatValidationDetail = { errors: [] as Array<{ columnName: string; message: string }> };
window.dispatchEvent(
new CustomEvent("validateFormInputs", {
detail: formatValidationDetail,
}),
);
// 약간의 대기 (이벤트 핸들러가 동기적으로 실행되지만 안전을 위해)
await new Promise((resolve) => setTimeout(resolve, 50));
if (formatValidationDetail.errors.length > 0) {
const errorMessages = formatValidationDetail.errors.map((e) => e.message);
console.log("❌ [handleSave] 입력 형식 검증 실패:", formatValidationDetail.errors);
toast.error(`입력 형식을 확인해주세요: ${errorMessages.join(", ")}`);
return false;
}
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
if (onSave) {
try {
@ -987,13 +1004,25 @@ export class ButtonActionExecutor {
}
const primaryKeys = primaryKeyResult.data || [];
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
let primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
// 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리
// originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨
// 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인
const hasRealOriginalData = originalData && Object.keys(originalData).length > 0;
// 🆕 폴백: formData에 PK가 없으면 originalData에서 PK 추출
// 수정 모달에서 id 입력 필드가 없는 경우 formData에 id가 포함되지 않음
if (!primaryKeyValue && hasRealOriginalData) {
primaryKeyValue = this.extractPrimaryKeyValueFromDB(originalData, primaryKeys);
if (primaryKeyValue) {
// formData에도 PK 값을 주입하여 UPDATE 쿼리에서 사용 가능하게 함
const pkColumn = primaryKeys[0];
formData[pkColumn] = primaryKeyValue;
console.log(`🔑 [handleSave] originalData에서 PK 복원: ${pkColumn} = ${primaryKeyValue}`);
}
}
// 🆕 폴백 로직: originalData가 없어도 formData에 id가 있으면 UPDATE로 판단
// 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리
const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== "";
@ -3144,6 +3173,8 @@ export class ButtonActionExecutor {
editData: useAsEditData && isPassDataMode ? parentData : undefined,
splitPanelParentData: isPassDataMode ? parentData : undefined,
urlParams: dataSourceId ? { dataSourceId } : undefined,
// 🆕 필드 매핑 정보 전달 - ScreenModal에서 매핑된 필드를 필터링하지 않도록
fieldMappings: config.fieldMappings,
},
});
@ -4151,6 +4182,8 @@ export class ButtonActionExecutor {
dataSourceType: controlDataSource,
sourceData,
context: extendedContext,
// 저장 전 원본 데이터 전달 (after 타이밍에서 DB 기존값 비교용)
originalData: context.originalData || null,
});
results.push({
@ -4965,7 +4998,7 @@ export class ButtonActionExecutor {
// visible이 true인 컬럼만 추출
visibleColumns = columns.filter((col: any) => col.visible !== false).map((col: any) => col.columnName);
// 🎯 column_labels 테이블에서 실제 라벨 가져오기
// column_labels 테이블에서 실제 라벨 가져오기
try {
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
params: { page: 1, size: 9999 },
@ -5002,19 +5035,77 @@ export class ButtonActionExecutor {
}
});
}
} else {
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
}
}
} catch (error) {
console.error("❌ 화면 레이아웃 조회 실패:", error);
}
// 🎨 카테고리 값들 조회 (한 번만)
// Fallback: 레이아웃에서 컬럼 정보를 못 가져온 경우, table_type_columns에서 직접 조회
// 시스템 컬럼 제외 + 라벨 적용으로 raw 컬럼명 노출 방지
const SYSTEM_COLUMNS = ["id", "company_code", "created_date", "updated_date", "writer"];
if ((!visibleColumns || visibleColumns.length === 0) && context.tableName && dataToExport.length > 0) {
console.log("⚠️ 레이아웃에서 컬럼 설정을 찾지 못함 → table_type_columns에서 fallback 조회");
try {
const { apiClient } = await import("@/lib/api/client");
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
params: { page: 1, size: 9999 },
});
if (columnsResponse.data?.success && columnsResponse.data?.data) {
let columnData = columnsResponse.data.data;
if (columnData.columns && Array.isArray(columnData.columns)) {
columnData = columnData.columns;
}
if (Array.isArray(columnData) && columnData.length > 0) {
// visible이 false가 아닌 컬럼만 + 시스템 컬럼 제외
const filteredCols = columnData.filter((col: any) => {
const colName = (col.column_name || col.columnName || "").toLowerCase();
if (SYSTEM_COLUMNS.includes(colName)) return false;
if (col.isVisible === false || col.is_visible === false) return false;
return true;
});
visibleColumns = filteredCols.map((col: any) => col.column_name || col.columnName);
columnLabels = {};
filteredCols.forEach((col: any) => {
const colName = col.column_name || col.columnName;
const labelValue = col.column_label || col.label || col.displayName || colName;
if (colName) {
columnLabels![colName] = labelValue;
}
});
console.log(`✅ Fallback 컬럼 ${visibleColumns.length}개 로드 완료`);
}
}
} catch (fallbackError) {
console.error("❌ Fallback 컬럼 조회 실패:", fallbackError);
}
}
// 최종 안전장치: 여전히 컬럼 정보가 없으면 데이터의 키에서 시스템 컬럼만 제외
if ((!visibleColumns || visibleColumns.length === 0) && dataToExport.length > 0) {
console.log("⚠️ 최종 fallback: 데이터 키에서 시스템 컬럼 제외");
const allKeys = Object.keys(dataToExport[0]);
visibleColumns = allKeys.filter((key) => {
const lowerKey = key.toLowerCase();
// 시스템 컬럼 제외
if (SYSTEM_COLUMNS.includes(lowerKey)) return false;
// _name, _label 등 조인된 보조 필드 제외
if (lowerKey.endsWith("_name") || lowerKey.endsWith("_label") || lowerKey.endsWith("_value_label")) return false;
return true;
});
// 라벨이 없으므로 최소한 column_labels 비워두지 않음 (컬럼명 그대로 표시되지만 시스템 컬럼은 제외됨)
if (!columnLabels) {
columnLabels = {};
}
}
// 카테고리 값들 조회 (한 번만)
const categoryMap: Record<string, Record<string, string>> = {};
let categoryColumns: string[] = [];
// 백엔드에서 카테고리 컬럼 정보 가져오기
if (context.tableName) {
try {
const { getCategoryColumns, getCategoryValues } = await import("@/lib/api/tableCategoryValue");
@ -5053,7 +5144,7 @@ export class ButtonActionExecutor {
}
}
// 🎨 컬럼 필터링 및 라벨 적용 (항상 실행)
// 컬럼 필터링 및 라벨 적용
if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) {
dataToExport = dataToExport.map((row: any) => {
const filteredRow: Record<string, any> = {};
@ -5146,6 +5237,8 @@ export class ButtonActionExecutor {
? config.excelAfterUploadFlows
: config.masterDetailExcel?.afterUploadFlows;
// masterDetailExcel 설정이 명시적으로 있을 때만 간단 모드 (디테일만 업로드)
// 설정이 없으면 기본 모드 (마스터+디테일 둘 다 업로드)
if (config.masterDetailExcel) {
masterDetailExcelConfig = {
...config.masterDetailExcel,
@ -5154,25 +5247,13 @@ export class ButtonActionExecutor {
detailTable: relationResponse.data.detailTable,
masterKeyColumn: relationResponse.data.masterKeyColumn,
detailFkColumn: relationResponse.data.detailFkColumn,
// 채번 규칙 ID 추가 (excelNumberingRuleId를 numberingRuleId로 매핑)
numberingRuleId: config.masterDetailExcel.numberingRuleId || config.excelNumberingRuleId,
// 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선)
afterUploadFlows,
};
} else {
// 버튼 설정이 없으면 분할 패널 정보만 사용
masterDetailExcelConfig = {
masterTable: relationResponse.data.masterTable,
detailTable: relationResponse.data.detailTable,
masterKeyColumn: relationResponse.data.masterKeyColumn,
detailFkColumn: relationResponse.data.detailFkColumn,
simpleMode: true, // 기본값으로 간단 모드 사용
// 채번 규칙 ID 추가 (excelNumberingRuleId 사용)
numberingRuleId: config.excelNumberingRuleId,
// 채번은 ExcelUploadModal에서 마스터 테이블 기반 자동 감지
// 업로드 후 제어 설정 (통합: excelAfterUploadFlows 우선)
afterUploadFlows,
};
}
// masterDetailExcel 설정 없으면 masterDetailExcelConfig는 undefined 유지
// → ExcelUploadModal에서 기본 모드로 동작 (마스터+디테일 둘 다 매핑/업로드)
}
}
@ -5214,9 +5295,7 @@ export class ButtonActionExecutor {
isMasterDetail,
masterDetailRelation,
masterDetailExcelConfig,
// 🆕 단일 테이블 채번 설정
numberingRuleId: config.excelNumberingRuleId,
numberingTargetColumn: config.excelNumberingTargetColumn,
// 채번은 ExcelUploadModal에서 테이블 타입 관리 기반 자동 감지
// 🆕 업로드 후 제어 실행 설정
afterUploadFlows: config.excelAfterUploadFlows,
onSuccess: () => {

View File

@ -118,7 +118,7 @@ export interface ConditionNodeData {
field: string;
operator: ConditionOperator;
value: any;
valueType?: "static" | "field"; // 비교 값 타입
valueType?: "static" | "field" | "target"; // 비교 값 타입 (target: DB 기존값 비교)
// EXISTS_IN / NOT_EXISTS_IN 전용 필드
lookupTable?: string; // 조회할 테이블명
lookupTableLabel?: string; // 조회할 테이블 라벨
@ -127,6 +127,16 @@ export interface ConditionNodeData {
}>;
logic: "AND" | "OR";
displayName?: string;
// 타겟 테이블 조회 (DB 기존값과 비교할 때 사용)
targetLookup?: {
tableName: string;
tableLabel?: string;
lookupKeys: Array<{
sourceField: string; // 소스(폼) 데이터의 키 필드
targetField: string; // 타겟(DB) 테이블의 키 필드
sourceFieldLabel?: string;
}>;
};
}
// 필드 매핑 노드

View File

@ -878,7 +878,7 @@ export interface LayerOverlayConfig {
/**
*
* ,
* @deprecated Zone - ConditionalZone.x/y/width/height
*/
export interface DisplayRegion {
x: number;
@ -887,6 +887,27 @@ export interface DisplayRegion {
height: number;
}
/**
* (Zone)
* - ,
* - Zone 1 (exclusive)
* - Zone / (Y )
*/
export interface ConditionalZone {
zone_id: number;
screen_id: number;
company_code: string;
zone_name: string;
x: number;
y: number;
width: number;
height: number;
trigger_component_id: string | null; // 기본 레이어의 트리거 컴포넌트 ID
trigger_operator: string; // eq, neq, in
created_at?: string;
updated_at?: string;
}
/**
*
*/
@ -898,10 +919,14 @@ export interface LayerDefinition {
isVisible: boolean; // 초기 표시 여부
isLocked: boolean; // 편집 잠금 여부
// 조건부 표시 로직
// 조건부 표시 로직 (레거시 - Zone 미사용 레이어용)
condition?: LayerCondition;
// 조건부 레이어 표시 영역 (조건 미충족 시 이 영역이 사라짐)
// Zone 기반 조건부 설정 (신규)
zoneId?: number; // 소속 조건부 영역 ID
conditionValue?: string; // Zone 트리거 매칭 값
// 조건부 레이어 표시 영역 (레거시 호환 - Zone으로 대체됨)
displayRegion?: DisplayRegion;
// 모달/드로어 전용 설정

View File

@ -16,15 +16,12 @@ import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { exec } from "child_process";
import { promisify } from "util";
import { spawn } from "child_process";
import { platform } from "os";
import { AGENT_CONFIGS } from "./agents/prompts.js";
import { AgentType, ParallelResult } from "./agents/types.js";
import { logger } from "./utils/logger.js";
const execAsync = promisify(exec);
// OS 감지
const isWindows = platform() === "win32";
logger.info(`Platform detected: ${platform()} (isWindows: ${isWindows})`);
@ -43,12 +40,97 @@ const server = new Server(
);
/**
* Cursor Agent CLI를
* Cursor Team Plan - API !
* 유틸: ms만큼
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Cursor Agent CLI ()
* spawn + stdin
*/
function spawnAgentOnce(
agentType: AgentType,
fullPrompt: string,
model: string
): Promise<string> {
const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`;
return new Promise<string>((resolve, reject) => {
let stdout = '';
let stderr = '';
let settled = false;
const child = spawn(agentPath, ['--model', model, '--print'], {
cwd: process.cwd(),
env: {
...process.env,
PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}`,
},
stdio: ['pipe', 'pipe', 'pipe'],
});
child.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
child.on('error', (err: Error) => {
if (!settled) {
settled = true;
reject(err);
}
});
child.on('close', (code: number | null) => {
if (settled) return;
settled = true;
if (stderr) {
const significantStderr = stderr
.split('\n')
.filter((line: string) => line && !line.includes('warning') && !line.includes('info') && !line.includes('debug'))
.join('\n');
if (significantStderr) {
logger.warn(`${agentType} agent stderr`, { stderr: significantStderr.substring(0, 500) });
}
}
if (code === 0 || stdout.trim().length > 0) {
resolve(stdout.trim());
} else {
reject(new Error(
`Agent exited with code ${code}. stderr: ${stderr.substring(0, 1000)}`
));
}
});
// 타임아웃 (5분)
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
child.kill('SIGTERM');
reject(new Error(`${agentType} agent timed out after 5 minutes`));
}
}, 300000);
child.on('close', () => clearTimeout(timeout));
// stdin으로 프롬프트 직접 전달
child.stdin.write(fullPrompt);
child.stdin.end();
});
}
/**
* Cursor Agent CLI를 ( )
*
* :
* - Windows: cmd /c "echo. | agent ..." (stdin )
* - Mac/Linux: ~/.local/bin/agent
* - 2 ( 3 )
* - 2 (Cursor CLI )
*/
async function callAgentCLI(
agentType: AgentType,
@ -56,60 +138,43 @@ async function callAgentCLI(
context?: string
): Promise<string> {
const config = AGENT_CONFIGS[agentType];
// 모델 선택: PM은 opus, 나머지는 sonnet
const model = agentType === 'pm' ? 'opus-4.5' : 'sonnet-4.5';
logger.info(`Calling ${agentType} agent via CLI`, { model, task: task.substring(0, 100) });
const maxRetries = 2;
try {
const userMessage = context
? `${task}\n\n배경 정보:\n${context}`
: task;
// 프롬프트를 임시 파일에 저장하여 쉘 이스케이프 문제 회피
const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`;
// Base64 인코딩으로 특수문자 문제 해결
const encodedPrompt = Buffer.from(fullPrompt).toString('base64');
let cmd: string;
let shell: string;
const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`;
if (isWindows) {
// Windows: PowerShell을 통해 Base64 디코딩 후 실행
cmd = `powershell -Command "$prompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encodedPrompt}')); echo $prompt | ${agentPath} --model ${model} --print"`;
shell = 'powershell.exe';
} else {
// Mac/Linux: echo로 base64 디코딩 후 파이프
cmd = `echo "${encodedPrompt}" | base64 -d | ${agentPath} --model ${model} --print`;
shell = '/bin/bash';
logger.info(`Calling ${agentType} agent via CLI (spawn+retry)`, {
model,
task: task.substring(0, 100),
});
const userMessage = context
? `${task}\n\n배경 정보:\n${context}`
: task;
const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
const delay = attempt * 2000; // 2초, 4초
logger.info(`${agentType} agent retry ${attempt}/${maxRetries} (waiting ${delay}ms)`);
await sleep(delay);
}
const result = await spawnAgentOnce(agentType, fullPrompt, model);
logger.info(`${agentType} agent completed (attempt ${attempt + 1})`);
return result;
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
logger.warn(`${agentType} agent attempt ${attempt + 1} failed`, {
error: lastError.message.substring(0, 200),
});
}
logger.debug(`Executing: ${agentPath} --model ${model} --print`);
const { stdout, stderr } = await execAsync(cmd, {
cwd: process.cwd(),
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
timeout: 300000, // 5분 타임아웃
shell,
env: {
...process.env,
PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}`,
},
});
if (stderr && !stderr.includes('warning') && !stderr.includes('info')) {
logger.warn(`${agentType} agent stderr`, { stderr: stderr.substring(0, 500) });
}
logger.info(`${agentType} agent completed via CLI`);
return stdout.trim();
} catch (error) {
logger.error(`${agentType} agent CLI error`, error);
throw error;
}
// 모든 재시도 실패
logger.error(`${agentType} agent failed after ${maxRetries + 1} attempts`);
throw lastError!;
}
/**
@ -277,12 +342,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
}>;
};
logger.info(`Parallel ask to ${requests.length} agents (TRUE PARALLEL!)`);
logger.info(`Parallel ask to ${requests.length} agents (STAGGERED PARALLEL)`);
// 시차 병렬 실행: 각 에이전트를 500ms 간격으로 시작
// Cursor Agent CLI 동시 실행 제한 대응
const STAGGER_DELAY = 500; // ms
// 진짜 병렬 실행! 모든 에이전트가 동시에 작업
const results: ParallelResult[] = await Promise.all(
requests.map(async (req) => {
requests.map(async (req, index) => {
try {
// 시차 적용 (첫 번째는 즉시, 이후 500ms 간격)
if (index > 0) {
await sleep(index * STAGGER_DELAY);
}
const result = await callAgentCLI(req.agent, req.task, req.context);
return { agent: req.agent, result };
} catch (error) {