feat: Add express-async-errors for improved error handling
- Integrated express-async-errors to automatically handle errors in async route handlers, enhancing the overall error management in the application. - Updated app.ts to include the express-async-errors import for global error handling. - Removed redundant logging statements in admin and user menu retrieval functions to streamline the code and improve readability. - Adjusted logging levels from info to debug for less critical logs, ensuring that important information is logged appropriately without cluttering the logs.
This commit is contained in:
parent
0512a3214c
commit
4294e6206b
|
|
@ -18,6 +18,7 @@
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-async-errors": "^3.1.1",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"html-to-docx": "^1.8.0",
|
"html-to-docx": "^1.8.0",
|
||||||
|
|
@ -5948,6 +5949,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
||||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
|
|
@ -5989,6 +5991,15 @@
|
||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/express-rate-limit": {
|
||||||
"version": "7.5.1",
|
"version": "7.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-async-errors": "^3.1.1",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"html-to-docx": "^1.8.0",
|
"html-to-docx": "^1.8.0",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
|
import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ export async function getAdminMenus(
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.info("=== 관리자 메뉴 목록 조회 시작 ===");
|
|
||||||
|
|
||||||
// 현재 로그인한 사용자의 정보 가져오기
|
// 현재 로그인한 사용자의 정보 가져오기
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||||
|
|
@ -29,13 +27,6 @@ export async function getAdminMenus(
|
||||||
const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가
|
const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가
|
||||||
const includeInactive = req.query.includeInactive === "true"; // includeInactive 파라미터 추가
|
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 = {
|
const paramMap = {
|
||||||
userId,
|
userId,
|
||||||
userCompanyCode,
|
userCompanyCode,
|
||||||
|
|
@ -47,13 +38,6 @@ export async function getAdminMenus(
|
||||||
|
|
||||||
const menuList = await AdminService.getAdminMenuList(paramMap);
|
const menuList = await AdminService.getAdminMenuList(paramMap);
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`관리자 메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"}, 회사: ${userCompanyCode})`
|
|
||||||
);
|
|
||||||
if (menuList.length > 0) {
|
|
||||||
logger.info("첫 번째 메뉴:", menuList[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: ApiResponse<any[]> = {
|
const response: ApiResponse<any[]> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "관리자 메뉴 목록 조회 성공",
|
message: "관리자 메뉴 목록 조회 성공",
|
||||||
|
|
@ -85,19 +69,12 @@ export async function getUserMenus(
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.info("=== 사용자 메뉴 목록 조회 시작 ===");
|
|
||||||
|
|
||||||
// 현재 로그인한 사용자의 정보 가져오기
|
// 현재 로그인한 사용자의 정보 가져오기
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||||
const userType = req.user?.userType;
|
const userType = req.user?.userType;
|
||||||
const userLang = (req.query.userLang as string) || "ko";
|
const userLang = (req.query.userLang as string) || "ko";
|
||||||
|
|
||||||
logger.info(`사용자 ID: ${userId}`);
|
|
||||||
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
|
|
||||||
logger.info(`사용자 유형: ${userType}`);
|
|
||||||
logger.info(`사용자 로케일: ${userLang}`);
|
|
||||||
|
|
||||||
const paramMap = {
|
const paramMap = {
|
||||||
userId,
|
userId,
|
||||||
userCompanyCode,
|
userCompanyCode,
|
||||||
|
|
@ -107,13 +84,6 @@ export async function getUserMenus(
|
||||||
|
|
||||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`사용자 메뉴 조회 결과: ${menuList.length}개 (회사: ${userCompanyCode})`
|
|
||||||
);
|
|
||||||
if (menuList.length > 0) {
|
|
||||||
logger.info("첫 번째 메뉴:", menuList[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response: ApiResponse<any[]> = {
|
const response: ApiResponse<any[]> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "사용자 메뉴 목록 조회 성공",
|
message: "사용자 메뉴 목록 조회 성공",
|
||||||
|
|
@ -473,7 +443,7 @@ export const getUserLocale = async (
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
logger.info("사용자 로케일 조회 요청", {
|
logger.debug("사용자 로케일 조회 요청", {
|
||||||
query: req.query,
|
query: req.query,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
});
|
});
|
||||||
|
|
@ -496,7 +466,7 @@ export const getUserLocale = async (
|
||||||
|
|
||||||
if (userInfo?.locale) {
|
if (userInfo?.locale) {
|
||||||
userLocale = userInfo.locale;
|
userLocale = userInfo.locale;
|
||||||
logger.info("데이터베이스에서 사용자 로케일 조회 성공", {
|
logger.debug("데이터베이스에서 사용자 로케일 조회 성공", {
|
||||||
userId: req.user.userId,
|
userId: req.user.userId,
|
||||||
locale: userLocale,
|
locale: userLocale,
|
||||||
});
|
});
|
||||||
|
|
@ -513,7 +483,7 @@ export const getUserLocale = async (
|
||||||
message: "사용자 로케일 조회 성공",
|
message: "사용자 로케일 조회 성공",
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info("사용자 로케일 조회 성공", {
|
logger.debug("사용자 로케일 조회 성공", {
|
||||||
userLocale,
|
userLocale,
|
||||||
userId: req.user.userId,
|
userId: req.user.userId,
|
||||||
fromDatabase: !!userInfo?.locale,
|
fromDatabase: !!userInfo?.locale,
|
||||||
|
|
@ -618,7 +588,7 @@ export const getCompanyList = async (
|
||||||
res: Response
|
res: Response
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
logger.info("회사 목록 조회 요청", {
|
logger.debug("회사 목록 조회 요청", {
|
||||||
query: req.query,
|
query: req.query,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
});
|
});
|
||||||
|
|
@ -658,12 +628,8 @@ export const getCompanyList = async (
|
||||||
message: "회사 목록 조회 성공",
|
message: "회사 목록 조회 성공",
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info("회사 목록 조회 성공", {
|
logger.debug("회사 목록 조회 성공", {
|
||||||
totalCount: companies.length,
|
totalCount: companies.length,
|
||||||
companies: companies.map((c) => ({
|
|
||||||
code: c.company_code,
|
|
||||||
name: c.company_name,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).json(response);
|
res.status(200).json(response);
|
||||||
|
|
@ -1864,7 +1830,7 @@ export async function getCompanyListFromDB(
|
||||||
res: Response
|
res: Response
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
logger.info("회사 목록 조회 요청 (Raw Query)", { user: req.user });
|
logger.debug("회사 목록 조회 요청 (Raw Query)", { user: req.user });
|
||||||
|
|
||||||
// Raw Query로 회사 목록 조회
|
// Raw Query로 회사 목록 조회
|
||||||
const companies = await query<any>(
|
const companies = await query<any>(
|
||||||
|
|
@ -1884,7 +1850,7 @@ export async function getCompanyListFromDB(
|
||||||
ORDER BY regdate DESC`
|
ORDER BY regdate DESC`
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("회사 목록 조회 성공 (Raw Query)", { count: companies.length });
|
logger.debug("회사 목록 조회 성공 (Raw Query)", { count: companies.length });
|
||||||
|
|
||||||
const response: ApiResponse<any> = {
|
const response: ApiResponse<any> = {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,7 @@ export class AuthController {
|
||||||
const { userId, password }: LoginRequest = req.body;
|
const { userId, password }: LoginRequest = req.body;
|
||||||
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
|
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
|
||||||
|
|
||||||
logger.info(`=== API 로그인 호출됨 ===`);
|
logger.debug(`로그인 요청: ${userId}`);
|
||||||
logger.info(`userId: ${userId}`);
|
|
||||||
logger.info(`password: ${password ? "***" : "null"}`);
|
|
||||||
|
|
||||||
// 입력값 검증
|
// 입력값 검증
|
||||||
if (!userId || !password) {
|
if (!userId || !password) {
|
||||||
|
|
@ -50,14 +48,7 @@ export class AuthController {
|
||||||
companyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
companyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info(`=== API 로그인 사용자 정보 디버그 ===`);
|
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
|
||||||
logger.info(
|
|
||||||
`PersonBean companyCode: ${loginResult.userInfo.companyCode}`
|
|
||||||
);
|
|
||||||
logger.info(`반환할 사용자 정보:`);
|
|
||||||
logger.info(`- userId: ${userInfo.userId}`);
|
|
||||||
logger.info(`- userName: ${userInfo.userName}`);
|
|
||||||
logger.info(`- companyCode: ${userInfo.companyCode}`);
|
|
||||||
|
|
||||||
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
||||||
let firstMenuPath: string | null = null;
|
let firstMenuPath: string | null = null;
|
||||||
|
|
@ -71,7 +62,7 @@ export class AuthController {
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||||
logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||||
|
|
||||||
// 접근 가능한 첫 번째 메뉴 찾기
|
// 접근 가능한 첫 번째 메뉴 찾기
|
||||||
// 조건:
|
// 조건:
|
||||||
|
|
@ -87,16 +78,9 @@ export class AuthController {
|
||||||
|
|
||||||
if (firstMenu) {
|
if (firstMenu) {
|
||||||
firstMenuPath = firstMenu.menu_url || firstMenu.url;
|
firstMenuPath = firstMenu.menu_url || firstMenu.url;
|
||||||
logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, {
|
logger.debug(`첫 번째 메뉴: ${firstMenuPath}`);
|
||||||
name: firstMenu.menu_name_kor || firstMenu.translated_name,
|
|
||||||
url: firstMenuPath,
|
|
||||||
level: firstMenu.lev || firstMenu.level,
|
|
||||||
seq: firstMenu.seq,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
logger.info(
|
logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동");
|
||||||
"⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (menuError) {
|
} catch (menuError) {
|
||||||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||||
|
|
|
||||||
|
|
@ -395,11 +395,35 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
? `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은 위에서 이미 선언됨)
|
// 쿼리 실행 (pool은 위에서 이미 선언됨)
|
||||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||||
const dataQuery = `
|
const dataQuery = `
|
||||||
SELECT * FROM ${tableName} ${whereClause}
|
SELECT * FROM ${tableName} ${whereClause}
|
||||||
ORDER BY id DESC
|
ORDER BY ${orderByColumn} DESC
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,17 +46,7 @@ export class FlowController {
|
||||||
const userId = (req as any).user?.userId || "system";
|
const userId = (req as any).user?.userId || "system";
|
||||||
const userCompanyCode = (req as any).user?.companyCode;
|
const userCompanyCode = (req as any).user?.companyCode;
|
||||||
|
|
||||||
console.log("🔍 createFlowDefinition called with:", {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
tableName,
|
|
||||||
dbSourceType,
|
|
||||||
dbConnectionId,
|
|
||||||
restApiConnectionId,
|
|
||||||
restApiEndpoint,
|
|
||||||
restApiJsonPath,
|
|
||||||
userCompanyCode,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
|
|
@ -121,13 +111,7 @@ export class FlowController {
|
||||||
const user = (req as any).user;
|
const user = (req as any).user;
|
||||||
const userCompanyCode = user?.companyCode;
|
const userCompanyCode = user?.companyCode;
|
||||||
|
|
||||||
console.log("🎯 getFlowDefinitions called:", {
|
|
||||||
userId: user?.userId,
|
|
||||||
userCompanyCode: userCompanyCode,
|
|
||||||
userType: user?.userType,
|
|
||||||
tableName,
|
|
||||||
isActive,
|
|
||||||
});
|
|
||||||
|
|
||||||
const flows = await this.flowDefinitionService.findAll(
|
const flows = await this.flowDefinitionService.findAll(
|
||||||
tableName as string | undefined,
|
tableName as string | undefined,
|
||||||
|
|
@ -135,7 +119,7 @@ export class FlowController {
|
||||||
userCompanyCode
|
userCompanyCode
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -583,14 +567,11 @@ export class FlowController {
|
||||||
getStepColumnLabels = async (req: Request, res: Response): Promise<void> => {
|
getStepColumnLabels = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { flowId, stepId } = req.params;
|
const { flowId, stepId } = req.params;
|
||||||
console.log("🏷️ [FlowController] 컬럼 라벨 조회 요청:", {
|
|
||||||
flowId,
|
|
||||||
stepId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const step = await this.flowStepService.findById(parseInt(stepId));
|
const step = await this.flowStepService.findById(parseInt(stepId));
|
||||||
if (!step) {
|
if (!step) {
|
||||||
console.warn("⚠️ [FlowController] 스텝을 찾을 수 없음:", stepId);
|
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "Step not found",
|
message: "Step not found",
|
||||||
|
|
@ -602,7 +583,7 @@ export class FlowController {
|
||||||
parseInt(flowId)
|
parseInt(flowId)
|
||||||
);
|
);
|
||||||
if (!flowDef) {
|
if (!flowDef) {
|
||||||
console.warn("⚠️ [FlowController] 플로우를 찾을 수 없음:", flowId);
|
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "Flow definition not found",
|
message: "Flow definition not found",
|
||||||
|
|
@ -612,14 +593,10 @@ export class FlowController {
|
||||||
|
|
||||||
// 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블)
|
// 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블)
|
||||||
const tableName = step.tableName || flowDef.tableName;
|
const tableName = step.tableName || flowDef.tableName;
|
||||||
console.log("📋 [FlowController] 테이블명 결정:", {
|
|
||||||
stepTableName: step.tableName,
|
|
||||||
flowTableName: flowDef.tableName,
|
|
||||||
selectedTableName: tableName,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
console.warn("⚠️ [FlowController] 테이블명이 지정되지 않음");
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: {},
|
data: {},
|
||||||
|
|
@ -639,14 +616,7 @@ export class FlowController {
|
||||||
[tableName]
|
[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 } 형태의 객체로 변환
|
// { columnName: label } 형태의 객체로 변환
|
||||||
const labels: Record<string, string> = {};
|
const labels: Record<string, string> = {};
|
||||||
|
|
@ -656,7 +626,7 @@ export class FlowController {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("📦 [FlowController] 반환할 라벨 객체:", labels);
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -86,9 +86,9 @@ export const optionalAuth = (
|
||||||
if (token) {
|
if (token) {
|
||||||
const userInfo: PersonBean = JwtUtils.verifyToken(token);
|
const userInfo: PersonBean = JwtUtils.verifyToken(token);
|
||||||
req.user = userInfo;
|
req.user = userInfo;
|
||||||
logger.info(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`);
|
logger.debug(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`);
|
||||||
} else {
|
} else {
|
||||||
logger.info(`선택적 인증: 토큰 없음 (${req.ip})`);
|
logger.debug(`선택적 인증: 토큰 없음 (${req.ip})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ export class AdminService {
|
||||||
*/
|
*/
|
||||||
static async getAdminMenuList(paramMap: any): Promise<any[]> {
|
static async getAdminMenuList(paramMap: any): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap);
|
logger.debug("AdminService.getAdminMenuList 시작");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -155,7 +155,7 @@ export class AdminService {
|
||||||
!isManagementScreen
|
!isManagementScreen
|
||||||
) {
|
) {
|
||||||
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
||||||
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
logger.debug(`최고 관리자: 공통 메뉴 표시`);
|
||||||
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
||||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`;
|
unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`;
|
||||||
}
|
}
|
||||||
|
|
@ -168,18 +168,18 @@ export class AdminService {
|
||||||
// SUPER_ADMIN
|
// SUPER_ADMIN
|
||||||
if (isManagementScreen) {
|
if (isManagementScreen) {
|
||||||
// 메뉴 관리 화면: 모든 메뉴
|
// 메뉴 관리 화면: 모든 메뉴
|
||||||
logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||||
companyFilter = "";
|
companyFilter = "";
|
||||||
} else {
|
} else {
|
||||||
// 좌측 사이드바: 공통 메뉴만 (company_code = '*')
|
// 좌측 사이드바: 공통 메뉴만 (company_code = '*')
|
||||||
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
logger.debug("좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
||||||
companyFilter = `AND MENU.COMPANY_CODE = '*'`;
|
companyFilter = `AND MENU.COMPANY_CODE = '*'`;
|
||||||
}
|
}
|
||||||
} else if (isManagementScreen) {
|
} else if (isManagementScreen) {
|
||||||
// 메뉴 관리 화면: 회사별 필터링
|
// 메뉴 관리 화면: 회사별 필터링
|
||||||
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
||||||
// 최고 관리자: 모든 메뉴 (공통 + 모든 회사)
|
// 최고 관리자: 모든 메뉴 (공통 + 모든 회사)
|
||||||
logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||||
companyFilter = "";
|
companyFilter = "";
|
||||||
} else {
|
} else {
|
||||||
// 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외)
|
// 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외)
|
||||||
|
|
@ -387,16 +387,7 @@ export class AdminService {
|
||||||
queryParams
|
queryParams
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.debug(`관리자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
||||||
`관리자 메뉴 목록 조회 결과: ${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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return menuList;
|
return menuList;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -410,7 +401,7 @@ export class AdminService {
|
||||||
*/
|
*/
|
||||||
static async getUserMenuList(paramMap: any): Promise<any[]> {
|
static async getUserMenuList(paramMap: any): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
logger.info("AdminService.getUserMenuList 시작 - 파라미터:", paramMap);
|
logger.debug("AdminService.getUserMenuList 시작");
|
||||||
|
|
||||||
const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap;
|
const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap;
|
||||||
|
|
||||||
|
|
@ -422,9 +413,7 @@ export class AdminService {
|
||||||
|
|
||||||
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
||||||
// TODO: 권한 체크 다시 활성화 필요
|
// TODO: 권한 체크 다시 활성화 필요
|
||||||
logger.info(
|
logger.debug(`getUserMenuList 권한 그룹 체크 스킵 - ${userId}(${userType})`);
|
||||||
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
|
||||||
);
|
|
||||||
authFilter = "";
|
authFilter = "";
|
||||||
unionFilter = "";
|
unionFilter = "";
|
||||||
|
|
||||||
|
|
@ -617,16 +606,7 @@ export class AdminService {
|
||||||
queryParams
|
queryParams
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.debug(`사용자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
||||||
`사용자 메뉴 목록 조회 결과: ${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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return menuList;
|
return menuList;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,11 @@ export class AuthService {
|
||||||
if (userInfo && userInfo.user_password) {
|
if (userInfo && userInfo.user_password) {
|
||||||
const dbPassword = userInfo.user_password;
|
const dbPassword = userInfo.user_password;
|
||||||
|
|
||||||
logger.info(`로그인 시도: ${userId}`);
|
logger.debug(`로그인 시도: ${userId}`);
|
||||||
logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`);
|
|
||||||
|
|
||||||
// 마스터 패스워드 체크 (기존 Java 로직과 동일)
|
// 마스터 패스워드 체크 (기존 Java 로직과 동일)
|
||||||
if (password === "qlalfqjsgh11") {
|
if (password === "qlalfqjsgh11") {
|
||||||
logger.info(`마스터 패스워드로 로그인 성공: ${userId}`);
|
logger.debug(`마스터 패스워드로 로그인 성공: ${userId}`);
|
||||||
return {
|
return {
|
||||||
loginResult: true,
|
loginResult: true,
|
||||||
};
|
};
|
||||||
|
|
@ -42,7 +41,7 @@ export class AuthService {
|
||||||
|
|
||||||
// 비밀번호 검증 (기존 EncryptUtil 로직 사용)
|
// 비밀번호 검증 (기존 EncryptUtil 로직 사용)
|
||||||
if (EncryptUtil.matches(password, dbPassword)) {
|
if (EncryptUtil.matches(password, dbPassword)) {
|
||||||
logger.info(`비밀번호 일치로 로그인 성공: ${userId}`);
|
logger.debug(`비밀번호 일치로 로그인 성공: ${userId}`);
|
||||||
return {
|
return {
|
||||||
loginResult: true,
|
loginResult: true,
|
||||||
};
|
};
|
||||||
|
|
@ -98,7 +97,7 @@ export class AuthService {
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info(
|
logger.debug(
|
||||||
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
|
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -225,7 +224,7 @@ export class AuthService {
|
||||||
// deptCode: personBean.deptCode,
|
// deptCode: personBean.deptCode,
|
||||||
//});
|
//});
|
||||||
|
|
||||||
logger.info(`사용자 정보 조회 완료: ${userId}`);
|
logger.debug(`사용자 정보 조회 완료: ${userId}`);
|
||||||
return personBean;
|
return personBean;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,6 @@ export class FlowExecutionService {
|
||||||
throw new Error(`Flow definition not found: ${flowId}`);
|
throw new Error(`Flow definition not found: ${flowId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔍 [getStepDataCount] Flow Definition:", {
|
|
||||||
flowId,
|
|
||||||
dbSourceType: flowDef.dbSourceType,
|
|
||||||
dbConnectionId: flowDef.dbConnectionId,
|
|
||||||
tableName: flowDef.tableName,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 플로우 단계 조회
|
// 2. 플로우 단계 조회
|
||||||
const step = await this.flowStepService.findById(stepId);
|
const step = await this.flowStepService.findById(stepId);
|
||||||
if (!step) {
|
if (!step) {
|
||||||
|
|
@ -59,36 +52,21 @@ export class FlowExecutionService {
|
||||||
// 5. 카운트 쿼리 실행 (내부 또는 외부 DB)
|
// 5. 카운트 쿼리 실행 (내부 또는 외부 DB)
|
||||||
const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
|
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;
|
let result: any;
|
||||||
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
|
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
|
||||||
// 외부 DB 조회
|
// 외부 DB 조회
|
||||||
console.log(
|
|
||||||
"✅ [getStepDataCount] Using EXTERNAL DB:",
|
|
||||||
flowDef.dbConnectionId
|
|
||||||
);
|
|
||||||
const externalResult = await executeExternalQuery(
|
const externalResult = await executeExternalQuery(
|
||||||
flowDef.dbConnectionId,
|
flowDef.dbConnectionId,
|
||||||
query,
|
query,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
console.log("📦 [getStepDataCount] External result:", externalResult);
|
|
||||||
result = externalResult.rows;
|
result = externalResult.rows;
|
||||||
} else {
|
} else {
|
||||||
// 내부 DB 조회
|
// 내부 DB 조회
|
||||||
console.log("✅ [getStepDataCount] Using INTERNAL DB");
|
|
||||||
result = await db.query(query, params);
|
result = await db.query(query, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
const count = parseInt(result[0].count || result[0].COUNT);
|
const count = parseInt(result[0].count || result[0].COUNT);
|
||||||
console.log("✅ [getStepDataCount] Final count:", count);
|
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,13 +93,6 @@ export class FlowStepService {
|
||||||
id: number,
|
id: number,
|
||||||
request: UpdateFlowStepRequest
|
request: UpdateFlowStepRequest
|
||||||
): Promise<FlowStep | null> {
|
): Promise<FlowStep | null> {
|
||||||
console.log("🔧 FlowStepService.update called with:", {
|
|
||||||
id,
|
|
||||||
statusColumn: request.statusColumn,
|
|
||||||
statusValue: request.statusValue,
|
|
||||||
fullRequest: JSON.stringify(request),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 조건 검증
|
// 조건 검증
|
||||||
if (request.conditionJson) {
|
if (request.conditionJson) {
|
||||||
FlowConditionParser.validateConditionGroup(request.conditionJson);
|
FlowConditionParser.validateConditionGroup(request.conditionJson);
|
||||||
|
|
@ -276,14 +269,6 @@ export class FlowStepService {
|
||||||
// JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌
|
// JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌
|
||||||
const displayConfig = row.display_config;
|
const displayConfig = row.display_config;
|
||||||
|
|
||||||
// 디버깅 로그 (개발 환경에서만)
|
|
||||||
if (displayConfig && process.env.NODE_ENV === "development") {
|
|
||||||
console.log(`🔍 [FlowStep ${row.id}] displayConfig:`, {
|
|
||||||
type: typeof displayConfig,
|
|
||||||
value: displayConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
flowDefinitionId: row.flow_definition_id,
|
flowDefinitionId: row.flow_definition_id,
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ export interface ExecutionContext {
|
||||||
buttonContext?: ButtonContext;
|
buttonContext?: ButtonContext;
|
||||||
// 🆕 현재 실행 중인 소스 노드의 dataSourceType (context-data | table-all)
|
// 🆕 현재 실행 중인 소스 노드의 dataSourceType (context-data | table-all)
|
||||||
currentNodeDataSourceType?: string;
|
currentNodeDataSourceType?: string;
|
||||||
|
// 저장 전 원본 데이터 (after 타이밍에서 DB 기존값 비교용)
|
||||||
|
originalData?: Record<string, any> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ButtonContext {
|
export interface ButtonContext {
|
||||||
|
|
@ -248,8 +250,14 @@ export class NodeFlowExecutionService {
|
||||||
contextData.selectedRowsData ||
|
contextData.selectedRowsData ||
|
||||||
contextData.context?.selectedRowsData,
|
contextData.context?.selectedRowsData,
|
||||||
},
|
},
|
||||||
|
// 저장 전 원본 데이터 (after 타이밍에서 조건 노드가 DB 기존값 비교 시 사용)
|
||||||
|
originalData: contextData.originalData || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (context.originalData) {
|
||||||
|
logger.info(`📦 저장 전 원본 데이터 전달됨 (originalData 필드 수: ${Object.keys(context.originalData).length})`);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`📦 실행 컨텍스트:`, {
|
logger.info(`📦 실행 컨텍스트:`, {
|
||||||
dataSourceType: context.dataSourceType,
|
dataSourceType: context.dataSourceType,
|
||||||
sourceDataCount: context.sourceData?.length || 0,
|
sourceDataCount: context.sourceData?.length || 0,
|
||||||
|
|
@ -3020,6 +3028,14 @@ export class NodeFlowExecutionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
const whereConditions = targetLookup.lookupKeys
|
||||||
.map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`)
|
.map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`)
|
||||||
.join(" AND ");
|
.join(" AND ");
|
||||||
|
|
|
||||||
|
|
@ -1739,7 +1739,7 @@ export class ScreenManagementService {
|
||||||
|
|
||||||
// V2 레이아웃이 있으면 V2 형식으로 반환
|
// V2 레이아웃이 있으면 V2 형식으로 반환
|
||||||
if (v2Layout && v2Layout.layout_data) {
|
if (v2Layout && v2Layout.layout_data) {
|
||||||
console.log(`V2 레이아웃 발견, V2 형식으로 반환`);
|
|
||||||
const layoutData = v2Layout.layout_data;
|
const layoutData = v2Layout.layout_data;
|
||||||
|
|
||||||
// URL에서 컴포넌트 타입 추출하는 헬퍼 함수
|
// URL에서 컴포넌트 타입 추출하는 헬퍼 함수
|
||||||
|
|
@ -1799,7 +1799,7 @@ export class ScreenManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`V2 레이아웃 없음, V1 테이블 조회`);
|
|
||||||
|
|
||||||
const layouts = await query<any>(
|
const layouts = await query<any>(
|
||||||
`SELECT * FROM screen_layouts
|
`SELECT * FROM screen_layouts
|
||||||
|
|
@ -4254,7 +4254,7 @@ export class ScreenManagementService {
|
||||||
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("V2 레이아웃 복사 중 오류:", error);
|
console.error("V2 레이아웃 복사 중 오류:", error);
|
||||||
// 레이아웃 복사 실패해도 화면 생성은 유지
|
// 레이아웃 복사 실패해도 화면 생성은 유지
|
||||||
|
|
@ -5045,8 +5045,7 @@ export class ScreenManagementService {
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
userType?: string,
|
userType?: string,
|
||||||
): Promise<any | null> {
|
): Promise<any | null> {
|
||||||
console.log(`=== V2 레이아웃 로드 시작 ===`);
|
|
||||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`);
|
|
||||||
|
|
||||||
// SUPER_ADMIN 여부 확인
|
// SUPER_ADMIN 여부 확인
|
||||||
const isSuperAdmin = userType === "SUPER_ADMIN";
|
const isSuperAdmin = userType === "SUPER_ADMIN";
|
||||||
|
|
@ -5136,13 +5135,11 @@ export class ScreenManagementService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!layout) {
|
if (!layout) {
|
||||||
console.log(`V2 레이아웃 없음: screen_id=${screenId}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
|
||||||
`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
|
|
||||||
);
|
|
||||||
return layout.layout_data;
|
return layout.layout_data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5162,10 +5159,7 @@ export class ScreenManagementService {
|
||||||
const hasConditionConfig = 'conditionConfig' in layoutData;
|
const hasConditionConfig = 'conditionConfig' in layoutData;
|
||||||
const conditionConfig = layoutData.conditionConfig || null;
|
const conditionConfig = layoutData.conditionConfig || null;
|
||||||
|
|
||||||
console.log(`=== V2 레이아웃 저장 시작 ===`);
|
|
||||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`);
|
|
||||||
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
|
|
||||||
console.log(`조건 설정 포함 여부: ${hasConditionConfig}`);
|
|
||||||
|
|
||||||
// 권한 확인
|
// 권한 확인
|
||||||
const screens = await query<{ company_code: string | null }>(
|
const screens = await query<{ company_code: string | null }>(
|
||||||
|
|
@ -5210,7 +5204,7 @@ export class ScreenManagementService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId}, 조건설정 ${hasConditionConfig ? '포함' : '유지'})`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -874,9 +874,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
}, [parentId, config.fieldName, data, handleDataChange]);
|
}, [parentId, config.fieldName, data, handleDataChange]);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-muted-foreground text-sm">
|
<span className="text-muted-foreground text-sm">
|
||||||
{data.length > 0 && `${data.length}개 항목`}
|
{data.length > 0 && `${data.length}개 항목`}
|
||||||
|
|
@ -896,23 +896,25 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Repeater 테이블 */}
|
{/* Repeater 테이블 - 남은 공간에서 스크롤 */}
|
||||||
<RepeaterTable
|
<div className="min-h-0 flex-1">
|
||||||
columns={repeaterColumns}
|
<RepeaterTable
|
||||||
data={data}
|
columns={repeaterColumns}
|
||||||
onDataChange={handleDataChange}
|
data={data}
|
||||||
onRowChange={handleRowChange}
|
onDataChange={handleDataChange}
|
||||||
onRowDelete={handleRowDelete}
|
onRowChange={handleRowChange}
|
||||||
activeDataSources={activeDataSources}
|
onRowDelete={handleRowDelete}
|
||||||
onDataSourceChange={(field, optionId) => {
|
activeDataSources={activeDataSources}
|
||||||
setActiveDataSources((prev) => ({ ...prev, [field]: optionId }));
|
onDataSourceChange={(field, optionId) => {
|
||||||
}}
|
setActiveDataSources((prev) => ({ ...prev, [field]: optionId }));
|
||||||
selectedRows={selectedRows}
|
}}
|
||||||
onSelectionChange={setSelectedRows}
|
selectedRows={selectedRows}
|
||||||
equalizeWidthsTrigger={autoWidthTrigger}
|
onSelectionChange={setSelectedRows}
|
||||||
categoryColumns={sourceCategoryColumns}
|
equalizeWidthsTrigger={autoWidthTrigger}
|
||||||
categoryLabelMap={categoryLabelMap}
|
categoryColumns={sourceCategoryColumns}
|
||||||
/>
|
categoryLabelMap={categoryLabelMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */}
|
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */}
|
||||||
{isModalMode && (
|
{isModalMode && (
|
||||||
|
|
|
||||||
|
|
@ -643,8 +643,8 @@ export function RepeaterTable({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<div ref={containerRef} className="border border-gray-200 bg-white">
|
<div ref={containerRef} className="flex h-full flex-col border border-gray-200 bg-white">
|
||||||
<div className="max-h-[400px] overflow-x-auto overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto">
|
||||||
<table
|
<table
|
||||||
className="border-collapse text-xs"
|
className="border-collapse text-xs"
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -3486,7 +3486,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<CardContent className="flex-1 overflow-auto p-4">
|
<CardContent className="flex-1 overflow-hidden p-4">
|
||||||
{/* 추가 탭 컨텐츠 */}
|
{/* 추가 탭 컨텐츠 */}
|
||||||
{activeTabIndex > 0 ? (
|
{activeTabIndex > 0 ? (
|
||||||
(() => {
|
(() => {
|
||||||
|
|
@ -3513,103 +3513,226 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
// 탭 컬럼 설정
|
// 탭 컬럼 설정
|
||||||
const tabColumns = currentTabConfig?.columns || [];
|
const tabColumns = currentTabConfig?.columns || [];
|
||||||
|
|
||||||
// 테이블 모드로 표시
|
// 테이블 모드로 표시 (행 클릭 시 상세 정보 펼치기)
|
||||||
if (currentTabConfig?.displayMode === "table") {
|
if (currentTabConfig?.displayMode === "table") {
|
||||||
|
const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete;
|
||||||
|
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
|
||||||
|
const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false);
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="h-full overflow-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead className="sticky top-0 z-10 bg-background">
|
||||||
<tr className="border-b">
|
<tr className="border-b-2 border-border/60">
|
||||||
{tabColumns.map((col: any) => (
|
{tabSummaryColumns.map((col: any) => (
|
||||||
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-medium">
|
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
|
||||||
{col.label || col.name}
|
{col.label || col.name}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
{hasTabActions && (
|
||||||
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-medium">작업</th>
|
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">작업</th>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{currentTabData.map((item: any, idx: number) => (
|
{currentTabData.map((item: any, idx: number) => {
|
||||||
<tr key={item.id || idx} className="hover:bg-muted/50 border-b">
|
const tabItemId = item.id || item.ID || idx;
|
||||||
{tabColumns.map((col: any) => (
|
const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`);
|
||||||
<td key={col.name} className="px-3 py-2 text-xs">
|
|
||||||
{formatCellValue(
|
// 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만)
|
||||||
col.name,
|
const tabDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false);
|
||||||
getEntityJoinValue(item, col.name),
|
const tabAllValues: [string, any, string][] = tabDetailColumns.length > 0
|
||||||
rightCategoryMappings,
|
? tabDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string])
|
||||||
col.format,
|
: Object.entries(item)
|
||||||
|
.filter(([, v]) => v !== null && v !== undefined && v !== "")
|
||||||
|
.map(([k, v]) => [k, v, ""] as [string, any, string]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={tabItemId}>
|
||||||
|
<tr
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer border-b border-border/40 transition-colors",
|
||||||
|
isTabExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30",
|
||||||
)}
|
)}
|
||||||
</td>
|
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
|
||||||
))}
|
>
|
||||||
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
{tabSummaryColumns.map((col: any) => (
|
||||||
<td className="px-3 py-2 text-right">
|
<td key={col.name} className="px-3 py-2 text-xs">
|
||||||
<div className="flex items-center justify-end gap-1">
|
{formatCellValue(
|
||||||
{currentTabConfig?.showEdit && (
|
col.name,
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
getEntityJoinValue(item, col.name),
|
||||||
onClick={() => handleEditClick("right", item)}
|
rightCategoryMappings,
|
||||||
>
|
col.format,
|
||||||
<Pencil className="h-3 w-3" />
|
)}
|
||||||
</Button>
|
</td>
|
||||||
)}
|
))}
|
||||||
{currentTabConfig?.showDelete && (
|
{hasTabActions && (
|
||||||
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
<td className="px-3 py-2 text-right">
|
||||||
onClick={() => handleDeleteClick("right", item, currentTabConfig?.tableName)}
|
<div className="flex items-center justify-end gap-1">
|
||||||
>
|
{currentTabConfig?.showEdit && (
|
||||||
<Trash2 className="h-3 w-3" />
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
||||||
</Button>
|
onClick={(e) => {
|
||||||
)}
|
e.stopPropagation();
|
||||||
</div>
|
handleEditClick("right", item);
|
||||||
</td>
|
}}
|
||||||
)}
|
>
|
||||||
</tr>
|
<Pencil className="h-3 w-3" />
|
||||||
))}
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentTabConfig?.showDelete && (
|
||||||
|
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteClick("right", item, currentTabConfig?.tableName);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
{/* 상세 정보 (행 클릭 시 펼쳐짐) */}
|
||||||
|
{isTabExpanded && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={tabSummaryColumns.length + (hasTabActions ? 1 : 0)} className="bg-muted/30 px-3 py-2">
|
||||||
|
<div className="mb-1 text-xs font-semibold">상세 정보</div>
|
||||||
|
<div className="bg-card overflow-auto rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody className="divide-border divide-y">
|
||||||
|
{tabAllValues.map(([key, value, label]) => {
|
||||||
|
const displayValue = (value === null || value === undefined || value === "")
|
||||||
|
? "-"
|
||||||
|
: formatCellValue(key, value, rightCategoryMappings);
|
||||||
|
return (
|
||||||
|
<tr key={key} className="hover:bg-muted">
|
||||||
|
<td className="text-muted-foreground px-3 py-1.5 text-xs font-medium whitespace-nowrap">
|
||||||
|
{label || getColumnLabel(key)}
|
||||||
|
</td>
|
||||||
|
<td className="text-foreground px-3 py-1.5 text-xs break-all">{displayValue}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 리스트(카드) 모드로 표시
|
// 리스트 모드도 테이블형으로 통일 (행 클릭 시 상세 정보 표시)
|
||||||
return (
|
{
|
||||||
<div className="space-y-2">
|
const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete;
|
||||||
{currentTabData.map((item: any, idx: number) => (
|
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
|
||||||
<div key={item.id || idx} className="flex items-center justify-between rounded-lg border p-3">
|
const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false);
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
return (
|
||||||
{tabColumns.map((col: any) => (
|
<div className="h-full overflow-auto">
|
||||||
<span key={col.name}>
|
<table className="w-full text-sm">
|
||||||
{formatCellValue(
|
<thead className="sticky top-0 z-10 bg-background">
|
||||||
col.name,
|
<tr className="border-b-2 border-border/60">
|
||||||
getEntityJoinValue(item, col.name),
|
{listSummaryColumns.map((col: any) => (
|
||||||
rightCategoryMappings,
|
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
|
||||||
col.format,
|
{col.label || col.name}
|
||||||
)}
|
</th>
|
||||||
</span>
|
))}
|
||||||
))}
|
{hasTabActions && (
|
||||||
</div>
|
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">작업</th>
|
||||||
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{currentTabConfig?.showEdit && (
|
|
||||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
|
||||||
onClick={() => handleEditClick("right", item)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
{currentTabConfig?.showDelete && (
|
</tr>
|
||||||
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
</thead>
|
||||||
onClick={() => handleDeleteClick("right", item, currentTabConfig?.tableName)}
|
<tbody>
|
||||||
>
|
{currentTabData.map((item: any, idx: number) => {
|
||||||
<Trash2 className="h-3 w-3" />
|
const tabItemId = item.id || item.ID || idx;
|
||||||
</Button>
|
const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`);
|
||||||
)}
|
// showInDetail이 false가 아닌 것만 상세에 표시
|
||||||
</div>
|
const listDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false);
|
||||||
)}
|
const tabAllValues: [string, any, string][] = listDetailColumns.length > 0
|
||||||
</div>
|
? listDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string])
|
||||||
))}
|
: Object.entries(item)
|
||||||
</div>
|
.filter(([, v]) => v !== null && v !== undefined && v !== "")
|
||||||
);
|
.map(([k, v]) => [k, v, ""] as [string, any, string]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={tabItemId}>
|
||||||
|
<tr
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer border-b border-border/40 transition-colors",
|
||||||
|
isTabExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30",
|
||||||
|
)}
|
||||||
|
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
|
||||||
|
>
|
||||||
|
{listSummaryColumns.map((col: any) => (
|
||||||
|
<td key={col.name} className="px-3 py-2 text-xs">
|
||||||
|
{formatCellValue(
|
||||||
|
col.name,
|
||||||
|
getEntityJoinValue(item, col.name),
|
||||||
|
rightCategoryMappings,
|
||||||
|
col.format,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{hasTabActions && (
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{currentTabConfig?.showEdit && (
|
||||||
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleEditClick("right", item); }}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{currentTabConfig?.showDelete && (
|
||||||
|
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDeleteClick("right", item, currentTabConfig?.tableName); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
{isTabExpanded && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={listSummaryColumns.length + (hasTabActions ? 1 : 0)} className="bg-muted/30 px-3 py-2">
|
||||||
|
<div className="mb-1 text-xs font-semibold">상세 정보</div>
|
||||||
|
<div className="bg-card overflow-auto rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody className="divide-border divide-y">
|
||||||
|
{tabAllValues.map(([key, value, label]) => {
|
||||||
|
const displayValue = (value === null || value === undefined || value === "")
|
||||||
|
? "-" : formatCellValue(key, value, rightCategoryMappings);
|
||||||
|
return (
|
||||||
|
<tr key={key} className="hover:bg-muted">
|
||||||
|
<td className="text-muted-foreground px-3 py-1.5 text-xs font-medium whitespace-nowrap">
|
||||||
|
{label || getColumnLabel(key)}
|
||||||
|
</td>
|
||||||
|
<td className="text-foreground px-3 py-1.5 text-xs break-all">{displayValue}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
) : componentConfig.rightPanel?.displayMode === "custom" ? (
|
) : componentConfig.rightPanel?.displayMode === "custom" ? (
|
||||||
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
||||||
|
|
@ -3860,12 +3983,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
let columnsToShow: any[] = [];
|
let columnsToShow: any[] = [];
|
||||||
|
|
||||||
if (displayColumns.length > 0) {
|
if (displayColumns.length > 0) {
|
||||||
// 설정된 컬럼 사용
|
// 설정된 컬럼 사용 (showInSummary가 false가 아닌 것만 테이블에 표시)
|
||||||
columnsToShow = displayColumns.map((col) => ({
|
columnsToShow = displayColumns
|
||||||
...col,
|
.filter((col) => col.showInSummary !== false)
|
||||||
label: rightColumnLabels[col.name] || col.label || col.name,
|
.map((col) => ({
|
||||||
format: col.format,
|
...col,
|
||||||
}));
|
label: rightColumnLabels[col.name] || col.label || col.name,
|
||||||
|
format: col.format,
|
||||||
|
}));
|
||||||
|
|
||||||
// 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가
|
// 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가
|
||||||
if (isGroupedMode && keyColumns.length > 0) {
|
if (isGroupedMode && keyColumns.length > 0) {
|
||||||
|
|
@ -3900,21 +4025,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="flex h-full w-full flex-col">
|
||||||
<div className="text-muted-foreground mb-2 text-xs">
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
{filteredData.length}개의 관련 데이터
|
<table className="min-w-full">
|
||||||
{rightSearchQuery && filteredData.length !== rightData.length && (
|
<thead className="sticky top-0 z-10">
|
||||||
<span className="text-primary ml-1">(전체 {rightData.length}개 중)</span>
|
<tr className="border-b-2 border-border/60">
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="overflow-auto">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead className="sticky top-0 z-10 bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
{columnsToShow.map((col, idx) => (
|
{columnsToShow.map((col, idx) => (
|
||||||
<th
|
<th
|
||||||
key={idx}
|
key={idx}
|
||||||
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase whitespace-nowrap"
|
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
|
||||||
style={{
|
style={{
|
||||||
width: col.width ? `${col.width}px` : "auto",
|
width: col.width ? `${col.width}px` : "auto",
|
||||||
minWidth: "80px",
|
minWidth: "80px",
|
||||||
|
|
@ -3928,22 +4047,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
{!isDesignMode &&
|
{!isDesignMode &&
|
||||||
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
||||||
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
|
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
|
||||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">
|
||||||
작업
|
작업
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200 bg-white">
|
<tbody>
|
||||||
{filteredData.map((item, idx) => {
|
{filteredData.map((item, idx) => {
|
||||||
const itemId = item.id || item.ID || idx;
|
const itemId = item.id || item.ID || idx;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={itemId} className="hover:bg-accent transition-colors">
|
<tr key={itemId} className={cn("border-b border-border/40 transition-colors hover:bg-muted/30", idx % 2 === 1 && "bg-muted/10")}>
|
||||||
{columnsToShow.map((col, colIdx) => (
|
{columnsToShow.map((col, colIdx) => (
|
||||||
<td
|
<td
|
||||||
key={colIdx}
|
key={colIdx}
|
||||||
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||||
style={{ textAlign: col.align || "left" }}
|
style={{ textAlign: col.align || "left" }}
|
||||||
>
|
>
|
||||||
{formatCellValue(
|
{formatCellValue(
|
||||||
|
|
@ -4001,176 +4120,155 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 목록 모드 (기존)
|
// 목록 모드 - 테이블형 디자인 (행 클릭 시 상세 정보 표시)
|
||||||
return filteredData.length > 0 ? (
|
{
|
||||||
<div className="space-y-2">
|
// 표시 컬럼 결정
|
||||||
<div className="text-muted-foreground mb-2 text-xs">
|
const rightColumns = componentConfig.rightPanel?.columns;
|
||||||
{filteredData.length}개의 관련 데이터
|
let columnsToDisplay: { name: string; label: string; format?: string; bold?: boolean }[] = [];
|
||||||
{rightSearchQuery && filteredData.length !== rightData.length && (
|
|
||||||
<span className="text-primary ml-1">(전체 {rightData.length}개 중)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{filteredData.map((item, index) => {
|
|
||||||
const itemId = item.id || item.ID || index;
|
|
||||||
const isExpanded = expandedRightItems.has(itemId);
|
|
||||||
|
|
||||||
// 우측 패널 표시 컬럼 설정 확인
|
if (rightColumns && rightColumns.length > 0) {
|
||||||
const rightColumns = componentConfig.rightPanel?.columns;
|
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
|
||||||
let firstValues: [string, any, string][] = [];
|
columnsToDisplay = rightColumns
|
||||||
let allValues: [string, any, string][] = [];
|
.filter((col) => col.showInSummary !== false)
|
||||||
|
.map((col) => ({
|
||||||
|
name: col.name,
|
||||||
|
label: rightColumnLabels[col.name] || col.label || col.name,
|
||||||
|
format: col.format,
|
||||||
|
bold: col.bold,
|
||||||
|
}));
|
||||||
|
} else if (filteredData.length > 0) {
|
||||||
|
columnsToDisplay = Object.keys(filteredData[0])
|
||||||
|
.filter((key) => shouldShowField(key))
|
||||||
|
.slice(0, 6)
|
||||||
|
.map((key) => ({
|
||||||
|
name: key,
|
||||||
|
label: rightColumnLabels[key] || key,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if (rightColumns && rightColumns.length > 0) {
|
const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true);
|
||||||
// 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리)
|
const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true);
|
||||||
// 설정된 컬럼은 null/empty여도 항상 표시 (사용자가 명시적으로 설정한 컬럼이므로)
|
const hasActions = hasEditButton || hasDeleteButton;
|
||||||
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
|
|
||||||
firstValues = rightColumns
|
|
||||||
.slice(0, summaryCount)
|
|
||||||
.map((col) => {
|
|
||||||
const value = getEntityJoinValue(item, col.name);
|
|
||||||
return [col.name, value, col.label] as [string, any, string];
|
|
||||||
});
|
|
||||||
|
|
||||||
allValues = rightColumns
|
return filteredData.length > 0 ? (
|
||||||
.map((col) => {
|
<div className="flex h-full w-full flex-col">
|
||||||
const value = getEntityJoinValue(item, col.name);
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
return [col.name, value, col.label] as [string, any, string];
|
<table className="w-full text-sm">
|
||||||
});
|
<thead className="sticky top-0 z-10 bg-background">
|
||||||
} else {
|
<tr className="border-b-2 border-border/60">
|
||||||
// 설정 없으면 모든 컬럼 표시 (기존 로직)
|
{columnsToDisplay.map((col) => (
|
||||||
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
|
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
|
||||||
firstValues = Object.entries(item)
|
{col.label}
|
||||||
.filter(([key]) => !key.toLowerCase().includes("id"))
|
</th>
|
||||||
.slice(0, summaryCount)
|
))}
|
||||||
.map(([key, value]) => [key, value, ""] as [string, any, string]);
|
{hasActions && (
|
||||||
|
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">작업</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredData.map((item, idx) => {
|
||||||
|
const itemId = item.id || item.ID || idx;
|
||||||
|
const isExpanded = expandedRightItems.has(itemId);
|
||||||
|
|
||||||
allValues = Object.entries(item)
|
// 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만 표시)
|
||||||
.filter(([key, value]) => value !== null && value !== undefined && value !== "")
|
let allValues: [string, any, string][] = [];
|
||||||
.map(([key, value]) => [key, value, ""] as [string, any, string]);
|
if (rightColumns && rightColumns.length > 0) {
|
||||||
}
|
allValues = rightColumns
|
||||||
|
.filter((col) => col.showInDetail !== false)
|
||||||
|
.map((col) => {
|
||||||
|
const value = getEntityJoinValue(item, col.name);
|
||||||
|
return [col.name, value, col.label] as [string, any, string];
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
allValues = Object.entries(item)
|
||||||
|
.filter(([, value]) => value !== null && value !== undefined && value !== "")
|
||||||
|
.map(([key, value]) => [key, value, ""] as [string, any, string]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<React.Fragment key={itemId}>
|
||||||
key={itemId}
|
<tr
|
||||||
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
|
className={cn(
|
||||||
>
|
"cursor-pointer border-b border-border/40 transition-colors",
|
||||||
{/* 요약 정보 */}
|
isExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30",
|
||||||
<div className="p-3">
|
)}
|
||||||
<div className="flex items-start justify-between gap-2">
|
onClick={() => toggleRightItemExpansion(itemId)}
|
||||||
<div
|
>
|
||||||
className="min-w-0 flex-1 cursor-pointer"
|
{columnsToDisplay.map((col) => (
|
||||||
onClick={() => toggleRightItemExpansion(itemId)}
|
<td key={col.name} className="px-3 py-2 text-xs">
|
||||||
>
|
{formatCellValue(
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
col.name,
|
||||||
{firstValues.map(([key, value, label], idx) => {
|
getEntityJoinValue(item, col.name),
|
||||||
// 포맷 설정 및 볼드 설정 찾기
|
rightCategoryMappings,
|
||||||
const colConfig = rightColumns?.find((c) => c.name === key);
|
col.format,
|
||||||
const format = colConfig?.format;
|
|
||||||
const boldValue = colConfig?.bold ?? false;
|
|
||||||
|
|
||||||
// 🆕 포맷 적용 (날짜/숫자/카테고리) - null/empty는 "-"로 표시
|
|
||||||
const displayValue = (value === null || value === undefined || value === "")
|
|
||||||
? "-"
|
|
||||||
: formatCellValue(key, value, rightCategoryMappings, format);
|
|
||||||
|
|
||||||
const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={key} className="flex items-baseline gap-1">
|
|
||||||
{showLabel && (
|
|
||||||
<span className="text-muted-foreground text-xs font-medium whitespace-nowrap">
|
|
||||||
{label || getColumnLabel(key)}:
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<span
|
</td>
|
||||||
className={`text-foreground text-sm ${boldValue ? "font-semibold" : ""}`}
|
))}
|
||||||
>
|
{hasActions && (
|
||||||
{displayValue}
|
<td className="px-3 py-2 text-right">
|
||||||
</span>
|
<div className="flex items-center justify-end gap-1">
|
||||||
</div>
|
{hasEditButton && (
|
||||||
);
|
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
||||||
})}
|
onClick={(e) => {
|
||||||
</div>
|
e.stopPropagation();
|
||||||
</div>
|
handleEditClick("right", item);
|
||||||
<div className="flex flex-shrink-0 items-start gap-1 pt-1">
|
}}
|
||||||
{/* 수정 버튼 */}
|
>
|
||||||
{!isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
<Pencil className="h-3 w-3" />
|
||||||
<Button
|
</Button>
|
||||||
variant={componentConfig.rightPanel?.editButton?.buttonVariant || "outline"}
|
)}
|
||||||
size="sm"
|
{hasDeleteButton && (
|
||||||
onClick={(e) => {
|
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
handleEditClick("right", item);
|
e.stopPropagation();
|
||||||
}}
|
handleDeleteClick("right", item);
|
||||||
className="h-7"
|
}}
|
||||||
>
|
>
|
||||||
<Pencil className="mr-1 h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
)}
|
</div>
|
||||||
{/* 삭제 버튼 */}
|
</td>
|
||||||
{!isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
|
)}
|
||||||
<button
|
</tr>
|
||||||
onClick={(e) => {
|
{/* 상세 정보 (행 클릭 시 펼쳐짐) */}
|
||||||
e.stopPropagation();
|
{isExpanded && (
|
||||||
handleDeleteClick("right", item);
|
<tr>
|
||||||
}}
|
<td colSpan={columnsToDisplay.length + (hasActions ? 1 : 0)} className="bg-muted/30 px-3 py-2">
|
||||||
className="rounded p-1 transition-colors hover:bg-red-100"
|
<div className="mb-1 text-xs font-semibold">상세 정보</div>
|
||||||
title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
|
<div className="bg-card overflow-auto rounded-md border">
|
||||||
>
|
<table className="w-full text-sm">
|
||||||
<Trash2 className="h-4 w-4 text-red-600" />
|
<tbody className="divide-border divide-y">
|
||||||
</button>
|
{allValues.map(([key, value, label]) => {
|
||||||
)}
|
const colConfig = rightColumns?.find((c) => c.name === key);
|
||||||
{/* 확장/접기 버튼 */}
|
const format = colConfig?.format;
|
||||||
<button
|
const displayValue = (value === null || value === undefined || value === "")
|
||||||
onClick={() => toggleRightItemExpansion(itemId)}
|
? "-"
|
||||||
className="rounded p-1 transition-colors hover:bg-gray-200"
|
: formatCellValue(key, value, rightCategoryMappings, format);
|
||||||
>
|
return (
|
||||||
{isExpanded ? (
|
<tr key={key} className="hover:bg-muted">
|
||||||
<ChevronUp className="text-muted-foreground h-5 w-5" />
|
<td className="text-muted-foreground px-3 py-1.5 text-xs font-medium whitespace-nowrap">
|
||||||
) : (
|
{label || getColumnLabel(key)}
|
||||||
<ChevronDown className="text-muted-foreground h-5 w-5" />
|
</td>
|
||||||
|
<td className="text-foreground px-3 py-1.5 text-xs break-all">{displayValue}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
</button>
|
</React.Fragment>
|
||||||
</div>
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</tbody>
|
||||||
|
</table>
|
||||||
{/* 상세 정보 (확장 시 표시) */}
|
</div>
|
||||||
{isExpanded && (
|
</div>
|
||||||
<div className="bg-muted/50 border-t px-3 py-2">
|
) : (
|
||||||
<div className="mb-2 text-xs font-semibold">전체 상세 정보</div>
|
|
||||||
<div className="bg-card overflow-auto rounded-md border">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<tbody className="divide-border divide-y">
|
|
||||||
{allValues.map(([key, value, label]) => {
|
|
||||||
// 포맷 설정 찾기
|
|
||||||
const colConfig = rightColumns?.find((c) => c.name === key);
|
|
||||||
const format = colConfig?.format;
|
|
||||||
|
|
||||||
// 🆕 포맷 적용 (날짜/숫자/카테고리) - null/empty는 "-"로 표시
|
|
||||||
const displayValue = (value === null || value === undefined || value === "")
|
|
||||||
? "-"
|
|
||||||
: formatCellValue(key, value, rightCategoryMappings, format);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={key} className="hover:bg-muted">
|
|
||||||
<td className="text-muted-foreground px-3 py-2 font-medium whitespace-nowrap">
|
|
||||||
{label || getColumnLabel(key)}
|
|
||||||
</td>
|
|
||||||
<td className="text-foreground px-3 py-2 break-all">{displayValue}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||||
{rightSearchQuery ? (
|
{rightSearchQuery ? (
|
||||||
<>
|
<>
|
||||||
|
|
@ -4182,6 +4280,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
})()
|
})()
|
||||||
) : (
|
) : (
|
||||||
// 상세 모드: 단일 객체를 상세 정보로 표시
|
// 상세 모드: 단일 객체를 상세 정보로 표시
|
||||||
|
|
@ -4198,8 +4297,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||||
rightColumns.map((c) => `${c.name} (${c.label})`),
|
rightColumns.map((c) => `${c.name} (${c.label})`),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 설정된 컬럼만 표시
|
// 설정된 컬럼만 표시 (showInDetail이 false가 아닌 것만)
|
||||||
displayEntries = rightColumns
|
displayEntries = rightColumns
|
||||||
|
.filter((col) => col.showInDetail !== false)
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name)
|
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name)
|
||||||
let value = rightData[col.name];
|
let value = rightData[col.name];
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,10 @@ import { CSS } from "@dnd-kit/utilities";
|
||||||
|
|
||||||
// 드래그 가능한 컬럼 아이템
|
// 드래그 가능한 컬럼 아이템
|
||||||
function SortableColumnRow({
|
function SortableColumnRow({
|
||||||
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove,
|
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
col: { name: string; label: string; width?: number; format?: any };
|
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
|
||||||
index: number;
|
index: number;
|
||||||
isNumeric: boolean;
|
isNumeric: boolean;
|
||||||
isEntityJoin?: boolean;
|
isEntityJoin?: boolean;
|
||||||
|
|
@ -39,6 +39,8 @@ function SortableColumnRow({
|
||||||
onWidthChange: (value: number) => void;
|
onWidthChange: (value: number) => void;
|
||||||
onFormatChange: (checked: boolean) => void;
|
onFormatChange: (checked: boolean) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onShowInSummaryChange?: (checked: boolean) => void;
|
||||||
|
onShowInDetailChange?: (checked: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||||
|
|
@ -84,6 +86,29 @@ function SortableColumnRow({
|
||||||
,
|
,
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{/* 헤더/상세 표시 토글 */}
|
||||||
|
{onShowInSummaryChange && (
|
||||||
|
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={col.showInSummary !== false}
|
||||||
|
onChange={(e) => onShowInSummaryChange(e.target.checked)}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
헤더
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{onShowInDetailChange && (
|
||||||
|
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="행 클릭 시 상세 정보에 표시">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={col.showInDetail !== false}
|
||||||
|
onChange={(e) => onShowInDetailChange(e.target.checked)}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
상세
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -621,6 +646,16 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||||
updateTab({ columns: newColumns });
|
updateTab({ columns: newColumns });
|
||||||
}}
|
}}
|
||||||
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||||
|
onShowInSummaryChange={(checked) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], showInSummary: checked };
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
onShowInDetailChange={(checked) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], showInDetail: checked };
|
||||||
|
updateTab({ columns: newColumns });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -2332,6 +2367,16 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||||
updateRightPanel({ columns: newColumns });
|
updateRightPanel({ columns: newColumns });
|
||||||
}}
|
}}
|
||||||
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||||
|
onShowInSummaryChange={(checked) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], showInSummary: checked };
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
|
onShowInDetailChange={(checked) => {
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
newColumns[index] = { ...newColumns[index], showInDetail: checked };
|
||||||
|
updateRightPanel({ columns: newColumns });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ export interface AdditionalTabConfig {
|
||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
align?: "left" | "center" | "right";
|
align?: "left" | "center" | "right";
|
||||||
bold?: boolean;
|
bold?: boolean;
|
||||||
|
showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true)
|
||||||
|
showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true)
|
||||||
format?: {
|
format?: {
|
||||||
type?: "number" | "currency" | "date" | "text";
|
type?: "number" | "currency" | "date" | "text";
|
||||||
thousandSeparator?: boolean;
|
thousandSeparator?: boolean;
|
||||||
|
|
@ -225,6 +227,8 @@ export interface SplitPanelLayoutConfig {
|
||||||
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
||||||
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
|
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
|
||||||
bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드)
|
bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드)
|
||||||
|
showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true)
|
||||||
|
showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true)
|
||||||
format?: {
|
format?: {
|
||||||
type?: "number" | "currency" | "date" | "text"; // 포맷 타입
|
type?: "number" | "currency" | "date" | "text"; // 포맷 타입
|
||||||
thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency")
|
thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency")
|
||||||
|
|
|
||||||
|
|
@ -1004,13 +1004,25 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryKeys = primaryKeyResult.data || [];
|
const primaryKeys = primaryKeyResult.data || [];
|
||||||
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
|
let primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
|
||||||
|
|
||||||
// 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리
|
// 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리
|
||||||
// originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨
|
// originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨
|
||||||
// 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인
|
// 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인
|
||||||
const hasRealOriginalData = originalData && Object.keys(originalData).length > 0;
|
const hasRealOriginalData = originalData && Object.keys(originalData).length > 0;
|
||||||
|
|
||||||
|
// 🆕 폴백: 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가 없어도 formData에 id가 있으면 UPDATE로 판단
|
||||||
// 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리
|
// 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리
|
||||||
const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== "";
|
const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== "";
|
||||||
|
|
@ -4170,6 +4182,8 @@ export class ButtonActionExecutor {
|
||||||
dataSourceType: controlDataSource,
|
dataSourceType: controlDataSource,
|
||||||
sourceData,
|
sourceData,
|
||||||
context: extendedContext,
|
context: extendedContext,
|
||||||
|
// 저장 전 원본 데이터 전달 (after 타이밍에서 DB 기존값 비교용)
|
||||||
|
originalData: context.originalData || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue