Compare commits
172 Commits
37d93d82b1
...
cc44f714c6
| Author | SHA1 | Date |
|---|---|---|
|
|
cc44f714c6 | |
|
|
9b153d85af | |
|
|
7ad17065f0 | |
|
|
e2d88f01e3 | |
|
|
e16d76936b | |
|
|
a8ad26cf30 | |
|
|
026e99511c | |
|
|
21c0c2b95c | |
|
|
1a6d78df43 | |
|
|
b1831ada04 | |
|
|
649bd77bbb | |
|
|
8bfc2ba4f5 | |
|
|
c1f7f27005 | |
|
|
c86337832a | |
|
|
d686c385e0 | |
|
|
0f52c3adc2 | |
|
|
36bc33860f | |
|
|
1b7163ee1a | |
|
|
c0df38c7ba | |
|
|
4e997ae36b | |
|
|
929b68299a | |
|
|
bfc89501ba | |
|
|
d50f705c44 | |
|
|
708a0fbd1f | |
|
|
bbbdd31311 | |
|
|
38ade7562e | |
|
|
385a10e2e7 | |
|
|
2335a413cb | |
|
|
e622013b3d | |
|
|
17d4cc297c | |
|
|
afc66a4971 | |
|
|
c161957cfe | |
|
|
0e0d433ce3 | |
|
|
95c8148787 | |
|
|
52d95b4798 | |
|
|
43ead0e7f2 | |
|
|
935c737fe3 | |
|
|
5888ff9c9e | |
|
|
27be48464a | |
|
|
20167ad359 | |
|
|
f90bf63354 | |
|
|
95caa2d10c | |
|
|
63d8e17392 | |
|
|
52389292a7 | |
|
|
dd86d5e63c | |
|
|
495594913f | |
|
|
efc4768ed7 | |
|
|
4e81571f2b | |
|
|
46ea3612fd | |
|
|
eb27f01616 | |
|
|
5cff85d260 | |
|
|
863ec614f4 | |
|
|
0f3ec495a5 | |
|
|
5e605efa26 | |
|
|
55cbd8778a | |
|
|
66c92bb7b1 | |
|
|
abb31a39bb | |
|
|
18cf5e3269 | |
|
|
262221e300 | |
|
|
ed9e36c213 | |
|
|
38dda2f807 | |
|
|
60b1ac1442 | |
|
|
2b175a21f4 | |
|
|
d09daa1503 | |
|
|
3ca511924e | |
|
|
cb4fa2aaba | |
|
|
b8569c6641 | |
|
|
2392dca6fc | |
|
|
593eee3a34 | |
|
|
0b6c305024 | |
|
|
9a85343166 | |
|
|
89b7627bcd | |
|
|
969b53637a | |
|
|
5ed2d42377 | |
|
|
a6f37fd3dc | |
|
|
72068d003a | |
|
|
bb7399df07 | |
|
|
6b4250b903 | |
|
|
076184aad2 | |
|
|
19efe4ada5 | |
|
|
fc96c958ba | |
|
|
4f6d9a689d | |
|
|
4ed2fa4d65 | |
|
|
5afa373b1f | |
|
|
27853a9447 | |
|
|
e8c0828d91 | |
|
|
5ec689101e | |
|
|
4e422fc477 | |
|
|
f2528fcb39 | |
|
|
ea610a243a | |
|
|
9cc93b88ff | |
|
|
aec516b8dc | |
|
|
350d567f3e | |
|
|
ab385f4bba | |
|
|
bfdf061ead | |
|
|
f2bd7edf7e | |
|
|
9614ce3973 | |
|
|
5af41ad90b | |
|
|
9e9aa01b03 | |
|
|
bfd90792f8 | |
|
|
5eab4669f0 | |
|
|
2289c88320 | |
|
|
a466e523d9 | |
|
|
2395a8d6b7 | |
|
|
59417b76aa | |
|
|
92bfac8cd7 | |
|
|
0d1a19e852 | |
|
|
0006c04c7d | |
|
|
97165ab007 | |
|
|
f35ba75966 | |
|
|
95f668d40d | |
|
|
b1ec674fa9 | |
|
|
df04afa5de | |
|
|
d0ebb82f90 | |
|
|
505930b3ec | |
|
|
fb02e5b389 | |
|
|
5d391f0cee | |
|
|
beb873f9f1 | |
|
|
70cb50e446 | |
|
|
4294e6206b | |
|
|
4473743d5f | |
|
|
14d6406a61 | |
|
|
5c6efa861d | |
|
|
56d069f853 | |
|
|
0512a3214c | |
|
|
4e12f93da4 | |
|
|
c551e82eee | |
|
|
e065835c4d | |
|
|
2bbb5d7013 | |
|
|
eac2fa63b1 | |
|
|
ced25c9a54 | |
|
|
308f05ca07 | |
|
|
225fd50ca1 | |
|
|
9785f098d8 | |
|
|
5b44a41651 | |
|
|
86a73267cb | |
|
|
403b3da36d | |
|
|
e97fd05e75 | |
|
|
8253be0048 | |
|
|
a8432b83ba | |
|
|
8894216ee8 | |
|
|
b05a883353 | |
|
|
219f7724e7 | |
|
|
3c8c2ebcf4 | |
|
|
9e1a54c738 | |
|
|
45029bf5f4 | |
|
|
30ee36f881 | |
|
|
2024299c02 | |
|
|
c65f436009 | |
|
|
e29eaceeff | |
|
|
1aacd829f2 | |
|
|
f8c0fe9499 | |
|
|
0ea5f3d5e4 | |
|
|
7118a723f3 | |
|
|
d7f900d8ae | |
|
|
b4d216b7c8 | |
|
|
a1c040ddf8 | |
|
|
423ef6231a | |
|
|
2b035ce6e1 | |
|
|
7dc0bbb329 | |
|
|
946ce1964d | |
|
|
64a775ce53 | |
|
|
78f23ea0a9 | |
|
|
2e500f066f | |
|
|
e653c7c472 | |
|
|
1c71b3aa83 | |
|
|
bb4d90fd58 | |
|
|
c552f32370 | |
|
|
79d8f0b160 | |
|
|
84eb035069 | |
|
|
9d368b1864 | |
|
|
08dde416b1 |
|
|
@ -291,16 +291,11 @@ uploads/
|
|||
|
||||
claude.md
|
||||
|
||||
# AI 에이전트 테스트 산출물
|
||||
*-test-screenshots/
|
||||
*-screenshots/
|
||||
*-test.mjs
|
||||
|
||||
# 개인 작업 문서 (popdocs)
|
||||
popdocs/
|
||||
.cursor/rules/popdocs-safety.mdc
|
||||
|
||||
# ============================================
|
||||
# KSH 개인 오케스트레이션 설정 (팀 공유 안함)
|
||||
# ============================================
|
||||
.cursor/rules/orchestrator.mdc
|
||||
.cursor/agents/
|
||||
.cursor/commands/
|
||||
.cursor/hooks.json
|
||||
.cursor/hooks/
|
||||
.cursor/plans/
|
||||
.cursor/rules/popdocs-safety.mdc
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
|
|
@ -1044,7 +1045,6 @@
|
|||
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.3",
|
||||
|
|
@ -2372,7 +2372,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
|
|
@ -3476,7 +3475,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
|
||||
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
|
|
@ -3713,7 +3711,6 @@
|
|||
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "6.21.0",
|
||||
"@typescript-eslint/types": "6.21.0",
|
||||
|
|
@ -3931,7 +3928,6 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -4458,7 +4454,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
|
|
@ -5669,7 +5664,6 @@
|
|||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
|
|
@ -5989,6 +5983,15 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-async-errors": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz",
|
||||
"integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"express": "^4.16.2"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "7.5.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||
|
|
@ -7432,7 +7435,6 @@
|
|||
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "^29.7.0",
|
||||
"@jest/types": "^29.6.3",
|
||||
|
|
@ -8402,6 +8404,7 @@
|
|||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
|
|
@ -9290,7 +9293,6 @@
|
|||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"pg-pool": "^3.10.1",
|
||||
|
|
@ -10141,6 +10143,7 @@
|
|||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
|
|
@ -10949,7 +10952,6 @@
|
|||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
|
|
@ -11055,7 +11057,6 @@
|
|||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"docx": "^9.5.1",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"html-to-docx": "^1.8.0",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import "dotenv/config";
|
||||
import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
|
|
@ -104,6 +105,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
|
|||
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||
|
|
@ -123,6 +125,7 @@ import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRou
|
|||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -289,6 +292,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
|
|||
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||
|
|
@ -305,6 +309,7 @@ app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호
|
|||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||
app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트)
|
||||
app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
|
|
|
|||
|
|
@ -19,8 +19,6 @@ export async function getAdminMenus(
|
|||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 관리자 메뉴 목록 조회 시작 ===");
|
||||
|
||||
// 현재 로그인한 사용자의 정보 가져오기
|
||||
const userId = req.user?.userId;
|
||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||
|
|
@ -29,13 +27,6 @@ export async function getAdminMenus(
|
|||
const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가
|
||||
const includeInactive = req.query.includeInactive === "true"; // includeInactive 파라미터 추가
|
||||
|
||||
logger.info(`사용자 ID: ${userId}`);
|
||||
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
|
||||
logger.info(`사용자 유형: ${userType}`);
|
||||
logger.info(`사용자 로케일: ${userLang}`);
|
||||
logger.info(`메뉴 타입: ${menuType || "전체"}`);
|
||||
logger.info(`비활성 메뉴 포함: ${includeInactive}`);
|
||||
|
||||
const paramMap = {
|
||||
userId,
|
||||
userCompanyCode,
|
||||
|
|
@ -47,13 +38,6 @@ export async function getAdminMenus(
|
|||
|
||||
const menuList = await AdminService.getAdminMenuList(paramMap);
|
||||
|
||||
logger.info(
|
||||
`관리자 메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"}, 회사: ${userCompanyCode})`
|
||||
);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", menuList[0]);
|
||||
}
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "관리자 메뉴 목록 조회 성공",
|
||||
|
|
@ -85,19 +69,12 @@ export async function getUserMenus(
|
|||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("=== 사용자 메뉴 목록 조회 시작 ===");
|
||||
|
||||
// 현재 로그인한 사용자의 정보 가져오기
|
||||
const userId = req.user?.userId;
|
||||
const userCompanyCode = req.user?.companyCode || "ILSHIN";
|
||||
const userType = req.user?.userType;
|
||||
const userLang = (req.query.userLang as string) || "ko";
|
||||
|
||||
logger.info(`사용자 ID: ${userId}`);
|
||||
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
|
||||
logger.info(`사용자 유형: ${userType}`);
|
||||
logger.info(`사용자 로케일: ${userLang}`);
|
||||
|
||||
const paramMap = {
|
||||
userId,
|
||||
userCompanyCode,
|
||||
|
|
@ -107,13 +84,6 @@ export async function getUserMenus(
|
|||
|
||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||
|
||||
logger.info(
|
||||
`사용자 메뉴 조회 결과: ${menuList.length}개 (회사: ${userCompanyCode})`
|
||||
);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", menuList[0]);
|
||||
}
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
success: true,
|
||||
message: "사용자 메뉴 목록 조회 성공",
|
||||
|
|
@ -473,7 +443,7 @@ export const getUserLocale = async (
|
|||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
logger.info("사용자 로케일 조회 요청", {
|
||||
logger.debug("사용자 로케일 조회 요청", {
|
||||
query: req.query,
|
||||
user: req.user,
|
||||
});
|
||||
|
|
@ -496,7 +466,7 @@ export const getUserLocale = async (
|
|||
|
||||
if (userInfo?.locale) {
|
||||
userLocale = userInfo.locale;
|
||||
logger.info("데이터베이스에서 사용자 로케일 조회 성공", {
|
||||
logger.debug("데이터베이스에서 사용자 로케일 조회 성공", {
|
||||
userId: req.user.userId,
|
||||
locale: userLocale,
|
||||
});
|
||||
|
|
@ -513,7 +483,7 @@ export const getUserLocale = async (
|
|||
message: "사용자 로케일 조회 성공",
|
||||
};
|
||||
|
||||
logger.info("사용자 로케일 조회 성공", {
|
||||
logger.debug("사용자 로케일 조회 성공", {
|
||||
userLocale,
|
||||
userId: req.user.userId,
|
||||
fromDatabase: !!userInfo?.locale,
|
||||
|
|
@ -618,7 +588,7 @@ export const getCompanyList = async (
|
|||
res: Response
|
||||
) => {
|
||||
try {
|
||||
logger.info("회사 목록 조회 요청", {
|
||||
logger.debug("회사 목록 조회 요청", {
|
||||
query: req.query,
|
||||
user: req.user,
|
||||
});
|
||||
|
|
@ -658,12 +628,8 @@ export const getCompanyList = async (
|
|||
message: "회사 목록 조회 성공",
|
||||
};
|
||||
|
||||
logger.info("회사 목록 조회 성공", {
|
||||
logger.debug("회사 목록 조회 성공", {
|
||||
totalCount: companies.length,
|
||||
companies: companies.map((c) => ({
|
||||
code: c.company_code,
|
||||
name: c.company_name,
|
||||
})),
|
||||
});
|
||||
|
||||
res.status(200).json(response);
|
||||
|
|
@ -1443,13 +1409,7 @@ async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
|
|||
* 메뉴 및 관련 데이터 정리 헬퍼 함수
|
||||
*/
|
||||
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
|
||||
// 1. category_column_mapping에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
);
|
||||
|
||||
// 2. code_category에서 menu_objid를 NULL로 설정
|
||||
// 1. code_category에서 menu_objid를 NULL로 설정
|
||||
await query(
|
||||
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
|
||||
[menuObjid]
|
||||
|
|
@ -1870,7 +1830,7 @@ export async function getCompanyListFromDB(
|
|||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
logger.info("회사 목록 조회 요청 (Raw Query)", { user: req.user });
|
||||
logger.debug("회사 목록 조회 요청 (Raw Query)", { user: req.user });
|
||||
|
||||
// Raw Query로 회사 목록 조회
|
||||
const companies = await query<any>(
|
||||
|
|
@ -1890,7 +1850,7 @@ export async function getCompanyListFromDB(
|
|||
ORDER BY regdate DESC`
|
||||
);
|
||||
|
||||
logger.info("회사 목록 조회 성공 (Raw Query)", { count: companies.length });
|
||||
logger.debug("회사 목록 조회 성공 (Raw Query)", { count: companies.length });
|
||||
|
||||
const response: ApiResponse<any> = {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -17,9 +17,7 @@ export class AuthController {
|
|||
const { userId, password }: LoginRequest = req.body;
|
||||
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
|
||||
|
||||
logger.info(`=== API 로그인 호출됨 ===`);
|
||||
logger.info(`userId: ${userId}`);
|
||||
logger.info(`password: ${password ? "***" : "null"}`);
|
||||
logger.debug(`로그인 요청: ${userId}`);
|
||||
|
||||
// 입력값 검증
|
||||
if (!userId || !password) {
|
||||
|
|
@ -50,14 +48,7 @@ export class AuthController {
|
|||
companyCode: loginResult.userInfo.companyCode || "ILSHIN",
|
||||
};
|
||||
|
||||
logger.info(`=== API 로그인 사용자 정보 디버그 ===`);
|
||||
logger.info(
|
||||
`PersonBean companyCode: ${loginResult.userInfo.companyCode}`
|
||||
);
|
||||
logger.info(`반환할 사용자 정보:`);
|
||||
logger.info(`- userId: ${userInfo.userId}`);
|
||||
logger.info(`- userName: ${userInfo.userName}`);
|
||||
logger.info(`- companyCode: ${userInfo.companyCode}`);
|
||||
logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
|
||||
|
||||
// 사용자의 첫 번째 접근 가능한 메뉴 조회
|
||||
let firstMenuPath: string | null = null;
|
||||
|
|
@ -71,7 +62,7 @@ export class AuthController {
|
|||
};
|
||||
|
||||
const menuList = await AdminService.getUserMenuList(paramMap);
|
||||
logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||
logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
|
||||
|
||||
// 접근 가능한 첫 번째 메뉴 찾기
|
||||
// 조건:
|
||||
|
|
@ -87,16 +78,9 @@ export class AuthController {
|
|||
|
||||
if (firstMenu) {
|
||||
firstMenuPath = firstMenu.menu_url || firstMenu.url;
|
||||
logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, {
|
||||
name: firstMenu.menu_name_kor || firstMenu.translated_name,
|
||||
url: firstMenuPath,
|
||||
level: firstMenu.lev || firstMenu.level,
|
||||
seq: firstMenu.seq,
|
||||
});
|
||||
logger.debug(`첫 번째 메뉴: ${firstMenuPath}`);
|
||||
} else {
|
||||
logger.info(
|
||||
"⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다."
|
||||
);
|
||||
logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동");
|
||||
}
|
||||
} catch (menuError) {
|
||||
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,226 @@
|
|||
/**
|
||||
* BOM 이력/버전 관리 컨트롤러
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { logger } from "../utils/logger";
|
||||
import * as bomService from "../services/bomService";
|
||||
|
||||
// ─── 이력 (History) ─────────────────────────────
|
||||
|
||||
export async function getBomHistory(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const tableName = (req.query.tableName as string) || undefined;
|
||||
|
||||
const data = await bomService.getBomHistory(bomId, companyCode, tableName);
|
||||
res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 이력 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function addBomHistory(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const changedBy = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
|
||||
const { change_type, change_description, revision, version, tableName } = req.body;
|
||||
if (!change_type) {
|
||||
res.status(400).json({ success: false, message: "change_type은 필수입니다" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bomService.addBomHistory(bomId, companyCode, {
|
||||
change_type,
|
||||
change_description,
|
||||
revision,
|
||||
version,
|
||||
changed_by: changedBy,
|
||||
}, tableName);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 이력 등록 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BOM 헤더 조회 (entity join 포함) ─────────────────────────
|
||||
|
||||
export async function getBomHeader(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const tableName = (req.query.tableName as string) || undefined;
|
||||
|
||||
const data = await bomService.getBomHeader(bomId, tableName);
|
||||
if (!data) {
|
||||
res.status(404).json({ success: false, message: "BOM을 찾을 수 없습니다" });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 헤더 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 버전 (Version) ─────────────────────────────
|
||||
|
||||
export async function getBomVersions(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const tableName = (req.query.tableName as string) || undefined;
|
||||
|
||||
const result = await bomService.getBomVersions(bomId, companyCode, tableName);
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.versions,
|
||||
currentVersionId: result.currentVersionId,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 목록 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
const { tableName, detailTable, versionName } = req.body || {};
|
||||
|
||||
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable, versionName);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 생성 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId, versionId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const { tableName, detailTable } = req.body || {};
|
||||
|
||||
const result = await bomService.loadBomVersion(bomId, versionId, companyCode, tableName, detailTable);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 불러오기 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function activateBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId, versionId } = req.params;
|
||||
const { tableName } = req.body || {};
|
||||
|
||||
const result = await bomService.activateBomVersion(bomId, versionId, tableName);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 사용 확정 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function initializeBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
|
||||
const result = await bomService.initializeBomVersion(bomId, companyCode, createdBy);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 초기 버전 생성 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BOM 엑셀 업로드/다운로드 ─────────────────────────
|
||||
|
||||
export async function createBomFromExcel(req: Request, res: Response) {
|
||||
try {
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
const { rows } = req.body;
|
||||
|
||||
if (!rows || !Array.isArray(rows) || rows.length === 0) {
|
||||
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bomService.createBomFromExcel(companyCode, userId, rows);
|
||||
if (!result.success) {
|
||||
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 엑셀 업로드 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBomVersionFromExcel(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
const { rows, versionName } = req.body;
|
||||
|
||||
if (!rows || !Array.isArray(rows) || rows.length === 0) {
|
||||
res.status(400).json({ success: false, message: "업로드할 데이터가 없습니다" });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bomService.createBomVersionFromExcel(bomId, companyCode, userId, rows, versionName);
|
||||
if (!result.success) {
|
||||
res.status(400).json({ success: false, message: result.errors.join(", "), data: result });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 엑셀 업로드 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadBomExcelData(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
|
||||
const data = await bomService.downloadBomExcelData(bomId, companyCode);
|
||||
res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 엑셀 다운로드 데이터 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId, versionId } = req.params;
|
||||
const tableName = (req.query.tableName as string) || undefined;
|
||||
const detailTable = (req.query.detailTable as string) || undefined;
|
||||
|
||||
const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName, detailTable);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 삭제 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -193,10 +193,11 @@ export class EntityJoinController {
|
|||
async getEntityJoinConfigs(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`Entity 조인 설정 조회: ${tableName}`);
|
||||
logger.info(`Entity 조인 설정 조회: ${tableName} (companyCode: ${companyCode})`);
|
||||
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
const joinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
|
|
@ -224,11 +225,12 @@ export class EntityJoinController {
|
|||
async getReferenceTableColumns(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`참조 테이블 컬럼 조회: ${tableName}`);
|
||||
logger.info(`참조 테이블 컬럼 조회: ${tableName} (companyCode: ${companyCode})`);
|
||||
|
||||
const columns =
|
||||
await tableManagementService.getReferenceTableColumns(tableName);
|
||||
await tableManagementService.getReferenceTableColumns(tableName, companyCode);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
|
|
@ -408,11 +410,12 @@ export class EntityJoinController {
|
|||
async getEntityJoinColumns(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`Entity 조인 컬럼 조회: ${tableName}`);
|
||||
logger.info(`Entity 조인 컬럼 조회: ${tableName} (companyCode: ${companyCode})`);
|
||||
|
||||
// 1. 현재 테이블의 Entity 조인 설정 조회
|
||||
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName);
|
||||
const allJoinConfigs = await entityJoinService.detectEntityJoins(tableName, undefined, companyCode);
|
||||
|
||||
// 🆕 화면 디자이너용: table_column_category_values는 카테고리 드롭다운용이므로 제외
|
||||
// 카테고리 값은 엔티티 조인 컬럼이 아니라 셀렉트박스 옵션으로 사용됨
|
||||
|
|
@ -439,7 +442,7 @@ export class EntityJoinController {
|
|||
try {
|
||||
const columns =
|
||||
await tableManagementService.getReferenceTableColumns(
|
||||
config.referenceTable
|
||||
config.referenceTable, companyCode
|
||||
);
|
||||
|
||||
// 현재 display_column 정보 (참고용으로만 사용, 필터링하지 않음)
|
||||
|
|
|
|||
|
|
@ -30,10 +30,13 @@ export class EntityReferenceController {
|
|||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { limit = 100, search } = req.query;
|
||||
// 멀티테넌시: 인증된 사용자의 회사 코드
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`엔티티 참조 데이터 조회 요청: ${tableName}.${columnName}`, {
|
||||
limit,
|
||||
search,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 컬럼 정보 조회 (table_type_columns에서)
|
||||
|
|
@ -89,16 +92,34 @@ export class EntityReferenceController {
|
|||
});
|
||||
}
|
||||
|
||||
// 동적 쿼리로 참조 데이터 조회
|
||||
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
||||
// 참조 테이블에 company_code 컬럼이 있는지 확인
|
||||
const hasCompanyCode = await queryOne<any>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code' AND table_schema = 'public'`,
|
||||
[referenceTable]
|
||||
);
|
||||
|
||||
// 동적 쿼리로 참조 데이터 조회 (멀티테넌시 필터 적용)
|
||||
const whereConditions: string[] = [];
|
||||
const queryParams: any[] = [];
|
||||
|
||||
// 멀티테넌시: company_code 필터링 (참조 테이블에 company_code가 있는 경우)
|
||||
if (hasCompanyCode && companyCode && companyCode !== "*") {
|
||||
queryParams.push(companyCode);
|
||||
whereConditions.push(`company_code = $${queryParams.length}`);
|
||||
logger.info(`멀티테넌시 필터 적용: company_code = ${companyCode}`, { referenceTable });
|
||||
}
|
||||
|
||||
// 검색 조건 추가
|
||||
if (search) {
|
||||
sqlQuery += ` WHERE ${displayColumn} ILIKE $1`;
|
||||
queryParams.push(`%${search}%`);
|
||||
whereConditions.push(`${displayColumn} ILIKE $${queryParams.length}`);
|
||||
}
|
||||
|
||||
let sqlQuery = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
|
||||
if (whereConditions.length > 0) {
|
||||
sqlQuery += ` WHERE ${whereConditions.join(" AND ")}`;
|
||||
}
|
||||
sqlQuery += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
|
||||
queryParams.push(Number(limit));
|
||||
|
||||
|
|
@ -107,6 +128,7 @@ export class EntityReferenceController {
|
|||
referenceTable,
|
||||
referenceColumn,
|
||||
displayColumn,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const referenceData = await query<any>(sqlQuery, queryParams);
|
||||
|
|
@ -119,7 +141,7 @@ export class EntityReferenceController {
|
|||
})
|
||||
);
|
||||
|
||||
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`);
|
||||
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
@ -149,13 +171,16 @@ export class EntityReferenceController {
|
|||
try {
|
||||
const { codeCategory } = req.params;
|
||||
const { limit = 100, search } = req.query;
|
||||
// 멀티테넌시: 인증된 사용자의 회사 코드
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
logger.info(`공통 코드 데이터 조회 요청: ${codeCategory}`, {
|
||||
limit,
|
||||
search,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// code_info 테이블에서 코드 데이터 조회
|
||||
// code_info 테이블에서 코드 데이터 조회 (멀티테넌시 필터 적용)
|
||||
const queryParams: any[] = [codeCategory, 'Y'];
|
||||
let sqlQuery = `
|
||||
SELECT code_value, code_name
|
||||
|
|
@ -163,9 +188,16 @@ export class EntityReferenceController {
|
|||
WHERE code_category = $1 AND is_active = $2
|
||||
`;
|
||||
|
||||
// 멀티테넌시: company_code 필터링
|
||||
if (companyCode && companyCode !== "*") {
|
||||
queryParams.push(companyCode);
|
||||
sqlQuery += ` AND company_code = $${queryParams.length}`;
|
||||
logger.info(`공통 코드 멀티테넌시 필터 적용: company_code = ${companyCode}`);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
sqlQuery += ` AND code_name ILIKE $3`;
|
||||
queryParams.push(`%${search}%`);
|
||||
sqlQuery += ` AND code_name ILIKE $${queryParams.length}`;
|
||||
}
|
||||
|
||||
sqlQuery += ` ORDER BY code_name ASC LIMIT $${queryParams.length + 1}`;
|
||||
|
|
@ -174,12 +206,12 @@ export class EntityReferenceController {
|
|||
const codeData = await query<any>(sqlQuery, queryParams);
|
||||
|
||||
// 옵션 형태로 변환
|
||||
const options: EntityReferenceOption[] = codeData.map((code) => ({
|
||||
const options: EntityReferenceOption[] = codeData.map((code: any) => ({
|
||||
value: code.code_value,
|
||||
label: code.code_name,
|
||||
}));
|
||||
|
||||
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`);
|
||||
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`, { companyCode });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -3,16 +3,115 @@ import { AuthenticatedRequest } from "../types/auth";
|
|||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 필터 조건을 WHERE절에 적용하는 공통 헬퍼
|
||||
* filters JSON 배열: [{ column, operator, value }]
|
||||
*/
|
||||
function applyFilters(
|
||||
filtersJson: string | undefined,
|
||||
existingColumns: Set<string>,
|
||||
whereConditions: string[],
|
||||
params: any[],
|
||||
startParamIndex: number,
|
||||
tableName: string,
|
||||
): number {
|
||||
let paramIndex = startParamIndex;
|
||||
|
||||
if (!filtersJson) return paramIndex;
|
||||
|
||||
let filters: Array<{ column: string; operator: string; value: unknown }>;
|
||||
try {
|
||||
filters = JSON.parse(filtersJson as string);
|
||||
} catch {
|
||||
logger.warn("filters JSON 파싱 실패", { tableName, filtersJson });
|
||||
return paramIndex;
|
||||
}
|
||||
|
||||
if (!Array.isArray(filters)) return paramIndex;
|
||||
|
||||
for (const filter of filters) {
|
||||
const { column, operator = "=", value } = filter;
|
||||
if (!column || !existingColumns.has(column)) {
|
||||
logger.warn("필터 컬럼 미존재 제외", { tableName, column });
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (operator) {
|
||||
case "=":
|
||||
whereConditions.push(`"${column}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "!=":
|
||||
whereConditions.push(`"${column}" != $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case ">":
|
||||
case "<":
|
||||
case ">=":
|
||||
case "<=":
|
||||
whereConditions.push(`"${column}" ${operator} $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "in": {
|
||||
const inVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||
if (inVals.length > 0) {
|
||||
const ph = inVals.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||
whereConditions.push(`"${column}" IN (${ph})`);
|
||||
params.push(...inVals);
|
||||
paramIndex += inVals.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "notIn": {
|
||||
const notInVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim());
|
||||
if (notInVals.length > 0) {
|
||||
const ph = notInVals.map((_, i) => `$${paramIndex + i}`).join(", ");
|
||||
whereConditions.push(`"${column}" NOT IN (${ph})`);
|
||||
params.push(...notInVals);
|
||||
paramIndex += notInVals.length;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "like":
|
||||
whereConditions.push(`"${column}"::text ILIKE $${paramIndex}`);
|
||||
params.push(`%${value}%`);
|
||||
paramIndex++;
|
||||
break;
|
||||
case "isNull":
|
||||
whereConditions.push(`"${column}" IS NULL`);
|
||||
break;
|
||||
case "isNotNull":
|
||||
whereConditions.push(`"${column}" IS NOT NULL`);
|
||||
break;
|
||||
default:
|
||||
whereConditions.push(`"${column}" = $${paramIndex}`);
|
||||
params.push(value);
|
||||
paramIndex++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return paramIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용)
|
||||
* GET /api/entity/:tableName/distinct/:columnName
|
||||
*
|
||||
* 해당 테이블의 해당 컬럼에서 DISTINCT 값을 조회하여 선택박스 옵션으로 반환
|
||||
*
|
||||
* Query Params:
|
||||
* - labelColumn: 별도의 라벨 컬럼 (선택)
|
||||
* - filters: JSON 배열 형태의 필터 조건 (선택)
|
||||
* 예: [{"column":"status","operator":"=","value":"active"}]
|
||||
*/
|
||||
export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼
|
||||
const { labelColumn, filters: filtersParam } = req.query;
|
||||
|
||||
// 유효성 검증
|
||||
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||
|
|
@ -68,6 +167,16 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
|||
whereConditions.push(`"${columnName}" IS NOT NULL`);
|
||||
whereConditions.push(`"${columnName}" != ''`);
|
||||
|
||||
// 필터 조건 적용
|
||||
paramIndex = applyFilters(
|
||||
filtersParam as string | undefined,
|
||||
existingColumns,
|
||||
whereConditions,
|
||||
params,
|
||||
paramIndex,
|
||||
tableName,
|
||||
);
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
|
@ -88,6 +197,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
|||
columnName,
|
||||
labelColumn: effectiveLabelColumn,
|
||||
companyCode,
|
||||
hasFilters: !!filtersParam,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
|
|
@ -111,11 +221,14 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re
|
|||
* Query Params:
|
||||
* - value: 값 컬럼 (기본: id)
|
||||
* - label: 표시 컬럼 (기본: name)
|
||||
* - fields: 추가 반환 컬럼 (콤마 구분)
|
||||
* - filters: JSON 배열 형태의 필터 조건 (선택)
|
||||
* 예: [{"column":"status","operator":"=","value":"active"}]
|
||||
*/
|
||||
export async function getEntityOptions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { tableName } = req.params;
|
||||
const { value = "id", label = "name" } = req.query;
|
||||
const { value = "id", label = "name", fields, filters: filtersParam } = req.query;
|
||||
|
||||
// tableName 유효성 검증
|
||||
if (!tableName || tableName === "undefined" || tableName === "null") {
|
||||
|
|
@ -163,13 +276,35 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
// 필터 조건 적용
|
||||
paramIndex = applyFilters(
|
||||
filtersParam as string | undefined,
|
||||
existingColumns,
|
||||
whereConditions,
|
||||
params,
|
||||
paramIndex,
|
||||
tableName,
|
||||
);
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// autoFill용 추가 컬럼 처리
|
||||
let extraColumns = "";
|
||||
if (fields && typeof fields === "string") {
|
||||
const requestedFields = fields.split(",").map((f) => f.trim()).filter(Boolean);
|
||||
const validExtra = requestedFields.filter(
|
||||
(f) => existingColumns.has(f) && f !== valueColumn && f !== effectiveLabelColumn
|
||||
);
|
||||
if (validExtra.length > 0) {
|
||||
extraColumns = ", " + validExtra.map((f) => `"${f}"`).join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
// 쿼리 실행 (최대 500개)
|
||||
const query = `
|
||||
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label
|
||||
SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label${extraColumns}
|
||||
FROM ${tableName}
|
||||
${whereClause}
|
||||
ORDER BY ${effectiveLabelColumn} ASC
|
||||
|
|
@ -183,7 +318,9 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response)
|
|||
valueColumn,
|
||||
labelColumn: effectiveLabelColumn,
|
||||
companyCode,
|
||||
hasFilters: !!filtersParam,
|
||||
rowCount: result.rowCount,
|
||||
extraFields: extraColumns ? true : false,
|
||||
});
|
||||
|
||||
res.json({
|
||||
|
|
@ -395,11 +532,35 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
|
|||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 정렬 컬럼 결정: id가 있으면 id, 없으면 첫 번째 컬럼 사용
|
||||
let orderByColumn = "1"; // 기본: 첫 번째 컬럼
|
||||
if (existingColumns.has("id")) {
|
||||
orderByColumn = '"id"';
|
||||
} else {
|
||||
// PK 컬럼 조회 시도
|
||||
try {
|
||||
const pkResult = await pool.query(
|
||||
`SELECT a.attname
|
||||
FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = $1::regclass AND i.indisprimary
|
||||
ORDER BY array_position(i.indkey, a.attnum)
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
if (pkResult.rows.length > 0) {
|
||||
orderByColumn = `"${pkResult.rows[0].attname}"`;
|
||||
}
|
||||
} catch {
|
||||
// PK 조회 실패 시 기본값 유지
|
||||
}
|
||||
}
|
||||
|
||||
// 쿼리 실행 (pool은 위에서 이미 선언됨)
|
||||
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
|
||||
const dataQuery = `
|
||||
SELECT * FROM ${tableName} ${whereClause}
|
||||
ORDER BY id DESC
|
||||
ORDER BY ${orderByColumn} DESC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -46,17 +46,7 @@ export class FlowController {
|
|||
const userId = (req as any).user?.userId || "system";
|
||||
const userCompanyCode = (req as any).user?.companyCode;
|
||||
|
||||
console.log("🔍 createFlowDefinition called with:", {
|
||||
name,
|
||||
description,
|
||||
tableName,
|
||||
dbSourceType,
|
||||
dbConnectionId,
|
||||
restApiConnectionId,
|
||||
restApiEndpoint,
|
||||
restApiJsonPath,
|
||||
userCompanyCode,
|
||||
});
|
||||
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({
|
||||
|
|
@ -121,13 +111,7 @@ export class FlowController {
|
|||
const user = (req as any).user;
|
||||
const userCompanyCode = user?.companyCode;
|
||||
|
||||
console.log("🎯 getFlowDefinitions called:", {
|
||||
userId: user?.userId,
|
||||
userCompanyCode: userCompanyCode,
|
||||
userType: user?.userType,
|
||||
tableName,
|
||||
isActive,
|
||||
});
|
||||
|
||||
|
||||
const flows = await this.flowDefinitionService.findAll(
|
||||
tableName as string | undefined,
|
||||
|
|
@ -135,7 +119,7 @@ export class FlowController {
|
|||
userCompanyCode
|
||||
);
|
||||
|
||||
console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
@ -583,14 +567,11 @@ export class FlowController {
|
|||
getStepColumnLabels = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { flowId, stepId } = req.params;
|
||||
console.log("🏷️ [FlowController] 컬럼 라벨 조회 요청:", {
|
||||
flowId,
|
||||
stepId,
|
||||
});
|
||||
|
||||
|
||||
const step = await this.flowStepService.findById(parseInt(stepId));
|
||||
if (!step) {
|
||||
console.warn("⚠️ [FlowController] 스텝을 찾을 수 없음:", stepId);
|
||||
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Step not found",
|
||||
|
|
@ -602,7 +583,7 @@ export class FlowController {
|
|||
parseInt(flowId)
|
||||
);
|
||||
if (!flowDef) {
|
||||
console.warn("⚠️ [FlowController] 플로우를 찾을 수 없음:", flowId);
|
||||
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "Flow definition not found",
|
||||
|
|
@ -612,14 +593,10 @@ export class FlowController {
|
|||
|
||||
// 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블)
|
||||
const tableName = step.tableName || flowDef.tableName;
|
||||
console.log("📋 [FlowController] 테이블명 결정:", {
|
||||
stepTableName: step.tableName,
|
||||
flowTableName: flowDef.tableName,
|
||||
selectedTableName: tableName,
|
||||
});
|
||||
|
||||
|
||||
if (!tableName) {
|
||||
console.warn("⚠️ [FlowController] 테이블명이 지정되지 않음");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {},
|
||||
|
|
@ -639,14 +616,7 @@ export class FlowController {
|
|||
[tableName]
|
||||
);
|
||||
|
||||
console.log(`✅ [FlowController] table_type_columns 조회 완료:`, {
|
||||
tableName,
|
||||
rowCount: labelRows.length,
|
||||
labels: labelRows.map((r) => ({
|
||||
col: r.column_name,
|
||||
label: r.column_label,
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
// { columnName: label } 형태의 객체로 변환
|
||||
const labels: Record<string, string> = {};
|
||||
|
|
@ -656,7 +626,7 @@ export class FlowController {
|
|||
}
|
||||
});
|
||||
|
||||
console.log("📦 [FlowController] 반환할 라벨 객체:", labels);
|
||||
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,713 @@
|
|||
/**
|
||||
* 공정 작업기준 컨트롤러
|
||||
* 품목별 라우팅/공정에 대한 작업 항목 및 상세 관리
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
// ============================================================
|
||||
// 품목/라우팅/공정 조회 (좌측 트리 데이터)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 라우팅이 있는 품목 목록 조회
|
||||
* 요청 쿼리: tableName(품목테이블), nameColumn, codeColumn
|
||||
*/
|
||||
export async function getItemsWithRouting(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const {
|
||||
tableName = "item_info",
|
||||
nameColumn = "item_name",
|
||||
codeColumn = "item_number",
|
||||
routingTable = "item_routing_version",
|
||||
routingFkColumn = "item_code",
|
||||
search = "",
|
||||
} = req.query as Record<string, string>;
|
||||
|
||||
const searchCondition = search
|
||||
? `AND (i.${nameColumn} ILIKE $2 OR i.${codeColumn} ILIKE $2)`
|
||||
: "";
|
||||
const params: any[] = [companyCode];
|
||||
if (search) params.push(`%${search}%`);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
i.id,
|
||||
i.${nameColumn} AS item_name,
|
||||
i.${codeColumn} AS item_code,
|
||||
COUNT(rv.id) AS routing_count
|
||||
FROM ${tableName} i
|
||||
LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn}
|
||||
AND rv.company_code = i.company_code
|
||||
WHERE i.company_code = $1
|
||||
${searchCondition}
|
||||
GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date
|
||||
ORDER BY i.created_date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const result = await getPool().query(query, params);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("품목 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 품목별 라우팅 버전 + 공정 목록 조회 (트리 하위 데이터)
|
||||
*/
|
||||
export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { itemCode } = req.params;
|
||||
const {
|
||||
routingVersionTable = "item_routing_version",
|
||||
routingDetailTable = "item_routing_detail",
|
||||
routingFkColumn = "item_code",
|
||||
processTable = "process_mng",
|
||||
processNameColumn = "process_name",
|
||||
processCodeColumn = "process_code",
|
||||
} = req.query as Record<string, string>;
|
||||
|
||||
// 라우팅 버전 목록
|
||||
const versionsQuery = `
|
||||
SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
|
||||
FROM ${routingVersionTable}
|
||||
WHERE ${routingFkColumn} = $1 AND company_code = $2
|
||||
ORDER BY is_default DESC, created_date DESC
|
||||
`;
|
||||
const versionsResult = await getPool().query(versionsQuery, [
|
||||
itemCode,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
// 각 버전별 공정 목록
|
||||
const routings = [];
|
||||
for (const version of versionsResult.rows) {
|
||||
const detailsQuery = `
|
||||
SELECT
|
||||
rd.id AS routing_detail_id,
|
||||
rd.seq_no,
|
||||
rd.process_code,
|
||||
rd.is_required,
|
||||
rd.work_type,
|
||||
p.${processNameColumn} AS process_name
|
||||
FROM ${routingDetailTable} rd
|
||||
LEFT JOIN ${processTable} p ON p.${processCodeColumn} = rd.process_code
|
||||
AND p.company_code = rd.company_code
|
||||
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
|
||||
ORDER BY rd.seq_no::integer
|
||||
`;
|
||||
const detailsResult = await getPool().query(detailsQuery, [
|
||||
version.id,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
routings.push({
|
||||
...version,
|
||||
processes: detailsResult.rows,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: routings });
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅/공정 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 기본 버전 설정
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 라우팅 버전을 기본 버전으로 설정
|
||||
* 같은 품목의 다른 버전은 기본 해제
|
||||
*/
|
||||
export async function setDefaultVersion(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { versionId } = req.params;
|
||||
const {
|
||||
routingVersionTable = "item_routing_version",
|
||||
routingFkColumn = "item_code",
|
||||
} = req.body;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
const versionResult = await client.query(
|
||||
`SELECT ${routingFkColumn} AS item_code FROM ${routingVersionTable} WHERE id = $1 AND company_code = $2`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
if (versionResult.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
const itemCode = versionResult.rows[0].item_code;
|
||||
|
||||
await client.query(
|
||||
`UPDATE ${routingVersionTable} SET is_default = false WHERE ${routingFkColumn} = $1 AND company_code = $2`,
|
||||
[itemCode, companyCode]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE ${routingVersionTable} SET is_default = true WHERE id = $1 AND company_code = $2`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("기본 버전 설정", { companyCode, versionId, itemCode });
|
||||
return res.json({ success: true, message: "기본 버전이 설정되었습니다" });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("기본 버전 설정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 버전 해제
|
||||
*/
|
||||
export async function unsetDefaultVersion(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { versionId } = req.params;
|
||||
const { routingVersionTable = "item_routing_version" } = req.body;
|
||||
|
||||
await getPool().query(
|
||||
`UPDATE ${routingVersionTable} SET is_default = false WHERE id = $1 AND company_code = $2`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
logger.info("기본 버전 해제", { companyCode, versionId });
|
||||
return res.json({ success: true, message: "기본 버전이 해제되었습니다" });
|
||||
} catch (error: any) {
|
||||
logger.error("기본 버전 해제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 작업 항목 CRUD
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 공정별 작업 항목 목록 조회 (phase별 그룹)
|
||||
*/
|
||||
export async function getWorkItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { routingDetailId } = req.params;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
wi.id,
|
||||
wi.routing_detail_id,
|
||||
wi.work_phase,
|
||||
wi.title,
|
||||
wi.is_required,
|
||||
wi.sort_order,
|
||||
wi.description,
|
||||
wi.created_date,
|
||||
(SELECT COUNT(*) FROM process_work_item_detail d
|
||||
WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code
|
||||
)::integer AS detail_count
|
||||
FROM process_work_item wi
|
||||
WHERE wi.routing_detail_id = $1 AND wi.company_code = $2
|
||||
ORDER BY wi.work_phase, wi.sort_order, wi.created_date
|
||||
`;
|
||||
|
||||
const result = await getPool().query(query, [routingDetailId, companyCode]);
|
||||
|
||||
// phase별 그룹핑
|
||||
const grouped: Record<string, any[]> = {};
|
||||
for (const row of result.rows) {
|
||||
const phase = row.work_phase;
|
||||
if (!grouped[phase]) grouped[phase] = [];
|
||||
grouped[phase].push(row);
|
||||
}
|
||||
|
||||
return res.json({ success: true, data: grouped, items: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("작업 항목 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 항목 추가
|
||||
*/
|
||||
export async function createWorkItem(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const writer = req.user?.userId;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { routing_detail_id, work_phase, title, is_required, sort_order, description } = req.body;
|
||||
|
||||
if (!routing_detail_id || !work_phase || !title) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "routing_detail_id, work_phase, title은 필수입니다",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO process_work_item
|
||||
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await getPool().query(query, [
|
||||
companyCode,
|
||||
routing_detail_id,
|
||||
work_phase,
|
||||
title,
|
||||
is_required || "N",
|
||||
sort_order || 0,
|
||||
description || null,
|
||||
writer,
|
||||
]);
|
||||
|
||||
logger.info("작업 항목 생성", { companyCode, id: result.rows[0].id });
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("작업 항목 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 항목 수정
|
||||
*/
|
||||
export async function updateWorkItem(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { title, is_required, sort_order, description } = req.body;
|
||||
|
||||
const query = `
|
||||
UPDATE process_work_item
|
||||
SET title = COALESCE($1, title),
|
||||
is_required = COALESCE($2, is_required),
|
||||
sort_order = COALESCE($3, sort_order),
|
||||
description = COALESCE($4, description),
|
||||
updated_date = NOW()
|
||||
WHERE id = $5 AND company_code = $6
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await getPool().query(query, [
|
||||
title,
|
||||
is_required,
|
||||
sort_order,
|
||||
description,
|
||||
id,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
logger.info("작업 항목 수정", { companyCode, id });
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("작업 항목 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 항목 삭제 (상세도 함께 삭제)
|
||||
*/
|
||||
export async function deleteWorkItem(req: AuthenticatedRequest, res: Response) {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 상세 먼저 삭제
|
||||
await client.query(
|
||||
"DELETE FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
// 항목 삭제
|
||||
const result = await client.query(
|
||||
"DELETE FROM process_work_item WHERE id = $1 AND company_code = $2 RETURNING id",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(404).json({ success: false, message: "항목을 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("작업 항목 삭제", { companyCode, id });
|
||||
return res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("작업 항목 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 작업 항목 상세 CRUD
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 작업 항목 상세 목록 조회
|
||||
*/
|
||||
export async function getWorkItemDetails(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { workItemId } = req.params;
|
||||
|
||||
const query = `
|
||||
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields,
|
||||
created_date
|
||||
FROM process_work_item_detail
|
||||
WHERE work_item_id = $1 AND company_code = $2
|
||||
ORDER BY sort_order, created_date
|
||||
`;
|
||||
|
||||
const result = await getPool().query(query, [workItemId, companyCode]);
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("작업 항목 상세 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 항목 상세 추가
|
||||
*/
|
||||
export async function createWorkItemDetail(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const writer = req.user?.userId;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const {
|
||||
work_item_id, detail_type, content, is_required, sort_order, remark,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields,
|
||||
} = req.body;
|
||||
|
||||
if (!work_item_id || !content) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "work_item_id, content는 필수입니다",
|
||||
});
|
||||
}
|
||||
|
||||
// work_item이 같은 company_code인지 검증
|
||||
const ownerCheck = await getPool().query(
|
||||
"SELECT id FROM process_work_item WHERE id = $1 AND company_code = $2",
|
||||
[work_item_id, companyCode]
|
||||
);
|
||||
if (ownerCheck.rowCount === 0) {
|
||||
return res.status(403).json({ success: false, message: "권한이 없습니다" });
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO process_work_item_detail
|
||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await getPool().query(query, [
|
||||
companyCode,
|
||||
work_item_id,
|
||||
detail_type || null,
|
||||
content,
|
||||
is_required || "N",
|
||||
sort_order || 0,
|
||||
remark || null,
|
||||
writer,
|
||||
inspection_code || null,
|
||||
inspection_method || null,
|
||||
unit || null,
|
||||
lower_limit || null,
|
||||
upper_limit || null,
|
||||
duration_minutes || null,
|
||||
input_type || null,
|
||||
lookup_target || null,
|
||||
display_fields || null,
|
||||
]);
|
||||
|
||||
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("작업 항목 상세 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 항목 상세 수정
|
||||
*/
|
||||
export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const {
|
||||
detail_type, content, is_required, sort_order, remark,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields,
|
||||
} = req.body;
|
||||
|
||||
const query = `
|
||||
UPDATE process_work_item_detail
|
||||
SET detail_type = COALESCE($1, detail_type),
|
||||
content = COALESCE($2, content),
|
||||
is_required = COALESCE($3, is_required),
|
||||
sort_order = COALESCE($4, sort_order),
|
||||
remark = COALESCE($5, remark),
|
||||
inspection_code = $8,
|
||||
inspection_method = $9,
|
||||
unit = $10,
|
||||
lower_limit = $11,
|
||||
upper_limit = $12,
|
||||
duration_minutes = $13,
|
||||
input_type = $14,
|
||||
lookup_target = $15,
|
||||
display_fields = $16,
|
||||
updated_date = NOW()
|
||||
WHERE id = $6 AND company_code = $7
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await getPool().query(query, [
|
||||
detail_type,
|
||||
content,
|
||||
is_required,
|
||||
sort_order,
|
||||
remark,
|
||||
id,
|
||||
companyCode,
|
||||
inspection_code || null,
|
||||
inspection_method || null,
|
||||
unit || null,
|
||||
lower_limit || null,
|
||||
upper_limit || null,
|
||||
duration_minutes || null,
|
||||
input_type || null,
|
||||
lookup_target || null,
|
||||
display_fields || null,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
logger.info("작업 항목 상세 수정", { companyCode, id });
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("작업 항목 상세 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 작업 항목 상세 삭제
|
||||
*/
|
||||
export async function deleteWorkItemDetail(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await getPool().query(
|
||||
"DELETE FROM process_work_item_detail WHERE id = $1 AND company_code = $2 RETURNING id",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "상세를 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
logger.info("작업 항목 상세 삭제", { companyCode, id });
|
||||
return res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("작업 항목 상세 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 전체 저장 (일괄)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 전체 저장: 작업 항목 + 상세를 일괄 저장
|
||||
* 기존 데이터를 삭제하고 새로 삽입하는 replace 방식
|
||||
*/
|
||||
export async function saveAll(req: AuthenticatedRequest, res: Response) {
|
||||
const client = await getPool().connect();
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
const writer = req.user?.userId;
|
||||
if (!companyCode) {
|
||||
return res.status(401).json({ success: false, message: "인증 필요" });
|
||||
}
|
||||
|
||||
const { routing_detail_id, items } = req.body;
|
||||
|
||||
if (!routing_detail_id || !Array.isArray(items)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "routing_detail_id와 items 배열이 필요합니다",
|
||||
});
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 기존 상세 삭제
|
||||
await client.query(
|
||||
`DELETE FROM process_work_item_detail
|
||||
WHERE work_item_id IN (
|
||||
SELECT id FROM process_work_item
|
||||
WHERE routing_detail_id = $1 AND company_code = $2
|
||||
)`,
|
||||
[routing_detail_id, companyCode]
|
||||
);
|
||||
|
||||
// 기존 항목 삭제
|
||||
await client.query(
|
||||
"DELETE FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2",
|
||||
[routing_detail_id, companyCode]
|
||||
);
|
||||
|
||||
// 새 항목 + 상세 삽입
|
||||
for (const item of items) {
|
||||
const itemResult = await client.query(
|
||||
`INSERT INTO process_work_item
|
||||
(company_code, routing_detail_id, work_phase, title, is_required, sort_order, description, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id`,
|
||||
[
|
||||
companyCode,
|
||||
routing_detail_id,
|
||||
item.work_phase,
|
||||
item.title,
|
||||
item.is_required || "N",
|
||||
item.sort_order || 0,
|
||||
item.description || null,
|
||||
writer,
|
||||
]
|
||||
);
|
||||
|
||||
const workItemId = itemResult.rows[0].id;
|
||||
|
||||
if (Array.isArray(item.details)) {
|
||||
for (const detail of item.details) {
|
||||
await client.query(
|
||||
`INSERT INTO process_work_item_detail
|
||||
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
|
||||
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
||||
duration_minutes, input_type, lookup_target, display_fields)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||
[
|
||||
companyCode,
|
||||
workItemId,
|
||||
detail.detail_type || null,
|
||||
detail.content,
|
||||
detail.is_required || "N",
|
||||
detail.sort_order || 0,
|
||||
detail.remark || null,
|
||||
writer,
|
||||
detail.inspection_code || null,
|
||||
detail.inspection_method || null,
|
||||
detail.unit || null,
|
||||
detail.lower_limit || null,
|
||||
detail.upper_limit || null,
|
||||
detail.duration_minutes || null,
|
||||
detail.input_type || null,
|
||||
detail.lookup_target || null,
|
||||
detail.display_fields || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
logger.info("작업기준 전체 저장", { companyCode, routing_detail_id, itemCount: items.length });
|
||||
return res.json({ success: true, message: "저장 완료" });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("작업기준 전체 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
|
@ -1493,13 +1493,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)
|
||||
|
|
@ -1512,7 +1512,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',
|
||||
|
|
@ -1535,7 +1535,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
|
||||
|
|
@ -1548,7 +1548,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
|
||||
|
|
@ -1561,7 +1561,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
|
||||
|
|
@ -1574,7 +1574,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
|
||||
|
|
@ -1755,7 +1755,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
|
||||
|
|
|
|||
|
|
@ -733,6 +733,133 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) =>
|
|||
}
|
||||
};
|
||||
|
||||
// 레이어 목록 조회
|
||||
export const getScreenLayers = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const layers = await screenManagementService.getScreenLayers(parseInt(screenId), companyCode);
|
||||
res.json({ success: true, data: layers });
|
||||
} catch (error) {
|
||||
console.error("레이어 목록 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "레이어 목록 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 레이어 레이아웃 조회
|
||||
export const getLayerLayout = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, layerId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const layout = await screenManagementService.getLayerLayout(parseInt(screenId), parseInt(layerId), companyCode);
|
||||
res.json({ success: true, data: layout });
|
||||
} catch (error) {
|
||||
console.error("레이어 레이아웃 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "레이어 레이아웃 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 레이어 삭제
|
||||
export const deleteLayer = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, layerId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
await screenManagementService.deleteLayer(parseInt(screenId), parseInt(layerId), companyCode);
|
||||
res.json({ success: true, message: "레이어가 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
console.error("레이어 삭제 실패:", error);
|
||||
res.status(400).json({ success: false, message: error.message || "레이어 삭제에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// 레이어 조건 설정 업데이트
|
||||
export const updateLayerCondition = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, layerId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { conditionConfig, layerName } = req.body;
|
||||
await screenManagementService.updateLayerCondition(
|
||||
parseInt(screenId), parseInt(layerId), companyCode, conditionConfig, layerName
|
||||
);
|
||||
res.json({ success: true, message: "레이어 조건이 업데이트되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("레이어 조건 업데이트 실패:", error);
|
||||
res.status(500).json({ success: false, message: "레이어 조건 업데이트에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// 조건부 영역(Zone) 관리
|
||||
// ========================================
|
||||
|
||||
// Zone 목록 조회
|
||||
export const getScreenZones = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const zones = await screenManagementService.getScreenZones(parseInt(screenId), companyCode);
|
||||
res.json({ success: true, data: zones });
|
||||
} catch (error) {
|
||||
console.error("Zone 목록 조회 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone 목록 조회에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// Zone 생성
|
||||
export const createZone = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const zone = await screenManagementService.createZone(parseInt(screenId), companyCode, req.body);
|
||||
res.json({ success: true, data: zone });
|
||||
} catch (error) {
|
||||
console.error("Zone 생성 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone 생성에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// Zone 업데이트 (위치/크기/트리거)
|
||||
export const updateZone = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { zoneId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
await screenManagementService.updateZone(parseInt(zoneId), companyCode, req.body);
|
||||
res.json({ success: true, message: "Zone이 업데이트되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("Zone 업데이트 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone 업데이트에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// Zone 삭제
|
||||
export const deleteZone = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { zoneId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
await screenManagementService.deleteZone(parseInt(zoneId), companyCode);
|
||||
res.json({ success: true, message: "Zone이 삭제되었습니다." });
|
||||
} catch (error) {
|
||||
console.error("Zone 삭제 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone 삭제에 실패했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// Zone에 레이어 추가
|
||||
export const addLayerToZone = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { screenId, zoneId } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
const { conditionValue, layerName } = req.body;
|
||||
const result = await screenManagementService.addLayerToZone(
|
||||
parseInt(screenId), companyCode, parseInt(zoneId), conditionValue, layerName
|
||||
);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error("Zone 레이어 추가 실패:", error);
|
||||
res.status(500).json({ success: false, message: "Zone에 레이어를 추가하지 못했습니다." });
|
||||
}
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// POP 레이아웃 관리 (모바일/태블릿)
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -921,14 +921,51 @@ export async function addTableData(
|
|||
}
|
||||
}
|
||||
|
||||
// 회사별 NOT NULL 소프트 제약조건 검증
|
||||
const notNullViolations = await tableManagementService.validateNotNullConstraints(
|
||||
tableName,
|
||||
data,
|
||||
companyCode || "*"
|
||||
);
|
||||
if (notNullViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
|
||||
error: {
|
||||
code: "NOT_NULL_VIOLATION",
|
||||
details: notNullViolations,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사별 UNIQUE 소프트 제약조건 검증
|
||||
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
|
||||
tableName,
|
||||
data,
|
||||
companyCode || "*"
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
error: {
|
||||
code: "UNIQUE_VIOLATION",
|
||||
details: uniqueViolations,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 추가
|
||||
await tableManagementService.addTableData(tableName, data);
|
||||
const result = await tableManagementService.addTableData(tableName, data);
|
||||
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
const response: ApiResponse<{ id: string | null }> = {
|
||||
success: true,
|
||||
message: "테이블 데이터를 성공적으로 추가했습니다.",
|
||||
data: { id: result.insertedId },
|
||||
};
|
||||
|
||||
res.status(201).json(response);
|
||||
|
|
@ -1003,6 +1040,45 @@ export async function editTableData(
|
|||
}
|
||||
|
||||
const tableManagementService = new TableManagementService();
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
// 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상)
|
||||
const notNullViolations = await tableManagementService.validateNotNullConstraints(
|
||||
tableName,
|
||||
updatedData,
|
||||
companyCode
|
||||
);
|
||||
if (notNullViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
|
||||
error: {
|
||||
code: "NOT_NULL_VIOLATION",
|
||||
details: notNullViolations,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외)
|
||||
const excludeId = originalData?.id ? String(originalData.id) : undefined;
|
||||
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
|
||||
tableName,
|
||||
updatedData,
|
||||
companyCode,
|
||||
excludeId
|
||||
);
|
||||
if (uniqueViolations.length > 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
|
||||
error: {
|
||||
code: "UNIQUE_VIOLATION",
|
||||
details: uniqueViolations,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터 수정
|
||||
await tableManagementService.editTableData(
|
||||
|
|
@ -1693,6 +1769,7 @@ export async function getCategoryColumnsByCompany(
|
|||
let columnsResult;
|
||||
|
||||
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
|
||||
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
|
||||
if (companyCode === "*") {
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
|
|
@ -1712,15 +1789,15 @@ export async function getCategoryColumnsByCompany(
|
|||
ON ttc.table_name = tl.table_name
|
||||
WHERE ttc.input_type = 'category'
|
||||
AND ttc.company_code = '*'
|
||||
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
`;
|
||||
|
||||
columnsResult = await pool.query(columnsQuery);
|
||||
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
|
||||
logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||
rowCount: columnsResult.rows.length
|
||||
});
|
||||
} else {
|
||||
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
ttc.table_name AS "tableName",
|
||||
|
|
@ -1739,11 +1816,12 @@ export async function getCategoryColumnsByCompany(
|
|||
ON ttc.table_name = tl.table_name
|
||||
WHERE ttc.input_type = 'category'
|
||||
AND ttc.company_code = $1
|
||||
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
`;
|
||||
|
||||
columnsResult = await pool.query(columnsQuery, [companyCode]);
|
||||
logger.info("✅ 회사별 카테고리 컬럼 조회 완료", {
|
||||
logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||
companyCode,
|
||||
rowCount: columnsResult.rows.length
|
||||
});
|
||||
|
|
@ -1804,13 +1882,10 @@ export async function getCategoryColumnsByMenu(
|
|||
const { getPool } = await import("../database/db");
|
||||
const pool = getPool();
|
||||
|
||||
// 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회
|
||||
// category_column_mapping 대신 table_type_columns 기준으로 조회
|
||||
logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
|
||||
|
||||
// table_type_columns에서 input_type = 'category' 컬럼 조회
|
||||
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
|
||||
let columnsResult;
|
||||
|
||||
// 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회
|
||||
if (companyCode === "*") {
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
|
|
@ -1830,15 +1905,15 @@ export async function getCategoryColumnsByMenu(
|
|||
ON ttc.table_name = tl.table_name
|
||||
WHERE ttc.input_type = 'category'
|
||||
AND ttc.company_code = '*'
|
||||
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
`;
|
||||
|
||||
columnsResult = await pool.query(columnsQuery);
|
||||
logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", {
|
||||
logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||
rowCount: columnsResult.rows.length
|
||||
});
|
||||
} else {
|
||||
// 일반 회사: 해당 회사의 카테고리 컬럼만 조회
|
||||
const columnsQuery = `
|
||||
SELECT DISTINCT
|
||||
ttc.table_name AS "tableName",
|
||||
|
|
@ -1857,11 +1932,12 @@ export async function getCategoryColumnsByMenu(
|
|||
ON ttc.table_name = tl.table_name
|
||||
WHERE ttc.input_type = 'category'
|
||||
AND ttc.company_code = $1
|
||||
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
|
||||
ORDER BY ttc.table_name, ttc.column_name
|
||||
`;
|
||||
|
||||
columnsResult = await pool.query(columnsQuery, [companyCode]);
|
||||
logger.info("✅ 회사별 카테고리 컬럼 조회 완료", {
|
||||
logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", {
|
||||
companyCode,
|
||||
rowCount: columnsResult.rows.length
|
||||
});
|
||||
|
|
@ -2447,3 +2523,425 @@ 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") {
|
||||
let indexColumns = `"${columnName}"`;
|
||||
|
||||
// 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장)
|
||||
if (indexType === "unique") {
|
||||
const hasCompanyCode = await query(
|
||||
`SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`,
|
||||
[tableName]
|
||||
);
|
||||
if (hasCompanyCode.length > 0) {
|
||||
indexColumns = `"company_code", "${columnName}"`;
|
||||
logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
|
||||
const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`;
|
||||
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);
|
||||
|
||||
const errMsg = error.message || "";
|
||||
let userMessage = "인덱스 설정 중 오류가 발생했습니다.";
|
||||
let duplicates: any[] = [];
|
||||
|
||||
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패
|
||||
if (
|
||||
errMsg.includes("could not create unique index") ||
|
||||
errMsg.includes("duplicate key")
|
||||
) {
|
||||
const { columnName, tableName } = { ...req.params, ...req.body };
|
||||
try {
|
||||
duplicates = await query(
|
||||
`SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
duplicates = await query(
|
||||
`SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
|
||||
);
|
||||
} catch { /* 중복 조회 실패 시 무시 */ }
|
||||
}
|
||||
|
||||
const dupDetails = duplicates.length > 0
|
||||
? duplicates.map((d: any) => {
|
||||
const company = d.company_code ? `[${d.company_code}] ` : "";
|
||||
return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`;
|
||||
}).join(", ")
|
||||
: "";
|
||||
|
||||
userMessage = dupDetails
|
||||
? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}`
|
||||
: `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`;
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: userMessage,
|
||||
error: errMsg,
|
||||
duplicates,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NOT NULL 토글 (회사별 소프트 제약조건)
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
|
||||
*
|
||||
* DB 레벨 ALTER TABLE 대신 table_type_columns.is_nullable을 회사별로 관리한다.
|
||||
* 멀티테넌시 환경에서 회사 A는 NOT NULL, 회사 B는 NULL 허용이 가능하다.
|
||||
*/
|
||||
export async function toggleColumnNullable(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { nullable } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!tableName || !columnName || typeof nullable !== "boolean") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, columnName, nullable(boolean)이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL
|
||||
const isNullableValue = nullable ? "Y" : "N";
|
||||
|
||||
if (!nullable) {
|
||||
// NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인
|
||||
const hasCompanyCode = await query<{ column_name: string }>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
if (hasCompanyCode.length > 0) {
|
||||
const nullCheckQuery = companyCode === "*"
|
||||
? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL`
|
||||
: `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`;
|
||||
const nullCheckParams = companyCode === "*" ? [] : [companyCode];
|
||||
|
||||
const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams);
|
||||
const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10);
|
||||
|
||||
if (nullCount > 0) {
|
||||
logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, {
|
||||
companyCode,
|
||||
nullCount,
|
||||
});
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// table_type_columns에 회사별 is_nullable 설정 UPSERT
|
||||
await query(
|
||||
`INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET is_nullable = $3, updated_date = NOW()`,
|
||||
[tableName, columnName, isNullableValue, companyCode]
|
||||
);
|
||||
|
||||
logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, {
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: nullable
|
||||
? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.`
|
||||
: `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("NOT NULL 토글 오류:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "NOT NULL 설정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UNIQUE 토글 (회사별 소프트 제약조건)
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
|
||||
*
|
||||
* DB 레벨 인덱스 대신 table_type_columns.is_unique를 회사별로 관리한다.
|
||||
* 저장 시 앱 레벨에서 중복 검증을 수행한다.
|
||||
*/
|
||||
export async function toggleColumnUnique(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const { unique } = req.body;
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
|
||||
if (!tableName || !columnName || typeof unique !== "boolean") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName, columnName, unique(boolean)이 필요합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isUniqueValue = unique ? "Y" : "N";
|
||||
|
||||
if (unique) {
|
||||
// UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인
|
||||
const hasCompanyCode = await query<{ column_name: string }>(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
if (hasCompanyCode.length > 0) {
|
||||
const dupQuery = companyCode === "*"
|
||||
? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`
|
||||
: `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`;
|
||||
const dupParams = companyCode === "*" ? [] : [companyCode];
|
||||
|
||||
const dupResult = await query<any>(dupQuery, dupParams);
|
||||
|
||||
if (dupResult.length > 0) {
|
||||
const dupDetails = dupResult
|
||||
.map((d: any) => `"${d[columnName]}" (${d.cnt}건)`)
|
||||
.join(", ");
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// table_type_columns에 회사별 is_unique 설정 UPSERT
|
||||
await query(
|
||||
`INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET is_unique = $3, updated_date = NOW()`,
|
||||
[tableName, columnName, isUniqueValue, companyCode]
|
||||
);
|
||||
|
||||
logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, {
|
||||
companyCode,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: unique
|
||||
? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.`
|
||||
: `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("UNIQUE 토글 오류:", error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "UNIQUE 설정 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@ import {
|
|||
PoolClient,
|
||||
QueryResult as PgQueryResult,
|
||||
QueryResultRow,
|
||||
types,
|
||||
} from "pg";
|
||||
import config from "../config/environment";
|
||||
|
||||
// DATE 타입(OID 1082)을 문자열로 반환 (타임존 변환에 의한 -1day 버그 방지)
|
||||
types.setTypeParser(1082, (val: string) => val);
|
||||
|
||||
// PostgreSQL 연결 풀
|
||||
let pool: Pool | null = null;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* BOM 이력/버전 관리 라우트
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as bomController from "../controllers/bomController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// BOM 헤더 (entity join 포함)
|
||||
router.get("/:bomId/header", bomController.getBomHeader);
|
||||
|
||||
// 이력
|
||||
router.get("/:bomId/history", bomController.getBomHistory);
|
||||
router.post("/:bomId/history", bomController.addBomHistory);
|
||||
|
||||
// 엑셀 업로드/다운로드
|
||||
router.post("/excel-upload", bomController.createBomFromExcel);
|
||||
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
|
||||
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
|
||||
|
||||
// 버전
|
||||
router.get("/:bomId/versions", bomController.getBomVersions);
|
||||
router.post("/:bomId/versions", bomController.createBomVersion);
|
||||
router.post("/:bomId/initialize-version", bomController.initializeBomVersion);
|
||||
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
|
||||
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
|
||||
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
|
||||
|
||||
export default router;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* 공정 작업기준 라우트
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as ctrl from "../controllers/processWorkStandardController";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 품목/라우팅/공정 조회 (좌측 트리)
|
||||
router.get("/items", ctrl.getItemsWithRouting);
|
||||
router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses);
|
||||
|
||||
// 기본 버전 설정/해제
|
||||
router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion);
|
||||
router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion);
|
||||
|
||||
// 작업 항목 CRUD
|
||||
router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems);
|
||||
router.post("/work-items", ctrl.createWorkItem);
|
||||
router.put("/work-items/:id", ctrl.updateWorkItem);
|
||||
router.delete("/work-items/:id", ctrl.deleteWorkItem);
|
||||
|
||||
// 작업 항목 상세 CRUD
|
||||
router.get("/work-items/:workItemId/details", ctrl.getWorkItemDetails);
|
||||
router.post("/work-item-details", ctrl.createWorkItemDetail);
|
||||
router.put("/work-item-details/:id", ctrl.updateWorkItemDetail);
|
||||
router.delete("/work-item-details/:id", ctrl.deleteWorkItemDetail);
|
||||
|
||||
// 전체 저장 (일괄)
|
||||
router.put("/save-all", ctrl.saveAll);
|
||||
|
||||
export default router;
|
||||
|
|
@ -42,6 +42,15 @@ import {
|
|||
copyCategoryMapping,
|
||||
copyTableTypeColumns,
|
||||
copyCascadingRelation,
|
||||
getScreenLayers,
|
||||
getLayerLayout,
|
||||
deleteLayer,
|
||||
updateLayerCondition,
|
||||
getScreenZones,
|
||||
createZone,
|
||||
updateZone,
|
||||
deleteZone,
|
||||
addLayerToZone,
|
||||
analyzePopScreenLinks,
|
||||
deployPopScreens,
|
||||
} from "../controllers/screenManagementController";
|
||||
|
|
@ -90,6 +99,19 @@ router.get("/screens/:screenId/layout-v1", getLayoutV1); // V1: component_url +
|
|||
router.get("/screens/:screenId/layout-v2", getLayoutV2); // V2: 1 레코드 방식 (url + overrides)
|
||||
router.post("/screens/:screenId/layout-v2", saveLayoutV2); // V2: 1 레코드 방식 저장
|
||||
|
||||
// 레이어 관리
|
||||
router.get("/screens/:screenId/layers", getScreenLayers); // 레이어 목록
|
||||
router.get("/screens/:screenId/layers/:layerId/layout", getLayerLayout); // 특정 레이어 레이아웃
|
||||
router.delete("/screens/:screenId/layers/:layerId", deleteLayer); // 레이어 삭제
|
||||
router.put("/screens/:screenId/layers/:layerId/condition", updateLayerCondition); // 레이어 조건 설정
|
||||
|
||||
// 조건부 영역(Zone) 관리
|
||||
router.get("/screens/:screenId/zones", getScreenZones); // Zone 목록
|
||||
router.post("/screens/:screenId/zones", createZone); // Zone 생성
|
||||
router.put("/zones/:zoneId", updateZone); // Zone 업데이트
|
||||
router.delete("/zones/:zoneId", deleteZone); // Zone 삭제
|
||||
router.post("/screens/:screenId/zones/:zoneId/layers", addLayerToZone); // Zone에 레이어 추가
|
||||
|
||||
// POP 레이아웃 관리 (모바일/태블릿)
|
||||
router.get("/screens/:screenId/layout-pop", getLayoutPop); // POP: 모바일/태블릿용 레이아웃 조회
|
||||
router.post("/screens/:screenId/layout-pop", saveLayoutPop); // POP: 모바일/태블릿용 레이아웃 저장
|
||||
|
|
|
|||
|
|
@ -28,6 +28,11 @@ import {
|
|||
multiTableSave, // 🆕 범용 다중 테이블 저장
|
||||
getTableEntityRelations, // 🆕 두 테이블 간 엔티티 관계 조회
|
||||
getReferencedByTables, // 🆕 현재 테이블을 참조하는 테이블 조회
|
||||
getTableConstraints, // 🆕 PK/인덱스 상태 조회
|
||||
setTablePrimaryKey, // 🆕 PK 설정
|
||||
toggleTableIndex, // 🆕 인덱스 토글
|
||||
toggleColumnNullable, // 🆕 NOT NULL 토글
|
||||
toggleColumnUnique, // 🆕 UNIQUE 토글
|
||||
} from "../controllers/tableManagementController";
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -133,6 +138,36 @@ 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);
|
||||
|
||||
/**
|
||||
* UNIQUE 토글
|
||||
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
|
||||
*/
|
||||
router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique);
|
||||
|
||||
/**
|
||||
* 테이블 존재 여부 확인
|
||||
* GET /api/table-management/tables/:tableName/exists
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export class AdminService {
|
|||
*/
|
||||
static async getAdminMenuList(paramMap: any): Promise<any[]> {
|
||||
try {
|
||||
logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap);
|
||||
logger.debug("AdminService.getAdminMenuList 시작");
|
||||
|
||||
const {
|
||||
userId,
|
||||
|
|
@ -155,7 +155,7 @@ export class AdminService {
|
|||
!isManagementScreen
|
||||
) {
|
||||
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
|
||||
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
|
||||
logger.debug(`최고 관리자: 공통 메뉴 표시`);
|
||||
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
|
||||
unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`;
|
||||
}
|
||||
|
|
@ -168,18 +168,18 @@ export class AdminService {
|
|||
// SUPER_ADMIN
|
||||
if (isManagementScreen) {
|
||||
// 메뉴 관리 화면: 모든 메뉴
|
||||
logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||
logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||
companyFilter = "";
|
||||
} else {
|
||||
// 좌측 사이드바: 공통 메뉴만 (company_code = '*')
|
||||
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
||||
logger.debug("좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
|
||||
companyFilter = `AND MENU.COMPANY_CODE = '*'`;
|
||||
}
|
||||
} else if (isManagementScreen) {
|
||||
// 메뉴 관리 화면: 회사별 필터링
|
||||
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
|
||||
// 최고 관리자: 모든 메뉴 (공통 + 모든 회사)
|
||||
logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||
logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
|
||||
companyFilter = "";
|
||||
} else {
|
||||
// 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외)
|
||||
|
|
@ -387,16 +387,7 @@ export class AdminService {
|
|||
queryParams
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`관리자 메뉴 목록 조회 결과: ${menuList.length}개 (menuType: ${menuType || "전체"}, userType: ${userType}, company: ${userCompanyCode})`
|
||||
);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", {
|
||||
objid: menuList[0].objid,
|
||||
name: menuList[0].menu_name_kor,
|
||||
companyCode: menuList[0].company_code,
|
||||
});
|
||||
}
|
||||
logger.debug(`관리자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
||||
|
||||
return menuList;
|
||||
} catch (error) {
|
||||
|
|
@ -410,7 +401,7 @@ export class AdminService {
|
|||
*/
|
||||
static async getUserMenuList(paramMap: any): Promise<any[]> {
|
||||
try {
|
||||
logger.info("AdminService.getUserMenuList 시작 - 파라미터:", paramMap);
|
||||
logger.debug("AdminService.getUserMenuList 시작");
|
||||
|
||||
const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap;
|
||||
|
||||
|
|
@ -422,9 +413,7 @@ export class AdminService {
|
|||
|
||||
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
|
||||
// TODO: 권한 체크 다시 활성화 필요
|
||||
logger.info(
|
||||
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
|
||||
);
|
||||
logger.debug(`getUserMenuList 권한 그룹 체크 스킵 - ${userId}(${userType})`);
|
||||
authFilter = "";
|
||||
unionFilter = "";
|
||||
|
||||
|
|
@ -617,16 +606,7 @@ export class AdminService {
|
|||
queryParams
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`사용자 메뉴 목록 조회 결과: ${menuList.length}개 (userType: ${userType}, company: ${userCompanyCode})`
|
||||
);
|
||||
if (menuList.length > 0) {
|
||||
logger.info("첫 번째 메뉴:", {
|
||||
objid: menuList[0].objid,
|
||||
name: menuList[0].menu_name_kor,
|
||||
companyCode: menuList[0].company_code,
|
||||
});
|
||||
}
|
||||
logger.debug(`사용자 메뉴 목록 조회 결과: ${menuList.length}개`);
|
||||
|
||||
return menuList;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -29,12 +29,11 @@ export class AuthService {
|
|||
if (userInfo && userInfo.user_password) {
|
||||
const dbPassword = userInfo.user_password;
|
||||
|
||||
logger.info(`로그인 시도: ${userId}`);
|
||||
logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`);
|
||||
logger.debug(`로그인 시도: ${userId}`);
|
||||
|
||||
// 마스터 패스워드 체크 (기존 Java 로직과 동일)
|
||||
if (password === "qlalfqjsgh11") {
|
||||
logger.info(`마스터 패스워드로 로그인 성공: ${userId}`);
|
||||
logger.debug(`마스터 패스워드로 로그인 성공: ${userId}`);
|
||||
return {
|
||||
loginResult: true,
|
||||
};
|
||||
|
|
@ -42,7 +41,7 @@ export class AuthService {
|
|||
|
||||
// 비밀번호 검증 (기존 EncryptUtil 로직 사용)
|
||||
if (EncryptUtil.matches(password, dbPassword)) {
|
||||
logger.info(`비밀번호 일치로 로그인 성공: ${userId}`);
|
||||
logger.debug(`비밀번호 일치로 로그인 성공: ${userId}`);
|
||||
return {
|
||||
loginResult: true,
|
||||
};
|
||||
|
|
@ -98,7 +97,7 @@ export class AuthService {
|
|||
]
|
||||
);
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
|
||||
);
|
||||
} catch (error) {
|
||||
|
|
@ -225,7 +224,7 @@ export class AuthService {
|
|||
// deptCode: personBean.deptCode,
|
||||
//});
|
||||
|
||||
logger.info(`사용자 정보 조회 완료: ${userId}`);
|
||||
logger.debug(`사용자 정보 조회 완료: ${userId}`);
|
||||
return personBean;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,844 @@
|
|||
/**
|
||||
* BOM 이력 및 버전 관리 서비스
|
||||
* 행(Row) 기반 버전 관리: bom_detail.version_id로 버전별 데이터 분리
|
||||
*/
|
||||
|
||||
import { query, queryOne, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
function safeTableName(name: string, fallback: string): string {
|
||||
if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return fallback;
|
||||
return name;
|
||||
}
|
||||
|
||||
// ─── 이력 (History) ─────────────────────────────
|
||||
|
||||
export async function getBomHistory(bomId: string, companyCode: string, tableName?: string) {
|
||||
const table = safeTableName(tableName || "", "bom_history");
|
||||
const sql = companyCode === "*"
|
||||
? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY changed_date DESC`
|
||||
: `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY changed_date DESC`;
|
||||
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
|
||||
return query(sql, params);
|
||||
}
|
||||
|
||||
export async function addBomHistory(
|
||||
bomId: string,
|
||||
companyCode: string,
|
||||
data: {
|
||||
revision?: string;
|
||||
version?: string;
|
||||
change_type: string;
|
||||
change_description?: string;
|
||||
changed_by?: string;
|
||||
},
|
||||
tableName?: string,
|
||||
) {
|
||||
const table = safeTableName(tableName || "", "bom_history");
|
||||
const sql = `
|
||||
INSERT INTO ${table} (bom_id, revision, version, change_type, change_description, changed_by, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
`;
|
||||
return queryOne(sql, [
|
||||
bomId,
|
||||
data.revision || null,
|
||||
data.version || null,
|
||||
data.change_type,
|
||||
data.change_description || null,
|
||||
data.changed_by || null,
|
||||
companyCode,
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── 버전 (Version) ─────────────────────────────
|
||||
|
||||
// ─── BOM 헤더 조회 (entity join 포함) ─────────────────────────────
|
||||
|
||||
export async function getBomHeader(bomId: string, tableName?: string) {
|
||||
const table = safeTableName(tableName || "", "bom");
|
||||
const sql = `
|
||||
SELECT b.*,
|
||||
i.item_name, i.item_number, i.division as item_type,
|
||||
COALESCE(b.unit, i.unit) as unit,
|
||||
i.unit as item_unit,
|
||||
i.division, i.size, i.material
|
||||
FROM ${table} b
|
||||
LEFT JOIN item_info i ON b.item_id = i.id
|
||||
WHERE b.id = $1
|
||||
LIMIT 1
|
||||
`;
|
||||
return queryOne<Record<string, any>>(sql, [bomId]);
|
||||
}
|
||||
|
||||
export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) {
|
||||
const table = safeTableName(tableName || "", "bom_version");
|
||||
const dTable = "bom_detail";
|
||||
|
||||
// 버전 목록 + 각 버전별 디테일 건수 + 현재 활성 버전 ID
|
||||
const sql = companyCode === "*"
|
||||
? `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count
|
||||
FROM ${table} v WHERE v.bom_id = $1 ORDER BY v.created_date DESC`
|
||||
: `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count
|
||||
FROM ${table} v WHERE v.bom_id = $1 AND v.company_code = $2 ORDER BY v.created_date DESC`;
|
||||
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
|
||||
const versions = await query(sql, params);
|
||||
|
||||
// bom.current_version_id도 함께 반환
|
||||
const bomRow = await queryOne<{ current_version_id: string }>(
|
||||
`SELECT current_version_id FROM bom WHERE id = $1`, [bomId],
|
||||
);
|
||||
|
||||
return {
|
||||
versions,
|
||||
currentVersionId: bomRow?.current_version_id || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 버전 생성: 현재 활성 버전의 bom_detail 행을 복사하여 새 version_id로 INSERT
|
||||
*/
|
||||
export async function createBomVersion(
|
||||
bomId: string, companyCode: string, createdBy: string,
|
||||
versionTableName?: string, detailTableName?: string,
|
||||
inputVersionName?: string,
|
||||
) {
|
||||
const vTable = safeTableName(versionTableName || "", "bom_version");
|
||||
const dTable = safeTableName(detailTableName || "", "bom_detail");
|
||||
|
||||
return transaction(async (client) => {
|
||||
const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]);
|
||||
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
|
||||
const bomData = bomRow.rows[0];
|
||||
|
||||
// 버전명: 사용자 입력 > 순번 자동 생성
|
||||
let versionName = inputVersionName?.trim();
|
||||
if (!versionName) {
|
||||
const countResult = await client.query(
|
||||
`SELECT COUNT(*)::int as cnt FROM ${vTable} WHERE bom_id = $1`,
|
||||
[bomId],
|
||||
);
|
||||
versionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
const dupCheck = await client.query(
|
||||
`SELECT id FROM ${vTable} WHERE bom_id = $1 AND version_name = $2`,
|
||||
[bomId, versionName],
|
||||
);
|
||||
if (dupCheck.rows.length > 0) {
|
||||
throw new Error(`이미 존재하는 버전명입니다: ${versionName}`);
|
||||
}
|
||||
|
||||
// 새 버전 레코드 생성 (snapshot_data 없이)
|
||||
const insertSql = `
|
||||
INSERT INTO ${vTable} (bom_id, version_name, revision, status, created_by, company_code)
|
||||
VALUES ($1, $2, $3, 'developing', $4, $5)
|
||||
RETURNING *
|
||||
`;
|
||||
const newVersion = await client.query(insertSql, [
|
||||
bomId,
|
||||
versionName,
|
||||
bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0,
|
||||
createdBy,
|
||||
companyCode,
|
||||
]);
|
||||
const newVersionId = newVersion.rows[0].id;
|
||||
|
||||
// 현재 활성 버전의 bom_detail 행을 복사
|
||||
const sourceVersionId = bomData.current_version_id;
|
||||
if (sourceVersionId) {
|
||||
const sourceDetails = await client.query(
|
||||
`SELECT * FROM ${dTable} WHERE bom_id = $1 AND version_id = $2 ORDER BY parent_detail_id NULLS FIRST, id`,
|
||||
[bomId, sourceVersionId],
|
||||
);
|
||||
|
||||
// old ID → new ID 매핑 (parent_detail_id 유지)
|
||||
const oldToNew: Record<string, string> = {};
|
||||
for (const d of sourceDetails.rows) {
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO ${dTable} (bom_id, version_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, seq_no, writer, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id`,
|
||||
[
|
||||
bomId,
|
||||
newVersionId,
|
||||
d.parent_detail_id ? (oldToNew[d.parent_detail_id] || null) : null,
|
||||
d.child_item_id,
|
||||
d.quantity,
|
||||
d.unit,
|
||||
d.process_type,
|
||||
d.loss_rate,
|
||||
d.remark,
|
||||
d.level,
|
||||
d.base_qty,
|
||||
d.revision,
|
||||
d.seq_no,
|
||||
d.writer,
|
||||
companyCode,
|
||||
],
|
||||
);
|
||||
oldToNew[d.id] = insertResult.rows[0].id;
|
||||
}
|
||||
|
||||
logger.info("BOM 버전 생성 - 디테일 복사 완료", {
|
||||
bomId, versionName, sourceVersionId, copiedCount: sourceDetails.rows.length,
|
||||
});
|
||||
}
|
||||
|
||||
// BOM 헤더의 version과 current_version_id 갱신
|
||||
await client.query(
|
||||
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
|
||||
[versionName, newVersionId, bomId],
|
||||
);
|
||||
|
||||
logger.info("BOM 버전 생성 완료", { bomId, versionName, newVersionId, companyCode });
|
||||
return newVersion.rows[0];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 불러오기: bom_detail 삭제/복원 없이 current_version_id만 전환
|
||||
*/
|
||||
export async function loadBomVersion(
|
||||
bomId: string, versionId: string, companyCode: string,
|
||||
versionTableName?: string, _detailTableName?: string,
|
||||
) {
|
||||
const vTable = safeTableName(versionTableName || "", "bom_version");
|
||||
|
||||
return transaction(async (client) => {
|
||||
const verRow = await client.query(
|
||||
`SELECT * FROM ${vTable} WHERE id = $1 AND bom_id = $2`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
|
||||
|
||||
const versionName = verRow.rows[0].version_name;
|
||||
|
||||
// BOM 헤더의 version과 current_version_id만 전환
|
||||
await client.query(
|
||||
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
|
||||
[versionName, versionId, bomId],
|
||||
);
|
||||
|
||||
logger.info("BOM 버전 불러오기 완료", { bomId, versionId, versionName });
|
||||
return { restored: true, versionName };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 확정: 선택 버전을 active로 변경 + current_version_id 갱신
|
||||
*/
|
||||
export async function activateBomVersion(bomId: string, versionId: string, tableName?: string) {
|
||||
const table = safeTableName(tableName || "", "bom_version");
|
||||
|
||||
return transaction(async (client) => {
|
||||
const verRow = await client.query(
|
||||
`SELECT version_name FROM ${table} WHERE id = $1 AND bom_id = $2`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
|
||||
|
||||
// 기존 active -> inactive
|
||||
await client.query(
|
||||
`UPDATE ${table} SET status = 'inactive' WHERE bom_id = $1 AND status = 'active'`,
|
||||
[bomId],
|
||||
);
|
||||
// 선택한 버전 -> active
|
||||
await client.query(
|
||||
`UPDATE ${table} SET status = 'active' WHERE id = $1`,
|
||||
[versionId],
|
||||
);
|
||||
// BOM 헤더 갱신
|
||||
const versionName = verRow.rows[0].version_name;
|
||||
await client.query(
|
||||
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
|
||||
[versionName, versionId, bomId],
|
||||
);
|
||||
|
||||
logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName });
|
||||
return { activated: true, versionName };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규 BOM 초기화: 첫 번째 버전 자동 생성 + version_id null인 디테일 보정
|
||||
* BOM 헤더의 version 필드를 그대로 버전명으로 사용 (사용자 입력값 존중)
|
||||
*/
|
||||
export async function initializeBomVersion(
|
||||
bomId: string, companyCode: string, createdBy: string,
|
||||
) {
|
||||
return transaction(async (client) => {
|
||||
const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]);
|
||||
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
|
||||
const bomData = bomRow.rows[0];
|
||||
|
||||
if (bomData.current_version_id) {
|
||||
await client.query(
|
||||
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
|
||||
[bomData.current_version_id, bomId],
|
||||
);
|
||||
return { versionId: bomData.current_version_id, created: false };
|
||||
}
|
||||
|
||||
// 이미 버전 레코드가 존재하는지 확인 (동시 호출 방지)
|
||||
const existingVersion = await client.query(
|
||||
`SELECT id, version_name FROM bom_version WHERE bom_id = $1 ORDER BY created_date ASC LIMIT 1`,
|
||||
[bomId],
|
||||
);
|
||||
if (existingVersion.rows.length > 0) {
|
||||
const existId = existingVersion.rows[0].id;
|
||||
await client.query(
|
||||
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
|
||||
[existId, bomId],
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE bom SET current_version_id = $1 WHERE id = $2 AND current_version_id IS NULL`,
|
||||
[existId, bomId],
|
||||
);
|
||||
return { versionId: existId, created: false };
|
||||
}
|
||||
|
||||
const versionName = bomData.version || "1.0";
|
||||
|
||||
const versionResult = await client.query(
|
||||
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
|
||||
VALUES ($1, $2, 0, 'active', $3, $4) RETURNING id`,
|
||||
[bomId, versionName, createdBy, companyCode],
|
||||
);
|
||||
const versionId = versionResult.rows[0].id;
|
||||
|
||||
const updated = await client.query(
|
||||
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
|
||||
logger.info("BOM 초기 버전 생성", { bomId, versionId, versionName, updatedDetails: updated.rowCount });
|
||||
return { versionId, versionName, created: true };
|
||||
});
|
||||
}
|
||||
|
||||
// ─── BOM 엑셀 업로드 ─────────────────────────────
|
||||
|
||||
interface BomExcelRow {
|
||||
level: number;
|
||||
item_number: string;
|
||||
item_name?: string;
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
process_type?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
interface BomExcelUploadResult {
|
||||
success: boolean;
|
||||
insertedCount: number;
|
||||
skippedCount: number;
|
||||
errors: string[];
|
||||
unmatchedItems: string[];
|
||||
createdBomId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 엑셀 업로드 - 새 BOM 생성
|
||||
*
|
||||
* 엑셀 레벨 체계:
|
||||
* 레벨 0 = BOM 마스터 (최상위 품목) → bom 테이블에 INSERT
|
||||
* 레벨 1 = 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0)
|
||||
* 레벨 2 = 자품목의 자품목 → bom_detail (parent_detail_id=부모ID, DB level=1)
|
||||
* 레벨 N = ... → bom_detail (DB level=N-1)
|
||||
*/
|
||||
export async function createBomFromExcel(
|
||||
companyCode: string,
|
||||
userId: string,
|
||||
rows: BomExcelRow[],
|
||||
): Promise<BomExcelUploadResult> {
|
||||
const result: BomExcelUploadResult = {
|
||||
success: false,
|
||||
insertedCount: 0,
|
||||
skippedCount: 0,
|
||||
errors: [],
|
||||
unmatchedItems: [],
|
||||
};
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
result.errors.push("업로드할 데이터가 없습니다");
|
||||
return result;
|
||||
}
|
||||
|
||||
const headerRow = rows.find(r => r.level === 0);
|
||||
const detailRows = rows.filter(r => r.level > 0);
|
||||
|
||||
if (!headerRow) {
|
||||
result.errors.push("레벨 0(BOM 마스터) 행이 필요합니다");
|
||||
return result;
|
||||
}
|
||||
if (!headerRow.item_number?.trim()) {
|
||||
result.errors.push("레벨 0(BOM 마스터)의 품번은 필수입니다");
|
||||
return result;
|
||||
}
|
||||
if (detailRows.length === 0) {
|
||||
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 레벨 유효성 검사
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (row.level < 0) {
|
||||
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
|
||||
}
|
||||
if (i > 0 && row.level > rows[i - 1].level + 1) {
|
||||
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다 (현재: ${row.level}, 이전: ${rows[i - 1].level})`);
|
||||
}
|
||||
if (row.level > 0 && !row.item_number?.trim()) {
|
||||
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return transaction(async (client) => {
|
||||
// 1. 모든 품번 일괄 조회 (헤더 + 디테일)
|
||||
const allItemNumbers = [...new Set(rows.filter(r => r.item_number?.trim()).map(r => r.item_number.trim()))];
|
||||
const itemLookup = await client.query(
|
||||
`SELECT id, item_number, item_name, unit FROM item_info
|
||||
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
|
||||
[companyCode, allItemNumbers],
|
||||
);
|
||||
|
||||
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
|
||||
for (const item of itemLookup.rows) {
|
||||
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
|
||||
}
|
||||
|
||||
for (const num of allItemNumbers) {
|
||||
if (!itemMap.has(num)) {
|
||||
result.unmatchedItems.push(num);
|
||||
}
|
||||
}
|
||||
if (result.unmatchedItems.length > 0) {
|
||||
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. bom 마스터 생성 (레벨 0)
|
||||
const headerItemInfo = itemMap.get(headerRow.item_number.trim())!;
|
||||
|
||||
// 동일 품목으로 이미 BOM이 존재하는지 확인
|
||||
const dupCheck = await client.query(
|
||||
`SELECT id FROM bom WHERE item_id = $1 AND company_code = $2 AND status = 'active'`,
|
||||
[headerItemInfo.id, companyCode],
|
||||
);
|
||||
if (dupCheck.rows.length > 0) {
|
||||
result.errors.push(`해당 품목(${headerRow.item_number})으로 등록된 BOM이 이미 존재합니다`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const bomInsert = await client.query(
|
||||
`INSERT INTO bom (item_id, item_code, item_name, base_qty, unit, version, status, remark, writer, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, '1.0', 'active', $6, $7, $8)
|
||||
RETURNING id`,
|
||||
[
|
||||
headerItemInfo.id,
|
||||
headerRow.item_number.trim(),
|
||||
headerItemInfo.item_name,
|
||||
String(headerRow.quantity || 1),
|
||||
headerRow.unit || headerItemInfo.unit || null,
|
||||
headerRow.remark || null,
|
||||
userId,
|
||||
companyCode,
|
||||
],
|
||||
);
|
||||
const newBomId = bomInsert.rows[0].id;
|
||||
result.createdBomId = newBomId;
|
||||
|
||||
// 3. bom_version 생성
|
||||
const versionInsert = await client.query(
|
||||
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
|
||||
VALUES ($1, '1.0', 0, 'active', $2, $3) RETURNING id`,
|
||||
[newBomId, userId, companyCode],
|
||||
);
|
||||
const versionId = versionInsert.rows[0].id;
|
||||
|
||||
await client.query(
|
||||
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
|
||||
[versionId, newBomId],
|
||||
);
|
||||
|
||||
// 4. bom_detail INSERT (레벨 1+ → DB level = 엑셀 level - 1)
|
||||
const levelStack: string[] = [];
|
||||
const seqCounterByParent = new Map<string, number>();
|
||||
|
||||
for (let i = 0; i < detailRows.length; i++) {
|
||||
const row = detailRows[i];
|
||||
const itemInfo = itemMap.get(row.item_number.trim())!;
|
||||
const dbLevel = row.level - 1;
|
||||
|
||||
while (levelStack.length > dbLevel) {
|
||||
levelStack.pop();
|
||||
}
|
||||
|
||||
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
|
||||
const parentKey = parentDetailId || "__root__";
|
||||
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
|
||||
seqCounterByParent.set(parentKey, currentSeq);
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
|
||||
RETURNING id`,
|
||||
[
|
||||
newBomId,
|
||||
versionId,
|
||||
parentDetailId,
|
||||
itemInfo.id,
|
||||
String(dbLevel),
|
||||
String(currentSeq),
|
||||
String(row.quantity || 1),
|
||||
row.unit || itemInfo.unit || null,
|
||||
row.process_type || null,
|
||||
row.remark || null,
|
||||
userId,
|
||||
companyCode,
|
||||
],
|
||||
);
|
||||
|
||||
levelStack.push(insertResult.rows[0].id);
|
||||
result.insertedCount++;
|
||||
}
|
||||
|
||||
// 5. 이력 기록
|
||||
await client.query(
|
||||
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
|
||||
VALUES ($1, 'excel_upload', $2, $3, $4)`,
|
||||
[newBomId, `엑셀 업로드로 BOM 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
|
||||
);
|
||||
|
||||
result.success = true;
|
||||
logger.info("BOM 엑셀 업로드 - 새 BOM 생성 완료", {
|
||||
newBomId, companyCode,
|
||||
insertedCount: result.insertedCount,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 엑셀 업로드 - 기존 BOM에 새 버전 생성
|
||||
*
|
||||
* 엑셀에 레벨 0 행이 있으면 건너뛰고 (마스터는 이미 존재)
|
||||
* 레벨 1 이상만 bom_detail로 INSERT, 새 bom_version에 연결
|
||||
*/
|
||||
export async function createBomVersionFromExcel(
|
||||
bomId: string,
|
||||
companyCode: string,
|
||||
userId: string,
|
||||
rows: BomExcelRow[],
|
||||
versionName?: string,
|
||||
): Promise<BomExcelUploadResult> {
|
||||
const result: BomExcelUploadResult = {
|
||||
success: false,
|
||||
insertedCount: 0,
|
||||
skippedCount: 0,
|
||||
errors: [],
|
||||
unmatchedItems: [],
|
||||
};
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
result.errors.push("업로드할 데이터가 없습니다");
|
||||
return result;
|
||||
}
|
||||
|
||||
const detailRows = rows.filter(r => r.level > 0);
|
||||
result.skippedCount = rows.length - detailRows.length;
|
||||
|
||||
if (detailRows.length === 0) {
|
||||
result.errors.push("하위품목이 없습니다 (레벨 1 이상의 행이 필요합니다)");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 레벨 유효성 검사
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
if (row.level < 0) {
|
||||
result.errors.push(`${i + 1}행: 레벨은 0 이상이어야 합니다`);
|
||||
}
|
||||
if (i > 0 && row.level > rows[i - 1].level + 1) {
|
||||
result.errors.push(`${i + 1}행: 레벨이 이전 행보다 2 이상 깊어질 수 없습니다`);
|
||||
}
|
||||
if (row.level > 0 && !row.item_number?.trim()) {
|
||||
result.errors.push(`${i + 1}행: 품번은 필수입니다`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return transaction(async (client) => {
|
||||
// 1. BOM 존재 확인
|
||||
const bomRow = await client.query(
|
||||
`SELECT id, version FROM bom WHERE id = $1 AND company_code = $2`,
|
||||
[bomId, companyCode],
|
||||
);
|
||||
if (bomRow.rows.length === 0) {
|
||||
result.errors.push("BOM을 찾을 수 없습니다");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. 품번 → item_info 매핑
|
||||
const uniqueItemNumbers = [...new Set(detailRows.map(r => r.item_number.trim()))];
|
||||
const itemLookup = await client.query(
|
||||
`SELECT id, item_number, item_name, unit FROM item_info
|
||||
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
|
||||
[companyCode, uniqueItemNumbers],
|
||||
);
|
||||
|
||||
const itemMap = new Map<string, { id: string; item_name: string; unit: string }>();
|
||||
for (const item of itemLookup.rows) {
|
||||
itemMap.set(item.item_number, { id: item.id, item_name: item.item_name, unit: item.unit });
|
||||
}
|
||||
|
||||
for (const num of uniqueItemNumbers) {
|
||||
if (!itemMap.has(num)) {
|
||||
result.unmatchedItems.push(num);
|
||||
}
|
||||
}
|
||||
if (result.unmatchedItems.length > 0) {
|
||||
result.errors.push(`매칭되지 않는 품번이 있습니다: ${result.unmatchedItems.join(", ")}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. 버전명 결정 (미입력 시 자동 채번)
|
||||
let finalVersionName = versionName?.trim();
|
||||
if (!finalVersionName) {
|
||||
const countResult = await client.query(
|
||||
`SELECT COUNT(*)::int as cnt FROM bom_version WHERE bom_id = $1`,
|
||||
[bomId],
|
||||
);
|
||||
finalVersionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
|
||||
}
|
||||
|
||||
// 중복 체크
|
||||
const dupCheck = await client.query(
|
||||
`SELECT id FROM bom_version WHERE bom_id = $1 AND version_name = $2`,
|
||||
[bomId, finalVersionName],
|
||||
);
|
||||
if (dupCheck.rows.length > 0) {
|
||||
result.errors.push(`이미 존재하는 버전명입니다: ${finalVersionName}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
// 4. bom_version 생성
|
||||
const versionInsert = await client.query(
|
||||
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
|
||||
VALUES ($1, $2, 0, 'developing', $3, $4) RETURNING id`,
|
||||
[bomId, finalVersionName, userId, companyCode],
|
||||
);
|
||||
const newVersionId = versionInsert.rows[0].id;
|
||||
|
||||
// 5. bom_detail INSERT
|
||||
const levelStack: string[] = [];
|
||||
const seqCounterByParent = new Map<string, number>();
|
||||
|
||||
for (let i = 0; i < detailRows.length; i++) {
|
||||
const row = detailRows[i];
|
||||
const itemInfo = itemMap.get(row.item_number.trim())!;
|
||||
const dbLevel = row.level - 1;
|
||||
|
||||
while (levelStack.length > dbLevel) {
|
||||
levelStack.pop();
|
||||
}
|
||||
|
||||
const parentDetailId = levelStack.length > 0 ? levelStack[levelStack.length - 1] : null;
|
||||
const parentKey = parentDetailId || "__root__";
|
||||
const currentSeq = (seqCounterByParent.get(parentKey) || 0) + 1;
|
||||
seqCounterByParent.set(parentKey, currentSeq);
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO bom_detail (bom_id, version_id, parent_detail_id, child_item_id, level, seq_no, quantity, unit, loss_rate, process_type, remark, writer, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, '0', $9, $10, $11, $12)
|
||||
RETURNING id`,
|
||||
[
|
||||
bomId,
|
||||
newVersionId,
|
||||
parentDetailId,
|
||||
itemInfo.id,
|
||||
String(dbLevel),
|
||||
String(currentSeq),
|
||||
String(row.quantity || 1),
|
||||
row.unit || itemInfo.unit || null,
|
||||
row.process_type || null,
|
||||
row.remark || null,
|
||||
userId,
|
||||
companyCode,
|
||||
],
|
||||
);
|
||||
|
||||
levelStack.push(insertResult.rows[0].id);
|
||||
result.insertedCount++;
|
||||
}
|
||||
|
||||
// 6. BOM 헤더의 version과 current_version_id 갱신
|
||||
await client.query(
|
||||
`UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`,
|
||||
[finalVersionName, newVersionId, bomId],
|
||||
);
|
||||
|
||||
// 7. 이력 기록
|
||||
await client.query(
|
||||
`INSERT INTO bom_history (bom_id, change_type, change_description, changed_by, company_code)
|
||||
VALUES ($1, 'excel_upload', $2, $3, $4)`,
|
||||
[bomId, `엑셀 업로드로 새 버전 ${finalVersionName} 생성 (하위품목 ${result.insertedCount}건)`, userId, companyCode],
|
||||
);
|
||||
|
||||
result.success = true;
|
||||
result.createdBomId = bomId;
|
||||
logger.info("BOM 엑셀 업로드 - 새 버전 생성 완료", {
|
||||
bomId, companyCode, versionName: finalVersionName,
|
||||
insertedCount: result.insertedCount,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 엑셀 다운로드용 데이터 조회
|
||||
*
|
||||
* 화면과 동일한 레벨 체계로 출력:
|
||||
* 레벨 0 = BOM 헤더 (최상위 품목)
|
||||
* 레벨 1 = 직접 자품목 (DB level=0)
|
||||
* 레벨 N = DB level N-1
|
||||
*
|
||||
* DFS로 순회하여 부모-자식 순서 보장
|
||||
*/
|
||||
export async function downloadBomExcelData(
|
||||
bomId: string,
|
||||
companyCode: string,
|
||||
): Promise<Record<string, any>[]> {
|
||||
// BOM 헤더 정보 조회 (최상위 품목)
|
||||
const bomHeader = await queryOne<Record<string, any>>(
|
||||
`SELECT b.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit
|
||||
FROM bom b
|
||||
LEFT JOIN item_info ii ON b.item_id = ii.id
|
||||
WHERE b.id = $1 AND b.company_code = $2`,
|
||||
[bomId, companyCode],
|
||||
);
|
||||
|
||||
if (!bomHeader) return [];
|
||||
|
||||
const flatList: Record<string, any>[] = [];
|
||||
|
||||
// 레벨 0: BOM 헤더 (최상위 품목)
|
||||
flatList.push({
|
||||
level: 0,
|
||||
item_number: bomHeader.item_number || "",
|
||||
item_name: bomHeader.item_name || "",
|
||||
quantity: bomHeader.base_qty || "1",
|
||||
unit: bomHeader.item_unit || bomHeader.unit || "",
|
||||
process_type: "",
|
||||
remark: bomHeader.remark || "",
|
||||
_is_header: true,
|
||||
});
|
||||
|
||||
// 하위 품목 조회
|
||||
const versionId = bomHeader.current_version_id;
|
||||
const whereVersion = versionId ? `AND bd.version_id = $3` : `AND bd.version_id IS NULL`;
|
||||
const params = versionId ? [bomId, companyCode, versionId] : [bomId, companyCode];
|
||||
|
||||
const details = await query(
|
||||
`SELECT bd.*, ii.item_number, ii.item_name, ii.division, ii.unit as item_unit, ii.size, ii.material
|
||||
FROM bom_detail bd
|
||||
LEFT JOIN item_info ii ON bd.child_item_id = ii.id
|
||||
WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion}
|
||||
ORDER BY bd.parent_detail_id NULLS FIRST, bd.seq_no::int`,
|
||||
params,
|
||||
);
|
||||
|
||||
// 부모 ID별 자식 목록으로 맵 구성
|
||||
const childrenMap = new Map<string, any[]>();
|
||||
const roots: any[] = [];
|
||||
for (const d of details) {
|
||||
if (!d.parent_detail_id) {
|
||||
roots.push(d);
|
||||
} else {
|
||||
if (!childrenMap.has(d.parent_detail_id)) childrenMap.set(d.parent_detail_id, []);
|
||||
childrenMap.get(d.parent_detail_id)!.push(d);
|
||||
}
|
||||
}
|
||||
|
||||
// DFS: depth로 정확한 레벨 계산 (DB level 무시, 실제 트리 깊이 사용)
|
||||
const dfs = (nodes: any[], depth: number) => {
|
||||
for (const node of nodes) {
|
||||
flatList.push({
|
||||
level: depth,
|
||||
item_number: node.item_number || "",
|
||||
item_name: node.item_name || "",
|
||||
quantity: node.quantity || "1",
|
||||
unit: node.unit || node.item_unit || "",
|
||||
process_type: node.process_type || "",
|
||||
remark: node.remark || "",
|
||||
});
|
||||
const children = childrenMap.get(node.id) || [];
|
||||
if (children.length > 0) {
|
||||
dfs(children, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 루트 노드들은 레벨 1 (BOM 헤더가 0이므로)
|
||||
dfs(roots, 1);
|
||||
|
||||
return flatList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제
|
||||
*/
|
||||
export async function deleteBomVersion(
|
||||
bomId: string, versionId: string,
|
||||
tableName?: string, detailTableName?: string,
|
||||
) {
|
||||
const table = safeTableName(tableName || "", "bom_version");
|
||||
const dTable = safeTableName(detailTableName || "", "bom_detail");
|
||||
|
||||
return transaction(async (client) => {
|
||||
// active 상태 버전은 삭제 불가
|
||||
const checkResult = await client.query(
|
||||
`SELECT status FROM ${table} WHERE id = $1 AND bom_id = $2`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
if (checkResult.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
|
||||
if (checkResult.rows[0].status === "active") {
|
||||
throw new Error("사용중인 버전은 삭제할 수 없습니다");
|
||||
}
|
||||
|
||||
// 해당 버전의 bom_detail 행 삭제
|
||||
const deleteDetails = await client.query(
|
||||
`DELETE FROM ${dTable} WHERE bom_id = $1 AND version_id = $2`,
|
||||
[bomId, versionId],
|
||||
);
|
||||
|
||||
// 버전 레코드 삭제
|
||||
const deleteVersion = await client.query(
|
||||
`DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
|
||||
logger.info("BOM 버전 삭제", {
|
||||
bomId, versionId,
|
||||
deletedDetails: deleteDetails.rowCount,
|
||||
});
|
||||
|
||||
return deleteVersion.rows.length > 0;
|
||||
});
|
||||
}
|
||||
|
|
@ -1354,9 +1354,10 @@ 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 }>
|
||||
ServiceResponse<{ inserted: number; updated: number; deleted: number; savedIds?: any[] }>
|
||||
> {
|
||||
try {
|
||||
// 테이블 접근 권한 검증
|
||||
|
|
@ -1405,7 +1406,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 +1414,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 +1498,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);
|
||||
|
|
|
|||
|
|
@ -210,19 +210,62 @@ export class DynamicFormService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VIEW인 경우 원본(base) 테이블명을 반환, 일반 테이블이면 그대로 반환
|
||||
*/
|
||||
async resolveBaseTable(tableName: string): Promise<string> {
|
||||
try {
|
||||
const result = await query<{ table_type: string }>(
|
||||
`SELECT table_type FROM information_schema.tables
|
||||
WHERE table_name = $1 AND table_schema = 'public'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
if (result.length === 0 || result[0].table_type !== 'VIEW') {
|
||||
return tableName;
|
||||
}
|
||||
|
||||
// VIEW의 FROM 절에서 첫 번째 테이블을 추출
|
||||
const viewDef = await query<{ view_definition: string }>(
|
||||
`SELECT view_definition FROM information_schema.views
|
||||
WHERE table_name = $1 AND table_schema = 'public'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
if (viewDef.length > 0) {
|
||||
const definition = viewDef[0].view_definition;
|
||||
// PostgreSQL은 뷰 정의를 "FROM (테이블명 별칭 LEFT JOIN ...)" 형태로 저장
|
||||
const fromMatch = definition.match(/FROM\s+\(?(?:public\.)?(\w+)\s/i);
|
||||
if (fromMatch) {
|
||||
const baseTable = fromMatch[1];
|
||||
console.log(`🔄 VIEW ${tableName} → 원본 테이블 ${baseTable} 으로 전환`);
|
||||
return baseTable;
|
||||
}
|
||||
}
|
||||
|
||||
return tableName;
|
||||
} catch (error) {
|
||||
console.error(`❌ VIEW 원본 테이블 조회 실패:`, error);
|
||||
return tableName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 폼 데이터 저장 (실제 테이블에 직접 저장)
|
||||
*/
|
||||
async saveFormData(
|
||||
screenId: number,
|
||||
tableName: string,
|
||||
tableNameInput: string,
|
||||
data: Record<string, any>,
|
||||
ipAddress?: string
|
||||
): Promise<FormDataResult> {
|
||||
// VIEW인 경우 원본 테이블로 전환
|
||||
const tableName = await this.resolveBaseTable(tableNameInput);
|
||||
try {
|
||||
console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", {
|
||||
screenId,
|
||||
tableName,
|
||||
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
|
||||
data,
|
||||
});
|
||||
|
||||
|
|
@ -813,14 +856,17 @@ export class DynamicFormService {
|
|||
*/
|
||||
async updateFormDataPartial(
|
||||
id: string | number, // 🔧 UUID 문자열도 지원
|
||||
tableName: string,
|
||||
tableNameInput: string,
|
||||
originalData: Record<string, any>,
|
||||
newData: Record<string, any>
|
||||
): Promise<PartialUpdateResult> {
|
||||
// VIEW인 경우 원본 테이블로 전환
|
||||
const tableName = await this.resolveBaseTable(tableNameInput);
|
||||
try {
|
||||
console.log("🔄 서비스: 부분 업데이트 시작:", {
|
||||
id,
|
||||
tableName,
|
||||
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
|
||||
originalData,
|
||||
newData,
|
||||
});
|
||||
|
|
@ -1008,13 +1054,16 @@ export class DynamicFormService {
|
|||
*/
|
||||
async updateFormData(
|
||||
id: string | number,
|
||||
tableName: string,
|
||||
tableNameInput: string,
|
||||
data: Record<string, any>
|
||||
): Promise<FormDataResult> {
|
||||
// VIEW인 경우 원본 테이블로 전환
|
||||
const tableName = await this.resolveBaseTable(tableNameInput);
|
||||
try {
|
||||
console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", {
|
||||
id,
|
||||
tableName,
|
||||
originalTable: tableNameInput !== tableName ? tableNameInput : undefined,
|
||||
data,
|
||||
});
|
||||
|
||||
|
|
@ -1033,6 +1082,9 @@ export class DynamicFormService {
|
|||
if (tableColumns.includes("updated_at")) {
|
||||
dataToUpdate.updated_at = new Date();
|
||||
}
|
||||
if (tableColumns.includes("updated_date")) {
|
||||
dataToUpdate.updated_date = new Date();
|
||||
}
|
||||
if (tableColumns.includes("regdate") && !dataToUpdate.regdate) {
|
||||
dataToUpdate.regdate = new Date();
|
||||
}
|
||||
|
|
@ -1212,9 +1264,13 @@ export class DynamicFormService {
|
|||
screenId?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// VIEW인 경우 원본 테이블로 전환 (VIEW에는 기본키가 없으므로)
|
||||
const actualTable = await this.resolveBaseTable(tableName);
|
||||
|
||||
console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", {
|
||||
id,
|
||||
tableName,
|
||||
tableName: actualTable,
|
||||
originalTable: tableName !== actualTable ? tableName : undefined,
|
||||
});
|
||||
|
||||
// 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회
|
||||
|
|
@ -1232,15 +1288,15 @@ export class DynamicFormService {
|
|||
`;
|
||||
|
||||
console.log("🔍 기본키 조회 SQL:", primaryKeyQuery);
|
||||
console.log("🔍 테이블명:", tableName);
|
||||
console.log("🔍 테이블명:", actualTable);
|
||||
|
||||
const primaryKeyResult = await query<{
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
}>(primaryKeyQuery, [tableName]);
|
||||
}>(primaryKeyQuery, [actualTable]);
|
||||
|
||||
if (!primaryKeyResult || primaryKeyResult.length === 0) {
|
||||
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
|
||||
throw new Error(`테이블 ${actualTable}의 기본키를 찾을 수 없습니다.`);
|
||||
}
|
||||
|
||||
const primaryKeyInfo = primaryKeyResult[0];
|
||||
|
|
@ -1272,7 +1328,7 @@ export class DynamicFormService {
|
|||
|
||||
// 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성
|
||||
const deleteQuery = `
|
||||
DELETE FROM ${tableName}
|
||||
DELETE FROM ${actualTable}
|
||||
WHERE ${primaryKeyColumn} = $1${typeCastSuffix}
|
||||
RETURNING *
|
||||
`;
|
||||
|
|
@ -1290,6 +1346,11 @@ export class DynamicFormService {
|
|||
return res.rows;
|
||||
});
|
||||
|
||||
// 삭제된 행이 없으면 레코드를 찾을 수 없는 것
|
||||
if (!result || !Array.isArray(result) || result.length === 0) {
|
||||
throw new Error(`테이블 ${actualTable}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`);
|
||||
}
|
||||
|
||||
console.log("✅ 서비스: 실제 테이블 삭제 성공:", result);
|
||||
|
||||
// 🔥 조건부 연결 실행 (DELETE 트리거)
|
||||
|
|
|
|||
|
|
@ -16,16 +16,18 @@ export class EntityJoinService {
|
|||
* 테이블의 Entity 컬럼들을 감지하여 조인 설정 생성
|
||||
* @param tableName 테이블명
|
||||
* @param screenEntityConfigs 화면별 엔티티 설정 (선택사항)
|
||||
* @param companyCode 회사코드 (회사별 설정 우선, 없으면 전체 조회)
|
||||
*/
|
||||
async detectEntityJoins(
|
||||
tableName: string,
|
||||
screenEntityConfigs?: Record<string, any>
|
||||
screenEntityConfigs?: Record<string, any>,
|
||||
companyCode?: string
|
||||
): Promise<EntityJoinConfig[]> {
|
||||
try {
|
||||
logger.info(`Entity 컬럼 감지 시작: ${tableName}`);
|
||||
logger.info(`Entity 컬럼 감지 시작: ${tableName} (companyCode: ${companyCode || 'all'})`);
|
||||
|
||||
// table_type_columns에서 entity 및 category 타입인 컬럼들 조회
|
||||
// company_code = '*' (공통 설정) 우선 조회
|
||||
// 회사코드가 있으면 해당 회사 + '*' 만 조회, 회사별 우선
|
||||
const entityColumns = await query<{
|
||||
column_name: string;
|
||||
input_type: string;
|
||||
|
|
@ -33,14 +35,17 @@ export class EntityJoinService {
|
|||
reference_column: string;
|
||||
display_column: string | null;
|
||||
}>(
|
||||
`SELECT column_name, input_type, reference_table, reference_column, display_column
|
||||
`SELECT DISTINCT ON (column_name)
|
||||
column_name, input_type, reference_table, reference_column, display_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND input_type IN ('entity', 'category')
|
||||
AND company_code = '*'
|
||||
AND reference_table IS NOT NULL
|
||||
AND reference_table != ''`,
|
||||
[tableName]
|
||||
AND reference_table != ''
|
||||
${companyCode ? `AND company_code IN ($2, '*')` : ''}
|
||||
ORDER BY column_name,
|
||||
CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
companyCode ? [tableName, companyCode] : [tableName]
|
||||
);
|
||||
|
||||
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
|
||||
|
|
@ -272,7 +277,8 @@ export class EntityJoinService {
|
|||
orderBy: string = "",
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
columnTypes?: Map<string, string> // 컬럼명 → 데이터 타입 매핑
|
||||
columnTypes?: Map<string, string>, // 컬럼명 → 데이터 타입 매핑
|
||||
referenceTableColumns?: Map<string, string[]> // 🆕 참조 테이블별 전체 컬럼 목록
|
||||
): { query: string; aliasMap: Map<string, string> } {
|
||||
try {
|
||||
// 기본 SELECT 컬럼들 (날짜는 YYYY-MM-DD 형식, 나머지는 TEXT 캐스팅)
|
||||
|
|
@ -338,115 +344,100 @@ export class EntityJoinService {
|
|||
);
|
||||
});
|
||||
|
||||
// 🔧 _label 별칭 중복 방지를 위한 Set
|
||||
// 같은 sourceColumn에서 여러 조인 설정이 있을 때 _label은 첫 번째만 생성
|
||||
const generatedLabelAliases = new Set<string>();
|
||||
// 🔧 생성된 별칭 중복 방지를 위한 Set
|
||||
const generatedAliases = new Set<string>();
|
||||
|
||||
const joinColumns = joinConfigs
|
||||
const joinColumns = uniqueReferenceTableConfigs
|
||||
.map((config) => {
|
||||
const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
|
||||
const alias = aliasMap.get(aliasKey);
|
||||
const displayColumns = config.displayColumns || [
|
||||
config.displayColumn,
|
||||
];
|
||||
const separator = config.separator || " - ";
|
||||
|
||||
// 결과 컬럼 배열 (aliasColumn + _label 필드)
|
||||
const resultColumns: string[] = [];
|
||||
|
||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||
// displayColumns가 빈 배열이거나 첫 번째 값이 null/undefined인 경우
|
||||
// 조인 테이블의 referenceColumn을 기본값으로 사용
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
} else if (displayColumns.length === 1) {
|
||||
// 단일 컬럼인 경우
|
||||
const col = displayColumns[0];
|
||||
// 🆕 참조 테이블의 전체 컬럼 목록이 있으면 모든 컬럼을 SELECT
|
||||
const refTableCols = referenceTableColumns?.get(
|
||||
`${config.referenceTable}:${config.sourceColumn}`
|
||||
) || referenceTableColumns?.get(config.referenceTable);
|
||||
|
||||
// ✅ 개선: referenceTable이 설정되어 있으면 조인 테이블에서 가져옴
|
||||
// 이렇게 하면 item_info.size, item_info.material 등 모든 조인 테이블 컬럼 지원
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
if (refTableCols && refTableCols.length > 0) {
|
||||
// 메타 컬럼은 제외 (메인 테이블과 중복되거나 불필요)
|
||||
const skipColumns = new Set(["company_code", "created_date", "updated_date", "writer"]);
|
||||
|
||||
for (const col of refTableCols) {
|
||||
if (skipColumns.has(col)) continue;
|
||||
|
||||
const colAlias = `${config.sourceColumn}_${col}`;
|
||||
if (generatedAliases.has(colAlias)) continue;
|
||||
|
||||
if (isJoinTableColumn) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
`COALESCE(${alias}."${col}"::TEXT, '') AS "${colAlias}"`
|
||||
);
|
||||
generatedAliases.add(colAlias);
|
||||
}
|
||||
|
||||
// _label 필드도 함께 SELECT (프론트엔드 getColumnUniqueValues용)
|
||||
// sourceColumn_label 형식으로 추가
|
||||
// 🔧 중복 방지: 같은 sourceColumn에서 _label은 첫 번째만 생성
|
||||
const labelAlias = `${config.sourceColumn}_label`;
|
||||
if (!generatedLabelAliases.has(labelAlias)) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
|
||||
);
|
||||
generatedLabelAliases.add(labelAlias);
|
||||
}
|
||||
|
||||
// 🆕 referenceColumn (PK)도 항상 SELECT (parentDataMapping용)
|
||||
// 예: customer_code, item_number 등
|
||||
// col과 동일해도 별도의 alias로 추가 (customer_code as customer_code)
|
||||
// 🔧 중복 방지: referenceColumn도 한 번만 추가
|
||||
const refColAlias = config.referenceColumn;
|
||||
if (!generatedLabelAliases.has(refColAlias)) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${refColAlias}`
|
||||
);
|
||||
generatedLabelAliases.add(refColAlias);
|
||||
}
|
||||
} else {
|
||||
// _label 필드도 추가 (기존 호환성)
|
||||
const labelAlias = `${config.sourceColumn}_label`;
|
||||
if (!generatedAliases.has(labelAlias)) {
|
||||
// 표시용 컬럼 자동 감지: *_name > name > label > referenceColumn
|
||||
const nameCol = refTableCols.find((c) => c.endsWith("_name") && c !== "company_name");
|
||||
const displayCol = nameCol || refTableCols.find((c) => c === "name") || config.referenceColumn;
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
`COALESCE(${alias}."${displayCol}"::TEXT, '') AS "${labelAlias}"`
|
||||
);
|
||||
generatedAliases.add(labelAlias);
|
||||
}
|
||||
} else {
|
||||
// 🆕 여러 컬럼인 경우 각 컬럼을 개별 alias로 반환 (합치지 않음)
|
||||
// 예: item_info.standard_price → sourceColumn_standard_price (item_id_standard_price)
|
||||
displayColumns.forEach((col) => {
|
||||
// 🔄 기존 로직 (참조 테이블 컬럼 목록이 없는 경우 - fallback)
|
||||
const displayColumns = config.displayColumns || [config.displayColumn];
|
||||
|
||||
if (displayColumns.length === 0 || !displayColumns[0]) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
} else if (displayColumns.length === 1) {
|
||||
const col = displayColumns[0];
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
|
||||
const individualAlias = `${config.sourceColumn}_${col}`;
|
||||
|
||||
// 🔧 중복 방지: 같은 alias가 이미 생성되었으면 스킵
|
||||
if (generatedLabelAliases.has(individualAlias)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJoinTableColumn) {
|
||||
// 조인 테이블 컬럼은 조인 별칭 사용
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
const labelAlias = `${config.sourceColumn}_label`;
|
||||
if (!generatedAliases.has(labelAlias)) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${labelAlias}`
|
||||
);
|
||||
generatedAliases.add(labelAlias);
|
||||
}
|
||||
} else {
|
||||
// 기본 테이블 컬럼은 main 별칭 사용
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${config.aliasColumn}`
|
||||
);
|
||||
}
|
||||
generatedLabelAliases.add(individualAlias);
|
||||
});
|
||||
} else {
|
||||
displayColumns.forEach((col) => {
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
const individualAlias = `${config.sourceColumn}_${col}`;
|
||||
if (generatedAliases.has(individualAlias)) return;
|
||||
|
||||
// 🆕 referenceColumn (PK)도 함께 SELECT (parentDataMapping용)
|
||||
const isJoinTableColumn =
|
||||
config.referenceTable && config.referenceTable !== tableName;
|
||||
if (
|
||||
isJoinTableColumn &&
|
||||
!displayColumns.includes(config.referenceColumn) &&
|
||||
!generatedLabelAliases.has(config.referenceColumn) // 🔧 중복 방지
|
||||
) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${config.referenceColumn}::TEXT, '') AS ${config.referenceColumn}`
|
||||
);
|
||||
generatedLabelAliases.add(config.referenceColumn);
|
||||
if (isJoinTableColumn) {
|
||||
resultColumns.push(
|
||||
`COALESCE(${alias}.${col}::TEXT, '') AS ${individualAlias}`
|
||||
);
|
||||
} else {
|
||||
resultColumns.push(
|
||||
`COALESCE(main.${col}::TEXT, '') AS ${individualAlias}`
|
||||
);
|
||||
}
|
||||
generatedAliases.add(individualAlias);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 resultColumns를 반환
|
||||
return resultColumns.join(", ");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
// SELECT 절 구성
|
||||
|
|
@ -466,17 +457,18 @@ export class EntityJoinService {
|
|||
|
||||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링)
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
}
|
||||
|
||||
// user_info는 전역 테이블이므로 company_code 조건 없이 조인
|
||||
if (config.referenceTable === "user_info") {
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;
|
||||
}
|
||||
|
||||
// 일반 테이블: company_code가 있으면 같은 회사 데이터만 조인 (멀티테넌시)
|
||||
// supplier_mng, customer_mng, item_info 등 회사별 데이터 테이블
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.company_code = main.company_code`;
|
||||
// ::TEXT 캐스팅으로 varchar/integer 등 타입 불일치 방지
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.company_code = main.company_code`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
|
|
@ -589,6 +581,7 @@ export class EntityJoinService {
|
|||
logger.info("🔍 조인 설정 검증 상세:", {
|
||||
sourceColumn: config.sourceColumn,
|
||||
referenceTable: config.referenceTable,
|
||||
referenceColumn: config.referenceColumn,
|
||||
displayColumns: config.displayColumns,
|
||||
displayColumn: config.displayColumn,
|
||||
aliasColumn: config.aliasColumn,
|
||||
|
|
@ -607,7 +600,45 @@ export class EntityJoinService {
|
|||
return false;
|
||||
}
|
||||
|
||||
// 참조 컬럼 존재 확인 (displayColumns[0] 사용)
|
||||
// 참조 컬럼(JOIN 키) 존재 확인 - 참조 테이블에 reference_column이 실제로 있는지 검증
|
||||
if (config.referenceColumn) {
|
||||
const refColExists = await query<{ exists: number }>(
|
||||
`SELECT 1 as exists FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
LIMIT 1`,
|
||||
[config.referenceTable, config.referenceColumn]
|
||||
);
|
||||
|
||||
if (refColExists.length === 0) {
|
||||
// reference_column이 없으면 'id' 컬럼으로 자동 대체 시도
|
||||
const idColExists = await query<{ exists: number }>(
|
||||
`SELECT 1 as exists FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
AND column_name = 'id'
|
||||
LIMIT 1`,
|
||||
[config.referenceTable]
|
||||
);
|
||||
|
||||
if (idColExists.length > 0) {
|
||||
logger.warn(
|
||||
`⚠️ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않음 → 'id'로 자동 대체`
|
||||
);
|
||||
config.referenceColumn = "id";
|
||||
} else {
|
||||
logger.warn(
|
||||
`❌ 참조 컬럼 ${config.referenceTable}.${config.referenceColumn}이 존재하지 않고 'id' 컬럼도 없음 → 스킵`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`✅ 참조 컬럼 확인 완료: ${config.referenceTable}.${config.referenceColumn}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 표시 컬럼 존재 확인 (displayColumns[0] 사용)
|
||||
const displayColumn = config.displayColumns?.[0] || config.displayColumn;
|
||||
logger.info(
|
||||
`🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})`
|
||||
|
|
@ -695,10 +726,10 @@ export class EntityJoinService {
|
|||
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
|
||||
if (config.referenceTable === "table_column_category_values") {
|
||||
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
|
||||
}
|
||||
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
|
||||
return `LEFT JOIN ${config.referenceTable} ${alias} ON main."${config.sourceColumn}"::TEXT = ${alias}."${config.referenceColumn}"::TEXT`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
|
|
@ -725,7 +756,7 @@ export class EntityJoinService {
|
|||
/**
|
||||
* 참조 테이블의 컬럼 목록 조회 (UI용)
|
||||
*/
|
||||
async getReferenceTableColumns(tableName: string): Promise<
|
||||
async getReferenceTableColumns(tableName: string, companyCode?: string): Promise<
|
||||
Array<{
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
|
|
@ -750,16 +781,19 @@ export class EntityJoinService {
|
|||
);
|
||||
|
||||
// 2. table_type_columns 테이블에서 라벨과 input_type 정보 조회
|
||||
// 회사코드가 있으면 해당 회사 + '*' 만, 회사별 우선
|
||||
const columnLabels = await query<{
|
||||
column_name: string;
|
||||
column_label: string | null;
|
||||
input_type: string | null;
|
||||
}>(
|
||||
`SELECT column_name, column_label, input_type
|
||||
`SELECT DISTINCT ON (column_name) column_name, column_label, input_type
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1
|
||||
AND company_code = '*'`,
|
||||
[tableName]
|
||||
${companyCode ? `AND company_code IN ($2, '*')` : ''}
|
||||
ORDER BY column_name,
|
||||
CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
companyCode ? [tableName, companyCode] : [tableName]
|
||||
);
|
||||
|
||||
// 3. 라벨 및 inputType 정보를 맵으로 변환
|
||||
|
|
|
|||
|
|
@ -31,13 +31,6 @@ export class FlowExecutionService {
|
|||
throw new Error(`Flow definition not found: ${flowId}`);
|
||||
}
|
||||
|
||||
console.log("🔍 [getStepDataCount] Flow Definition:", {
|
||||
flowId,
|
||||
dbSourceType: flowDef.dbSourceType,
|
||||
dbConnectionId: flowDef.dbConnectionId,
|
||||
tableName: flowDef.tableName,
|
||||
});
|
||||
|
||||
// 2. 플로우 단계 조회
|
||||
const step = await this.flowStepService.findById(stepId);
|
||||
if (!step) {
|
||||
|
|
@ -59,36 +52,21 @@ export class FlowExecutionService {
|
|||
// 5. 카운트 쿼리 실행 (내부 또는 외부 DB)
|
||||
const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
|
||||
|
||||
console.log("🔍 [getStepDataCount] Query Info:", {
|
||||
tableName,
|
||||
query,
|
||||
params,
|
||||
isExternal: flowDef.dbSourceType === "external",
|
||||
connectionId: flowDef.dbConnectionId,
|
||||
});
|
||||
|
||||
let result: any;
|
||||
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
|
||||
// 외부 DB 조회
|
||||
console.log(
|
||||
"✅ [getStepDataCount] Using EXTERNAL DB:",
|
||||
flowDef.dbConnectionId
|
||||
);
|
||||
const externalResult = await executeExternalQuery(
|
||||
flowDef.dbConnectionId,
|
||||
query,
|
||||
params
|
||||
);
|
||||
console.log("📦 [getStepDataCount] External result:", externalResult);
|
||||
result = externalResult.rows;
|
||||
} else {
|
||||
// 내부 DB 조회
|
||||
console.log("✅ [getStepDataCount] Using INTERNAL DB");
|
||||
result = await db.query(query, params);
|
||||
}
|
||||
|
||||
const count = parseInt(result[0].count || result[0].COUNT);
|
||||
console.log("✅ [getStepDataCount] Final count:", count);
|
||||
return count;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,13 +93,6 @@ export class FlowStepService {
|
|||
id: number,
|
||||
request: UpdateFlowStepRequest
|
||||
): Promise<FlowStep | null> {
|
||||
console.log("🔧 FlowStepService.update called with:", {
|
||||
id,
|
||||
statusColumn: request.statusColumn,
|
||||
statusValue: request.statusValue,
|
||||
fullRequest: JSON.stringify(request),
|
||||
});
|
||||
|
||||
// 조건 검증
|
||||
if (request.conditionJson) {
|
||||
FlowConditionParser.validateConditionGroup(request.conditionJson);
|
||||
|
|
@ -276,14 +269,6 @@ export class FlowStepService {
|
|||
// JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌
|
||||
const displayConfig = row.display_config;
|
||||
|
||||
// 디버깅 로그 (개발 환경에서만)
|
||||
if (displayConfig && process.env.NODE_ENV === "development") {
|
||||
console.log(`🔍 [FlowStep ${row.id}] displayConfig:`, {
|
||||
type: typeof displayConfig,
|
||||
value: displayConfig,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
flowDefinitionId: row.flow_definition_id,
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export interface ExcelUploadResult {
|
|||
masterInserted: number;
|
||||
masterUpdated: number;
|
||||
detailInserted: number;
|
||||
detailUpdated: number;
|
||||
detailDeleted: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
|
@ -310,6 +311,7 @@ class MasterDetailExcelService {
|
|||
sourceColumn: string;
|
||||
alias: string;
|
||||
displayColumn: string;
|
||||
tableAlias: string; // "m" (마스터) 또는 "d" (디테일) - JOIN 시 소스 테이블 구분
|
||||
}> = [];
|
||||
|
||||
// SELECT 절 구성
|
||||
|
|
@ -332,6 +334,7 @@ class MasterDetailExcelService {
|
|||
sourceColumn: fkColumn.sourceColumn,
|
||||
alias,
|
||||
displayColumn,
|
||||
tableAlias: "m", // 마스터 테이블에서 조인
|
||||
});
|
||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||
} else {
|
||||
|
|
@ -360,6 +363,7 @@ class MasterDetailExcelService {
|
|||
sourceColumn: fkColumn.sourceColumn,
|
||||
alias,
|
||||
displayColumn,
|
||||
tableAlias: "d", // 디테일 테이블에서 조인
|
||||
});
|
||||
selectParts.push(`${alias}."${displayColumn}" AS "${col.name}"`);
|
||||
} else {
|
||||
|
|
@ -373,9 +377,9 @@ class MasterDetailExcelService {
|
|||
|
||||
const selectClause = selectParts.join(", ");
|
||||
|
||||
// 엔티티 조인 절 구성
|
||||
// 엔티티 조인 절 구성 (마스터/디테일 테이블 alias 구분)
|
||||
const entityJoinClauses = entityJoins.map(ej =>
|
||||
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON m."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
||||
`LEFT JOIN "${ej.refTable}" ${ej.alias} ON ${ej.tableAlias}."${ej.sourceColumn}" = ${ej.alias}."${ej.refColumn}"`
|
||||
).join("\n ");
|
||||
|
||||
// WHERE 절 구성
|
||||
|
|
@ -410,6 +414,16 @@ class MasterDetailExcelService {
|
|||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 디테일 테이블의 id 컬럼 존재 여부 확인 (user_info 등 id가 없는 테이블 대응)
|
||||
const detailIdCheck = await queryOne<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'id'
|
||||
) as exists`,
|
||||
[detailTable]
|
||||
);
|
||||
const detailOrderColumn = detailIdCheck?.exists ? `d."id"` : `d."${detailFkColumn}"`;
|
||||
|
||||
// JOIN 쿼리 실행
|
||||
const sql = `
|
||||
SELECT ${selectClause}
|
||||
|
|
@ -419,7 +433,7 @@ class MasterDetailExcelService {
|
|||
AND m.company_code = d.company_code
|
||||
${entityJoinClauses}
|
||||
${whereClause}
|
||||
ORDER BY m."${masterKeyColumn}", d.id
|
||||
ORDER BY m."${masterKeyColumn}", ${detailOrderColumn}
|
||||
`;
|
||||
|
||||
logger.info(`마스터-디테일 JOIN 쿼리:`, { sql, params });
|
||||
|
|
@ -478,14 +492,172 @@ class MasterDetailExcelService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 특정 컬럼이 채번 타입인지 확인하고, 채번 규칙 ID를 반환
|
||||
* 회사별 설정을 우선 조회하고, 없으면 공통(*) 설정으로 fallback
|
||||
*/
|
||||
private async detectNumberingRuleForColumn(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
companyCode?: string
|
||||
): Promise<{ numberingRuleId: string } | null> {
|
||||
try {
|
||||
// 회사별 설정 우선, 공통 설정 fallback (company_code DESC로 회사별이 먼저)
|
||||
const companyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($3, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
const params = companyCode && companyCode !== "*"
|
||||
? [tableName, columnName, companyCode]
|
||||
: [tableName, columnName];
|
||||
|
||||
const result = await query<any>(
|
||||
`SELECT input_type, detail_settings, company_code
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND column_name = $2 ${companyCondition}
|
||||
ORDER BY CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
params
|
||||
);
|
||||
|
||||
// 채번 타입인 행 찾기 (회사별 우선)
|
||||
for (const row of result) {
|
||||
if (row.input_type === "numbering") {
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
|
||||
if (settings?.numberingRuleId) {
|
||||
return { numberingRuleId: settings.numberingRuleId };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error(`채번 컬럼 감지 실패: ${tableName}.${columnName}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 모든 채번 컬럼을 한 번에 조회
|
||||
* 회사별 설정 우선, 공통(*) 설정 fallback
|
||||
* @returns Map<columnName, numberingRuleId>
|
||||
*/
|
||||
private async detectAllNumberingColumns(
|
||||
tableName: string,
|
||||
companyCode?: string
|
||||
): Promise<Map<string, string>> {
|
||||
const numberingCols = new Map<string, string>();
|
||||
try {
|
||||
const companyCondition = companyCode && companyCode !== "*"
|
||||
? `AND company_code IN ($2, '*')`
|
||||
: `AND company_code = '*'`;
|
||||
const params = companyCode && companyCode !== "*"
|
||||
? [tableName, companyCode]
|
||||
: [tableName];
|
||||
|
||||
const result = await query<any>(
|
||||
`SELECT column_name, detail_settings, company_code
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'numbering' ${companyCondition}
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
params
|
||||
);
|
||||
|
||||
// 컬럼별로 회사 설정 우선 적용
|
||||
for (const row of result) {
|
||||
if (numberingCols.has(row.column_name)) continue; // 이미 회사별 설정이 있으면 스킵
|
||||
const settings = typeof row.detail_settings === "string"
|
||||
? JSON.parse(row.detail_settings || "{}")
|
||||
: row.detail_settings;
|
||||
if (settings?.numberingRuleId) {
|
||||
numberingCols.set(row.column_name, settings.numberingRuleId);
|
||||
}
|
||||
}
|
||||
|
||||
if (numberingCols.size > 0) {
|
||||
logger.info(`테이블 ${tableName} 채번 컬럼 감지:`, Object.fromEntries(numberingCols));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`테이블 ${tableName} 채번 컬럼 감지 실패:`, error);
|
||||
}
|
||||
return numberingCols;
|
||||
}
|
||||
|
||||
/**
|
||||
* 디테일 테이블의 고유 키 컬럼 감지 (UPSERT 매칭용)
|
||||
* PK가 비즈니스 키이면 사용, auto-increment 'id'만이면 유니크 인덱스 탐색
|
||||
* @returns 고유 키 컬럼 배열 (빈 배열이면 매칭 불가 → INSERT만 수행)
|
||||
*/
|
||||
private async detectUniqueKeyColumns(
|
||||
client: any,
|
||||
tableName: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
// 1. PK 컬럼 조회
|
||||
const pkResult = await client.query(
|
||||
`SELECT array_agg(a.attname ORDER BY x.n) AS columns
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY AS x(attnum, n)
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
|
||||
WHERE n.nspname = 'public' AND t.relname = $1 AND c.contype = 'p'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
if (pkResult.rows.length > 0 && pkResult.rows[0].columns) {
|
||||
const pkCols: string[] = typeof pkResult.rows[0].columns === "string"
|
||||
? pkResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim())
|
||||
: pkResult.rows[0].columns;
|
||||
|
||||
// PK가 'id' 하나만 있으면 auto-increment이므로 사용 불가
|
||||
if (!(pkCols.length === 1 && pkCols[0] === "id")) {
|
||||
logger.info(`디테일 테이블 ${tableName} 고유 키 (PK): ${pkCols.join(", ")}`);
|
||||
return pkCols;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. PK가 'id'뿐이면 유니크 인덱스 탐색
|
||||
const uqResult = await client.query(
|
||||
`SELECT array_agg(a.attname ORDER BY x.n) AS columns
|
||||
FROM pg_index ix
|
||||
JOIN pg_class t ON t.oid = ix.indrelid
|
||||
JOIN pg_class i ON i.oid = ix.indexrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n)
|
||||
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
|
||||
WHERE n.nspname = 'public' AND t.relname = $1
|
||||
AND ix.indisunique = true AND ix.indisprimary = false
|
||||
GROUP BY i.relname
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
if (uqResult.rows.length > 0 && uqResult.rows[0].columns) {
|
||||
const uqCols: string[] = typeof uqResult.rows[0].columns === "string"
|
||||
? uqResult.rows[0].columns.replace(/[{}]/g, "").split(",").map((s: string) => s.trim())
|
||||
: uqResult.rows[0].columns;
|
||||
logger.info(`디테일 테이블 ${tableName} 고유 키 (UNIQUE INDEX): ${uqCols.join(", ")}`);
|
||||
return uqCols;
|
||||
}
|
||||
|
||||
logger.info(`디테일 테이블 ${tableName} 고유 키 없음 → INSERT 전용`);
|
||||
return [];
|
||||
} catch (error) {
|
||||
logger.error(`디테일 테이블 ${tableName} 고유 키 감지 실패:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마스터-디테일 데이터 업로드 (엑셀 업로드용)
|
||||
*
|
||||
* 처리 로직:
|
||||
* 1. 엑셀 데이터를 마스터 키로 그룹화
|
||||
* 2. 각 그룹의 첫 번째 행에서 마스터 데이터 추출 → UPSERT
|
||||
* 3. 해당 마스터 키의 기존 디테일 삭제
|
||||
* 4. 새 디테일 데이터 INSERT
|
||||
* 1. 마스터 키 컬럼이 채번 타입인지 확인
|
||||
* 2-A. 채번인 경우: 다른 마스터 컬럼 값으로 그룹화 → 키 자동 생성 → INSERT
|
||||
* 2-B. 채번 아닌 경우: 마스터 키 값으로 그룹화 → UPSERT
|
||||
* 3. 디테일 데이터 개별 행 UPSERT (고유 키 기반)
|
||||
*/
|
||||
async uploadJoinedData(
|
||||
relation: MasterDetailRelation,
|
||||
|
|
@ -498,6 +670,7 @@ class MasterDetailExcelService {
|
|||
masterInserted: 0,
|
||||
masterUpdated: 0,
|
||||
detailInserted: 0,
|
||||
detailUpdated: 0,
|
||||
detailDeleted: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
|
@ -510,118 +683,322 @@ class MasterDetailExcelService {
|
|||
|
||||
const { masterTable, detailTable, masterKeyColumn, detailFkColumn, masterColumns, detailColumns } = relation;
|
||||
|
||||
// 1. 데이터를 마스터 키로 그룹화
|
||||
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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 조건 평가
|
||||
* 다른 테이블에 값이 존재하는지 확인
|
||||
|
|
|
|||
|
|
@ -14,6 +14,35 @@ interface NumberingRulePart {
|
|||
autoConfig?: any;
|
||||
manualConfig?: any;
|
||||
generatedValue?: string;
|
||||
separatorAfter?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트 배열에서 autoConfig.separatorAfter를 파트 레벨로 추출
|
||||
*/
|
||||
function extractSeparatorAfterFromParts(parts: any[]): any[] {
|
||||
return parts.map((part) => {
|
||||
if (part.autoConfig?.separatorAfter !== undefined) {
|
||||
part.separatorAfter = part.autoConfig.separatorAfter;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 파트별 개별 구분자를 사용하여 코드 결합
|
||||
* 마지막 파트의 separatorAfter는 무시됨
|
||||
*/
|
||||
function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string {
|
||||
let result = "";
|
||||
partValues.forEach((val, idx) => {
|
||||
result += val;
|
||||
if (idx < partValues.length - 1) {
|
||||
const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator;
|
||||
result += sep;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
interface NumberingRuleConfig {
|
||||
|
|
@ -141,7 +170,7 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
const partsResult = await pool.query(partsQuery, partsParams);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
}
|
||||
|
||||
logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, {
|
||||
|
|
@ -274,7 +303,7 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
const partsResult = await pool.query(partsQuery, partsParams);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
}
|
||||
|
||||
return result.rows;
|
||||
|
|
@ -381,7 +410,7 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
const partsResult = await pool.query(partsQuery, partsParams);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
logger.info("✅ 규칙 파트 조회 성공", {
|
||||
ruleId: rule.ruleId,
|
||||
|
|
@ -517,7 +546,7 @@ class NumberingRuleService {
|
|||
companyCode === "*" ? rule.companyCode : companyCode,
|
||||
]);
|
||||
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
}
|
||||
|
||||
logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, {
|
||||
|
|
@ -633,7 +662,7 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
const partsResult = await pool.query(partsQuery, partsParams);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
return rule;
|
||||
}
|
||||
|
|
@ -708,17 +737,25 @@ class NumberingRuleService {
|
|||
manual_config AS "manualConfig"
|
||||
`;
|
||||
|
||||
// auto_config에 separatorAfter 포함
|
||||
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
|
||||
|
||||
const partResult = await client.query(insertPartQuery, [
|
||||
config.ruleId,
|
||||
part.order,
|
||||
part.partType,
|
||||
part.generationMethod,
|
||||
JSON.stringify(part.autoConfig || {}),
|
||||
JSON.stringify(autoConfigWithSep),
|
||||
JSON.stringify(part.manualConfig || {}),
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
parts.push(partResult.rows[0]);
|
||||
const savedPart = partResult.rows[0];
|
||||
// autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동
|
||||
if (savedPart.autoConfig?.separatorAfter !== undefined) {
|
||||
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
|
||||
}
|
||||
parts.push(savedPart);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
|
@ -820,17 +857,23 @@ class NumberingRuleService {
|
|||
manual_config AS "manualConfig"
|
||||
`;
|
||||
|
||||
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
|
||||
|
||||
const partResult = await client.query(insertPartQuery, [
|
||||
ruleId,
|
||||
part.order,
|
||||
part.partType,
|
||||
part.generationMethod,
|
||||
JSON.stringify(part.autoConfig || {}),
|
||||
JSON.stringify(autoConfigWithSep),
|
||||
JSON.stringify(part.manualConfig || {}),
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
parts.push(partResult.rows[0]);
|
||||
const savedPart = partResult.rows[0];
|
||||
if (savedPart.autoConfig?.separatorAfter !== undefined) {
|
||||
savedPart.separatorAfter = savedPart.autoConfig.separatorAfter;
|
||||
}
|
||||
parts.push(savedPart);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -885,9 +928,9 @@ class NumberingRuleService {
|
|||
const rule = await this.getRuleById(ruleId, companyCode);
|
||||
if (!rule) throw new Error("규칙을 찾을 수 없습니다");
|
||||
|
||||
const parts = rule.parts
|
||||
const parts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
// 수동 입력 - 항상 ____ 마커 사용 (프론트엔드에서 편집 가능하게 처리)
|
||||
// placeholder 텍스트는 프론트엔드에서 별도로 표시
|
||||
|
|
@ -982,17 +1025,52 @@ class NumberingRuleService {
|
|||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
||||
// selectedValue는 valueCode일 수 있음 (V2Select에서 valueCode를 value로 사용)
|
||||
const selectedValueStr = String(selectedValue);
|
||||
const mapping = categoryMappings.find((m: any) => {
|
||||
// ID로 매칭
|
||||
let mapping = categoryMappings.find((m: any) => {
|
||||
// ID로 매칭 (기존 방식: V2Select가 valueId를 사용하던 경우)
|
||||
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||
return true;
|
||||
// 라벨로 매칭
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
// valueCode로 매칭 (라벨과 동일할 수 있음)
|
||||
// valueCode로 매칭 (매핑에 categoryValueCode가 있는 경우)
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr)
|
||||
return true;
|
||||
// 라벨로 매칭 (폴백)
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// 매핑을 못 찾았으면 category_values 테이블에서 valueCode → valueId 역변환 시도
|
||||
if (!mapping) {
|
||||
try {
|
||||
const pool = getPool();
|
||||
const [catTableName, catColumnName] = categoryKey.includes(".")
|
||||
? categoryKey.split(".")
|
||||
: [categoryKey, categoryKey];
|
||||
const cvResult = await pool.query(
|
||||
`SELECT value_id, value_code, value_label FROM category_values
|
||||
WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[catTableName, catColumnName, selectedValueStr]
|
||||
);
|
||||
if (cvResult.rows.length > 0) {
|
||||
const resolvedId = cvResult.rows[0].value_id;
|
||||
const resolvedLabel = cvResult.rows[0].value_label;
|
||||
mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(resolvedId)) return true;
|
||||
if (m.categoryValueLabel === resolvedLabel) return true;
|
||||
return false;
|
||||
});
|
||||
if (mapping) {
|
||||
logger.info("카테고리 매핑 역변환 성공 (valueCode→valueId)", {
|
||||
valueCode: selectedValueStr,
|
||||
resolvedId,
|
||||
resolvedLabel,
|
||||
format: mapping.format,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (lookupError: any) {
|
||||
logger.warn("카테고리 값 역변환 조회 실패", { error: lookupError.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (mapping) {
|
||||
logger.info("카테고리 매핑 적용", {
|
||||
selectedValue,
|
||||
|
|
@ -1016,9 +1094,10 @@ class NumberingRuleService {
|
|||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
const previewCode = parts.join(rule.separator || "");
|
||||
const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||
const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || "");
|
||||
logger.info("코드 미리보기 생성", {
|
||||
ruleId,
|
||||
previewCode,
|
||||
|
|
@ -1059,9 +1138,9 @@ class NumberingRuleService {
|
|||
if (manualParts.length > 0 && userInputCode) {
|
||||
// 프리뷰 코드를 생성해서 ____ 위치 파악
|
||||
// 🔧 category 파트도 처리하여 올바른 템플릿 생성
|
||||
const previewParts = rule.parts
|
||||
const previewParts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return "____";
|
||||
}
|
||||
|
|
@ -1077,39 +1156,60 @@ class NumberingRuleService {
|
|||
return "DATEPART"; // 날짜 자리 표시
|
||||
case "category": {
|
||||
// 카테고리 파트: formData에서 실제 값을 가져와서 매핑된 형식 사용
|
||||
const categoryKey = autoConfig.categoryKey;
|
||||
const categoryMappings = autoConfig.categoryMappings || [];
|
||||
const catKey2 = autoConfig.categoryKey;
|
||||
const catMappings2 = autoConfig.categoryMappings || [];
|
||||
|
||||
if (!categoryKey || !formData) {
|
||||
if (!catKey2 || !formData) {
|
||||
return "CATEGORY"; // 폴백
|
||||
}
|
||||
|
||||
const columnName = categoryKey.includes(".")
|
||||
? categoryKey.split(".")[1]
|
||||
: categoryKey;
|
||||
const selectedValue = formData[columnName];
|
||||
const colName2 = catKey2.includes(".")
|
||||
? catKey2.split(".")[1]
|
||||
: catKey2;
|
||||
const selVal2 = formData[colName2];
|
||||
|
||||
if (!selectedValue) {
|
||||
if (!selVal2) {
|
||||
return "CATEGORY"; // 폴백
|
||||
}
|
||||
|
||||
const selectedValueStr = String(selectedValue);
|
||||
const mapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||
return true;
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
const selValStr2 = String(selVal2);
|
||||
let catMapping2 = catMappings2.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selValStr2) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selValStr2) return true;
|
||||
if (m.categoryValueLabel === selValStr2) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
return mapping?.format || "CATEGORY";
|
||||
// valueCode → valueId 역변환 시도
|
||||
if (!catMapping2) {
|
||||
try {
|
||||
const pool2 = getPool();
|
||||
const [ct2, cc2] = catKey2.includes(".") ? catKey2.split(".") : [catKey2, catKey2];
|
||||
const cvr2 = await pool2.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[ct2, cc2, selValStr2]
|
||||
);
|
||||
if (cvr2.rows.length > 0) {
|
||||
const rid2 = cvr2.rows[0].value_id;
|
||||
const rlabel2 = cvr2.rows[0].value_label;
|
||||
catMapping2 = catMappings2.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(rid2)) return true;
|
||||
if (m.categoryValueLabel === rlabel2) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return catMapping2?.format || "CATEGORY";
|
||||
}
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
const separator = rule.separator || "";
|
||||
const previewTemplate = previewParts.join(separator);
|
||||
const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||
const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || "");
|
||||
|
||||
// 사용자 입력 코드에서 수동 입력 부분 추출
|
||||
// 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출
|
||||
|
|
@ -1150,9 +1250,9 @@ class NumberingRuleService {
|
|||
}
|
||||
|
||||
let manualPartIndex = 0;
|
||||
const parts = rule.parts
|
||||
const parts = await Promise.all(rule.parts
|
||||
.sort((a: any, b: any) => a.order - b.order)
|
||||
.map((part: any) => {
|
||||
.map(async (part: any) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
// 추출된 수동 입력 값 사용, 없으면 기본값 사용
|
||||
const manualValue =
|
||||
|
|
@ -1267,28 +1367,53 @@ class NumberingRuleService {
|
|||
|
||||
// 카테고리 매핑에서 해당 값에 대한 형식 찾기
|
||||
const selectedValueStr = String(selectedValue);
|
||||
const mapping = categoryMappings.find((m: any) => {
|
||||
// ID로 매칭
|
||||
if (m.categoryValueId?.toString() === selectedValueStr)
|
||||
return true;
|
||||
// 라벨로 매칭
|
||||
let allocMapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === selectedValueStr) return true;
|
||||
if (m.categoryValueCode && m.categoryValueCode === selectedValueStr) return true;
|
||||
if (m.categoryValueLabel === selectedValueStr) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (mapping) {
|
||||
// valueCode → valueId 역변환 시도
|
||||
if (!allocMapping) {
|
||||
try {
|
||||
const pool3 = getPool();
|
||||
const [ct3, cc3] = categoryKey.includes(".") ? categoryKey.split(".") : [categoryKey, categoryKey];
|
||||
const cvr3 = await pool3.query(
|
||||
`SELECT value_id, value_label FROM category_values WHERE table_name = $1 AND column_name = $2 AND value_code = $3 LIMIT 1`,
|
||||
[ct3, cc3, selectedValueStr]
|
||||
);
|
||||
if (cvr3.rows.length > 0) {
|
||||
const rid3 = cvr3.rows[0].value_id;
|
||||
const rlabel3 = cvr3.rows[0].value_label;
|
||||
allocMapping = categoryMappings.find((m: any) => {
|
||||
if (m.categoryValueId?.toString() === String(rid3)) return true;
|
||||
if (m.categoryValueLabel === rlabel3) return true;
|
||||
return false;
|
||||
});
|
||||
if (allocMapping) {
|
||||
logger.info("allocateCode: 카테고리 매핑 역변환 성공", {
|
||||
valueCode: selectedValueStr, resolvedId: rid3, format: allocMapping.format,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (allocMapping) {
|
||||
logger.info("allocateCode: 카테고리 매핑 적용", {
|
||||
selectedValue,
|
||||
format: mapping.format,
|
||||
categoryValueLabel: mapping.categoryValueLabel,
|
||||
format: allocMapping.format,
|
||||
categoryValueLabel: allocMapping.categoryValueLabel,
|
||||
});
|
||||
return mapping.format || "";
|
||||
return allocMapping.format || "";
|
||||
}
|
||||
|
||||
logger.warn("allocateCode: 카테고리 매핑을 찾을 수 없음", {
|
||||
selectedValue,
|
||||
availableMappings: categoryMappings.map((m: any) => ({
|
||||
id: m.categoryValueId,
|
||||
code: m.categoryValueCode,
|
||||
label: m.categoryValueLabel,
|
||||
})),
|
||||
});
|
||||
|
|
@ -1299,9 +1424,10 @@ class NumberingRuleService {
|
|||
logger.warn("알 수 없는 파트 타입", { partType: part.partType });
|
||||
return "";
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
const allocatedCode = parts.join(rule.separator || "");
|
||||
const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order);
|
||||
const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || "");
|
||||
|
||||
// 순번이 있는 경우에만 증가
|
||||
const hasSequence = rule.parts.some(
|
||||
|
|
@ -1460,7 +1586,7 @@ class NumberingRuleService {
|
|||
rule.ruleId,
|
||||
companyCode === "*" ? rule.companyCode : companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
}
|
||||
|
||||
logger.info("[테스트] 채번 규칙 목록 조회 완료", {
|
||||
|
|
@ -1553,7 +1679,7 @@ class NumberingRuleService {
|
|||
rule.ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", {
|
||||
ruleId: rule.ruleId,
|
||||
|
|
@ -1673,12 +1799,14 @@ class NumberingRuleService {
|
|||
auto_config, manual_config, company_code, created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
|
||||
`;
|
||||
const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" };
|
||||
|
||||
await client.query(partInsertQuery, [
|
||||
config.ruleId,
|
||||
part.order,
|
||||
part.partType,
|
||||
part.generationMethod,
|
||||
JSON.stringify(part.autoConfig || {}),
|
||||
JSON.stringify(autoConfigWithSep),
|
||||
JSON.stringify(part.manualConfig || {}),
|
||||
companyCode,
|
||||
]);
|
||||
|
|
@ -1833,7 +1961,7 @@ class NumberingRuleService {
|
|||
rule.ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
logger.info("카테고리 조건 매칭 채번 규칙 찾음", {
|
||||
ruleId: rule.ruleId,
|
||||
|
|
@ -1892,7 +2020,7 @@ class NumberingRuleService {
|
|||
rule.ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
|
||||
logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", {
|
||||
ruleId: rule.ruleId,
|
||||
|
|
@ -1975,7 +2103,7 @@ class NumberingRuleService {
|
|||
rule.ruleId,
|
||||
companyCode,
|
||||
]);
|
||||
rule.parts = partsResult.rows;
|
||||
rule.parts = extractSeparatorAfterFromParts(partsResult.rows);
|
||||
}
|
||||
|
||||
return result.rows;
|
||||
|
|
|
|||
|
|
@ -1728,25 +1728,35 @@ export class ScreenManagementService {
|
|||
throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// 🆕 V2 테이블 우선 조회 (회사별 → 공통(*))
|
||||
// V2 테이블 우선 조회: 기본 레이어(layer_id=1)만 가져옴
|
||||
// layer_id 필터 없이 queryOne 하면 조건부 레이어가 반환될 수 있음
|
||||
let v2Layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 레이아웃 없으면 공통(*) 조회
|
||||
// 최고관리자(*): 화면 정의의 company_code로 재조회
|
||||
if (!v2Layout && companyCode === "*" && existingScreen.company_code && existingScreen.company_code !== "*") {
|
||||
v2Layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||
[screenId, existingScreen.company_code],
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 사용자: 회사별 레이아웃 없으면 공통(*) 조회
|
||||
if (!v2Layout && companyCode !== "*") {
|
||||
v2Layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
WHERE screen_id = $1 AND company_code = '*' AND layer_id = 1`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
|
||||
// V2 레이아웃이 있으면 V2 형식으로 반환
|
||||
if (v2Layout && v2Layout.layout_data) {
|
||||
console.log(`V2 레이아웃 발견, V2 형식으로 반환`);
|
||||
|
||||
const layoutData = v2Layout.layout_data;
|
||||
|
||||
// URL에서 컴포넌트 타입 추출하는 헬퍼 함수
|
||||
|
|
@ -1806,7 +1816,7 @@ export class ScreenManagementService {
|
|||
};
|
||||
}
|
||||
|
||||
console.log(`V2 레이아웃 없음, V1 테이블 조회`);
|
||||
|
||||
|
||||
const layouts = await query<any>(
|
||||
`SELECT * FROM screen_layouts
|
||||
|
|
@ -4252,16 +4262,16 @@ export class ScreenManagementService {
|
|||
},
|
||||
);
|
||||
|
||||
// V2 레이아웃 저장 (UPSERT)
|
||||
// V2 레이아웃 저장 (UPSERT) - layer_id 포함
|
||||
await client.query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, 1, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
|
||||
);
|
||||
|
||||
console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`);
|
||||
|
||||
} catch (error) {
|
||||
console.error("V2 레이아웃 복사 중 오류:", error);
|
||||
// 레이아웃 복사 실패해도 화면 생성은 유지
|
||||
|
|
@ -5052,8 +5062,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";
|
||||
|
|
@ -5080,67 +5089,94 @@ export class ScreenManagementService {
|
|||
|
||||
let layout: { layout_data: any } | null = null;
|
||||
|
||||
// SUPER_ADMIN인 경우: 화면의 회사 코드로 레이아웃 조회
|
||||
if (isSuperAdmin) {
|
||||
// 1. 화면 정의의 회사 코드로 레이아웃 조회
|
||||
// 🆕 기본 레이어(layer_id=1)를 우선 로드
|
||||
// SUPER_ADMIN이거나 companyCode가 "*"인 경우: 화면의 회사 코드로 레이아웃 조회
|
||||
if (isSuperAdmin || companyCode === "*") {
|
||||
// 1. 화면 정의의 회사 코드 + 기본 레이어
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||
[screenId, existingScreen.company_code],
|
||||
);
|
||||
|
||||
// 2. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째 조회
|
||||
// 2. 기본 레이어 없으면 layer_id 조건 없이 조회 (하위 호환)
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId, existingScreen.company_code],
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 화면 정의의 회사 코드로 없으면, 해당 화면의 모든 레이아웃 중 첫 번째
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1
|
||||
ORDER BY updated_at DESC
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 일반 사용자: 기존 로직 (회사별 우선, 없으면 공통(*) 조회)
|
||||
// 일반 사용자: 회사별 우선 + 기본 레이어
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 기본 레이어 없으면 layer_id 조건 없이 (하위 호환)
|
||||
if (!layout) {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
// 회사별 레이아웃이 없으면 공통(*) 레이아웃 조회
|
||||
if (!layout && companyCode !== "*") {
|
||||
layout = await queryOne<{ layout_data: any }>(
|
||||
`SELECT layout_data FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'`,
|
||||
WHERE screen_id = $1 AND company_code = '*'
|
||||
ORDER BY layer_id ASC
|
||||
LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!layout) {
|
||||
console.log(`V2 레이아웃 없음: screen_id=${screenId}`);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
|
||||
);
|
||||
|
||||
return layout.layout_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 레이아웃 저장 (1 레코드 방식)
|
||||
* - screen_layouts_v2 테이블에 화면당 1개 레코드 저장
|
||||
* - layout_data JSON에 모든 컴포넌트 포함
|
||||
* V2 레이아웃 저장 (레이어별 저장)
|
||||
* - screen_layouts_v2 테이블에 화면당 레이어별 1개 레코드 저장
|
||||
* - layout_data JSON에 해당 레이어의 컴포넌트 포함
|
||||
*/
|
||||
async saveLayoutV2(
|
||||
screenId: number,
|
||||
layoutData: any,
|
||||
companyCode: string,
|
||||
): Promise<void> {
|
||||
console.log(`=== V2 레이아웃 저장 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
|
||||
const layerId = layoutData.layerId || 1;
|
||||
const layerName = layoutData.layerName || (layerId === 1 ? '기본 레이어' : `레이어 ${layerId}`);
|
||||
// conditionConfig가 명시적으로 전달되었는지 확인 (undefined = 미전달, null/object = 명시적 전달)
|
||||
const hasConditionConfig = 'conditionConfig' in layoutData;
|
||||
const conditionConfig = layoutData.conditionConfig || null;
|
||||
|
||||
|
||||
|
||||
// 권한 확인
|
||||
const screens = await query<{ company_code: string | null }>(
|
||||
|
|
@ -5158,22 +5194,375 @@ export class ScreenManagementService {
|
|||
throw new Error("이 화면의 레이아웃을 저장할 권한이 없습니다.");
|
||||
}
|
||||
|
||||
// 버전 정보 추가 (updatedAt은 DB 컬럼 updated_at으로 관리)
|
||||
// 화면의 기본 테이블 업데이트 (테이블이 선택된 경우)
|
||||
const mainTableName = layoutData.mainTableName;
|
||||
if (mainTableName) {
|
||||
await query(
|
||||
`UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`,
|
||||
[mainTableName, screenId],
|
||||
);
|
||||
console.log(`✅ [saveLayoutV2] 화면 기본 테이블 업데이트: ${mainTableName}`);
|
||||
}
|
||||
|
||||
// 저장할 layout_data에서 레이어 메타 정보 제거 (순수 레이아웃만 저장)
|
||||
const { layerId: _lid, layerName: _ln, conditionConfig: _cc, mainTableName: _mtn, ...pureLayoutData } = layoutData;
|
||||
const dataToSave = {
|
||||
version: "2.0",
|
||||
...layoutData
|
||||
...pureLayoutData,
|
||||
};
|
||||
|
||||
// UPSERT (있으면 업데이트, 없으면 삽입)
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code)
|
||||
DO UPDATE SET layout_data = $3, updated_at = NOW()`,
|
||||
[screenId, companyCode, 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)],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면의 모든 레이어 목록 조회
|
||||
* 레이어가 없으면 기본 레이어를 자동 생성
|
||||
*/
|
||||
async getScreenLayers(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
): Promise<any[]> {
|
||||
let layers;
|
||||
|
||||
if (companyCode === "*") {
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1
|
||||
ORDER BY layer_id`,
|
||||
[screenId],
|
||||
);
|
||||
} else {
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
|
||||
// 회사별 레이어가 없으면 공통(*) 레이어 조회
|
||||
if (layers.length === 0 && companyCode !== "*") {
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*'
|
||||
ORDER BY layer_id`,
|
||||
[screenId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 레이어가 없으면 기본 레이어 자동 생성
|
||||
if (layers.length === 0) {
|
||||
const defaultLayout = JSON.stringify({ version: "2.0", components: [] });
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layer_id, layer_name, layout_data, created_at, updated_at)
|
||||
VALUES ($1, $2, 1, '기본 레이어', $3, NOW(), NOW())
|
||||
ON CONFLICT (screen_id, company_code, layer_id) DO NOTHING`,
|
||||
[screenId, companyCode, defaultLayout],
|
||||
);
|
||||
console.log(`기본 레이어 자동 생성: screen_id=${screenId}, company_code=${companyCode}`);
|
||||
|
||||
// 다시 조회
|
||||
layers = await query<any>(
|
||||
`SELECT layer_id, layer_name, condition_config,
|
||||
jsonb_array_length(COALESCE(layout_data->'components', '[]'::jsonb)) as component_count,
|
||||
updated_at
|
||||
FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2
|
||||
ORDER BY layer_id`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 레이어의 레이아웃 조회
|
||||
*/
|
||||
async getLayerLayout(
|
||||
screenId: number,
|
||||
layerId: number,
|
||||
companyCode: string,
|
||||
): Promise<any> {
|
||||
let layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
[screenId, companyCode, layerId],
|
||||
);
|
||||
|
||||
console.log(`V2 레이아웃 저장 완료`);
|
||||
// 최고관리자(*): 화면 정의의 company_code로 재조회
|
||||
if (!layout && companyCode === "*") {
|
||||
const screenDef = await queryOne<{ company_code: string }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId],
|
||||
);
|
||||
if (screenDef && screenDef.company_code && screenDef.company_code !== "*") {
|
||||
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
[screenId, screenDef.company_code, layerId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 일반 사용자: 회사별 레이어가 없으면 공통(*) 조회
|
||||
if (!layout && companyCode !== "*") {
|
||||
layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>(
|
||||
`SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = '*' AND layer_id = $2`,
|
||||
[screenId, layerId],
|
||||
);
|
||||
}
|
||||
|
||||
if (!layout) return null;
|
||||
|
||||
return {
|
||||
...layout.layout_data,
|
||||
layerId,
|
||||
layerName: layout.layer_name,
|
||||
conditionConfig: layout.condition_config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이어 삭제
|
||||
*/
|
||||
async deleteLayer(
|
||||
screenId: number,
|
||||
layerId: number,
|
||||
companyCode: string,
|
||||
): Promise<void> {
|
||||
if (layerId === 1) {
|
||||
throw new Error("기본 레이어는 삭제할 수 없습니다.");
|
||||
}
|
||||
|
||||
await query(
|
||||
`DELETE FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
[screenId, companyCode, layerId],
|
||||
);
|
||||
|
||||
console.log(`레이어 삭제 완료: screen_id=${screenId}, layer_id=${layerId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이어 조건 설정 업데이트
|
||||
*/
|
||||
async updateLayerCondition(
|
||||
screenId: number,
|
||||
layerId: number,
|
||||
companyCode: string,
|
||||
conditionConfig: any,
|
||||
layerName?: string,
|
||||
): Promise<void> {
|
||||
const setClauses = ['condition_config = $4', 'updated_at = NOW()'];
|
||||
const params: any[] = [screenId, companyCode, layerId, conditionConfig ? JSON.stringify(conditionConfig) : null];
|
||||
|
||||
if (layerName) {
|
||||
setClauses.push(`layer_name = $${params.length + 1}`);
|
||||
params.push(layerName);
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE screen_layouts_v2 SET ${setClauses.join(', ')}
|
||||
WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 조건부 영역(Zone) 관리
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 화면의 조건부 영역(Zone) 목록 조회
|
||||
*/
|
||||
async getScreenZones(screenId: number, companyCode: string): Promise<any[]> {
|
||||
let zones;
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 회사 Zone 조회 가능
|
||||
zones = await query<any>(
|
||||
`SELECT * FROM screen_conditional_zones WHERE screen_id = $1 ORDER BY zone_id`,
|
||||
[screenId],
|
||||
);
|
||||
} else {
|
||||
// 일반 회사: 자사 Zone + 공통(*) Zone 조회
|
||||
zones = await query<any>(
|
||||
`SELECT * FROM screen_conditional_zones
|
||||
WHERE screen_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
ORDER BY zone_id`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
}
|
||||
return zones;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 영역(Zone) 생성
|
||||
*/
|
||||
async createZone(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
zoneData: {
|
||||
zone_name?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
trigger_component_id?: string;
|
||||
trigger_operator?: string;
|
||||
},
|
||||
): Promise<any> {
|
||||
const result = await queryOne<any>(
|
||||
`INSERT INTO screen_conditional_zones
|
||||
(screen_id, company_code, zone_name, x, y, width, height, trigger_component_id, trigger_operator)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[
|
||||
screenId,
|
||||
companyCode,
|
||||
zoneData.zone_name || '조건부 영역',
|
||||
zoneData.x,
|
||||
zoneData.y,
|
||||
zoneData.width,
|
||||
zoneData.height,
|
||||
zoneData.trigger_component_id || null,
|
||||
zoneData.trigger_operator || 'eq',
|
||||
],
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 영역(Zone) 업데이트 (위치/크기/트리거)
|
||||
*/
|
||||
async updateZone(
|
||||
zoneId: number,
|
||||
companyCode: string,
|
||||
updates: {
|
||||
zone_name?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
trigger_component_id?: string;
|
||||
trigger_operator?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const setClauses: string[] = ['updated_at = NOW()'];
|
||||
const params: any[] = [zoneId, companyCode];
|
||||
let paramIdx = 3;
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== undefined) {
|
||||
setClauses.push(`${key} = $${paramIdx}`);
|
||||
params.push(value);
|
||||
paramIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE screen_conditional_zones SET ${setClauses.join(', ')}
|
||||
WHERE zone_id = $1 AND company_code = $2`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건부 영역(Zone) 삭제 + 소속 레이어들의 condition_config 정리
|
||||
*/
|
||||
async deleteZone(zoneId: number, companyCode: string): Promise<void> {
|
||||
// Zone에 소속된 레이어들의 condition_config에서 zone_id 제거
|
||||
await query(
|
||||
`UPDATE screen_layouts_v2 SET condition_config = NULL, updated_at = NOW()
|
||||
WHERE company_code = $1 AND condition_config->>'zone_id' = $2::text`,
|
||||
[companyCode, String(zoneId)],
|
||||
);
|
||||
|
||||
await query(
|
||||
`DELETE FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
|
||||
[zoneId, companyCode],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone에 레이어 추가 (빈 레이아웃으로 새 레이어 생성 + zone_id 할당)
|
||||
*/
|
||||
async addLayerToZone(
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
zoneId: number,
|
||||
conditionValue: string,
|
||||
layerName?: string,
|
||||
): Promise<{ layerId: number }> {
|
||||
// 다음 layer_id 계산
|
||||
const maxResult = await queryOne<{ max_id: number }>(
|
||||
`SELECT COALESCE(MAX(layer_id), 1) as max_id FROM screen_layouts_v2
|
||||
WHERE screen_id = $1 AND company_code = $2`,
|
||||
[screenId, companyCode],
|
||||
);
|
||||
const newLayerId = (maxResult?.max_id || 1) + 1;
|
||||
|
||||
// Zone 정보로 캔버스 크기 결정 (company_code 필터링 필수)
|
||||
const zone = await queryOne<any>(
|
||||
`SELECT * FROM screen_conditional_zones WHERE zone_id = $1 AND company_code = $2`,
|
||||
[zoneId, companyCode],
|
||||
);
|
||||
|
||||
const layoutData = {
|
||||
version: "2.1",
|
||||
components: [],
|
||||
screenResolution: zone
|
||||
? { width: zone.width, height: zone.height }
|
||||
: { width: 800, height: 200 },
|
||||
};
|
||||
|
||||
const conditionConfig = {
|
||||
zone_id: zoneId,
|
||||
condition_value: conditionValue,
|
||||
};
|
||||
|
||||
await query(
|
||||
`INSERT INTO screen_layouts_v2 (screen_id, company_code, layout_data, layer_id, layer_name, condition_config)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (screen_id, company_code, layer_id) DO UPDATE
|
||||
SET layout_data = EXCLUDED.layout_data,
|
||||
layer_name = EXCLUDED.layer_name,
|
||||
condition_config = EXCLUDED.condition_config,
|
||||
updated_at = NOW()`,
|
||||
[screenId, companyCode, JSON.stringify(layoutData), newLayerId, layerName || `레이어 ${newLayerId}`, JSON.stringify(conditionConfig)],
|
||||
);
|
||||
|
||||
return { layerId: newLayerId };
|
||||
}
|
||||
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -199,7 +199,15 @@ export class TableManagementService {
|
|||
cl.input_type as "cl_input_type",
|
||||
COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings",
|
||||
COALESCE(ttc.description, cl.description, '') as "description",
|
||||
c.is_nullable as "isNullable",
|
||||
CASE
|
||||
WHEN COALESCE(ttc.is_nullable, cl.is_nullable) IS NOT NULL
|
||||
THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END
|
||||
ELSE c.is_nullable
|
||||
END as "isNullable",
|
||||
CASE
|
||||
WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES'
|
||||
ELSE 'NO'
|
||||
END as "isUnique",
|
||||
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
||||
c.column_default as "defaultValue",
|
||||
c.character_maximum_length as "maxLength",
|
||||
|
|
@ -241,7 +249,15 @@ export class TableManagementService {
|
|||
COALESCE(cl.input_type, 'direct') as "inputType",
|
||||
COALESCE(cl.detail_settings::text, '') as "detailSettings",
|
||||
COALESCE(cl.description, '') as "description",
|
||||
c.is_nullable as "isNullable",
|
||||
CASE
|
||||
WHEN cl.is_nullable IS NOT NULL
|
||||
THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END
|
||||
ELSE c.is_nullable
|
||||
END as "isNullable",
|
||||
CASE
|
||||
WHEN cl.is_unique = 'Y' THEN 'YES'
|
||||
ELSE 'NO'
|
||||
END as "isUnique",
|
||||
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey",
|
||||
c.column_default as "defaultValue",
|
||||
c.character_maximum_length as "maxLength",
|
||||
|
|
@ -502,8 +518,8 @@ export class TableManagementService {
|
|||
table_name, column_name, column_label, input_type, detail_settings,
|
||||
code_category, code_value, reference_table, reference_column,
|
||||
display_column, display_order, is_visible, is_nullable,
|
||||
company_code, created_date, updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW())
|
||||
company_code, category_ref, created_date, updated_date
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, $14, NOW(), NOW())
|
||||
ON CONFLICT (table_name, column_name, company_code)
|
||||
DO UPDATE SET
|
||||
column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label),
|
||||
|
|
@ -516,6 +532,7 @@ export class TableManagementService {
|
|||
display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column),
|
||||
display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order),
|
||||
is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible),
|
||||
category_ref = EXCLUDED.category_ref,
|
||||
updated_date = NOW()`,
|
||||
[
|
||||
tableName,
|
||||
|
|
@ -531,6 +548,7 @@ export class TableManagementService {
|
|||
settings.displayOrder || 0,
|
||||
settings.isVisible !== undefined ? settings.isVisible : true,
|
||||
companyCode,
|
||||
settings.categoryRef || null,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -1599,7 +1617,8 @@ export class TableManagementService {
|
|||
tableName,
|
||||
columnName,
|
||||
actualValue,
|
||||
paramIndex
|
||||
paramIndex,
|
||||
operator
|
||||
);
|
||||
|
||||
case "entity":
|
||||
|
|
@ -1612,7 +1631,14 @@ export class TableManagementService {
|
|||
);
|
||||
|
||||
default:
|
||||
// 기본 문자열 검색 (actualValue 사용)
|
||||
// operator에 따라 정확 일치 또는 부분 일치 검색
|
||||
if (operator === "equals") {
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
values: [String(actualValue)],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${actualValue}%`],
|
||||
|
|
@ -1626,10 +1652,19 @@ export class TableManagementService {
|
|||
);
|
||||
// 오류 시 기본 검색으로 폴백
|
||||
let fallbackValue = value;
|
||||
let fallbackOperator = "contains";
|
||||
if (typeof value === "object" && value !== null && "value" in value) {
|
||||
fallbackValue = value.value;
|
||||
fallbackOperator = value.operator || "contains";
|
||||
}
|
||||
|
||||
if (fallbackOperator === "equals") {
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
values: [String(fallbackValue)],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${fallbackValue}%`],
|
||||
|
|
@ -1776,7 +1811,8 @@ export class TableManagementService {
|
|||
tableName: string,
|
||||
columnName: string,
|
||||
value: any,
|
||||
paramIndex: number
|
||||
paramIndex: number,
|
||||
operator: string = "contains"
|
||||
): Promise<{
|
||||
whereClause: string;
|
||||
values: any[];
|
||||
|
|
@ -1786,7 +1822,14 @@ export class TableManagementService {
|
|||
const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName);
|
||||
|
||||
if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) {
|
||||
// 코드 타입이 아니면 기본 검색
|
||||
// 코드 타입이 아니면 operator에 따라 검색
|
||||
if (operator === "equals") {
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
values: [String(value)],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
whereClause: `${columnName}::text ILIKE $${paramIndex}`,
|
||||
values: [`%${value}%`],
|
||||
|
|
@ -1794,6 +1837,15 @@ export class TableManagementService {
|
|||
};
|
||||
}
|
||||
|
||||
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
|
||||
if (operator === "equals") {
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
values: [String(value)],
|
||||
paramCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === "string" && value.trim() !== "") {
|
||||
// 코드값 또는 코드명으로 검색
|
||||
return {
|
||||
|
|
@ -2431,6 +2483,154 @@ export class TableManagementService {
|
|||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사별 NOT NULL 소프트 제약조건 검증
|
||||
* table_type_columns.is_nullable = 'N'인 컬럼에 NULL/빈값이 들어오면 위반 목록을 반환한다.
|
||||
*/
|
||||
async validateNotNullConstraints(
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
companyCode: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
|
||||
const notNullColumns = await query<{ column_name: string; column_label: string }>(
|
||||
`SELECT
|
||||
ttc.column_name,
|
||||
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||
FROM table_type_columns ttc
|
||||
WHERE ttc.table_name = $1
|
||||
AND ttc.is_nullable = 'N'
|
||||
AND ttc.company_code = $2`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
// 회사별 설정이 없으면 공통 설정 확인
|
||||
if (notNullColumns.length === 0 && companyCode !== "*") {
|
||||
const globalNotNull = await query<{ column_name: string; column_label: string }>(
|
||||
`SELECT
|
||||
ttc.column_name,
|
||||
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||
FROM table_type_columns ttc
|
||||
WHERE ttc.table_name = $1
|
||||
AND ttc.is_nullable = 'N'
|
||||
AND ttc.company_code = '*'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM table_type_columns ttc2
|
||||
WHERE ttc2.table_name = ttc.table_name
|
||||
AND ttc2.column_name = ttc.column_name
|
||||
AND ttc2.company_code = $2
|
||||
)`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
notNullColumns.push(...globalNotNull);
|
||||
}
|
||||
|
||||
if (notNullColumns.length === 0) return [];
|
||||
|
||||
const violations: string[] = [];
|
||||
for (const col of notNullColumns) {
|
||||
const value = data[col.column_name];
|
||||
// NULL, undefined, 빈 문자열을 NOT NULL 위반으로 처리
|
||||
if (value === null || value === undefined || value === "") {
|
||||
violations.push(col.column_label);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
} catch (error) {
|
||||
logger.error(`NOT NULL 검증 오류: ${tableName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회사별 UNIQUE 소프트 제약조건 검증
|
||||
* table_type_columns.is_unique = 'Y'인 컬럼에 중복 값이 들어오면 위반 목록을 반환한다.
|
||||
* @param excludeId 수정 시 자기 자신은 제외
|
||||
*/
|
||||
async validateUniqueConstraints(
|
||||
tableName: string,
|
||||
data: Record<string, any>,
|
||||
companyCode: string,
|
||||
excludeId?: string
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
|
||||
let uniqueColumns = await query<{ column_name: string; column_label: string }>(
|
||||
`SELECT
|
||||
ttc.column_name,
|
||||
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||
FROM table_type_columns ttc
|
||||
WHERE ttc.table_name = $1
|
||||
AND ttc.is_unique = 'Y'
|
||||
AND ttc.company_code = $2`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
// 회사별 설정이 없으면 공통 설정 확인
|
||||
if (uniqueColumns.length === 0 && companyCode !== "*") {
|
||||
const globalUnique = await query<{ column_name: string; column_label: string }>(
|
||||
`SELECT
|
||||
ttc.column_name,
|
||||
COALESCE(ttc.column_label, ttc.column_name) as column_label
|
||||
FROM table_type_columns ttc
|
||||
WHERE ttc.table_name = $1
|
||||
AND ttc.is_unique = 'Y'
|
||||
AND ttc.company_code = '*'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM table_type_columns ttc2
|
||||
WHERE ttc2.table_name = ttc.table_name
|
||||
AND ttc2.column_name = ttc.column_name
|
||||
AND ttc2.company_code = $2
|
||||
)`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
uniqueColumns = globalUnique;
|
||||
}
|
||||
|
||||
if (uniqueColumns.length === 0) return [];
|
||||
|
||||
const violations: string[] = [];
|
||||
for (const col of uniqueColumns) {
|
||||
const value = data[col.column_name];
|
||||
if (value === null || value === undefined || value === "") continue;
|
||||
|
||||
// 해당 회사 내에서 같은 값이 이미 존재하는지 확인
|
||||
const hasCompanyCode = await query(
|
||||
`SELECT column_name FROM information_schema.columns
|
||||
WHERE table_name = $1 AND column_name = 'company_code'`,
|
||||
[tableName]
|
||||
);
|
||||
|
||||
let dupQuery: string;
|
||||
let dupParams: any[];
|
||||
|
||||
if (hasCompanyCode.length > 0 && companyCode !== "*") {
|
||||
dupQuery = excludeId
|
||||
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1`
|
||||
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`;
|
||||
dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode];
|
||||
} else {
|
||||
dupQuery = excludeId
|
||||
? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1`
|
||||
: `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`;
|
||||
dupParams = excludeId ? [value, excludeId] : [value];
|
||||
}
|
||||
|
||||
const dupResult = await query(dupQuery, dupParams);
|
||||
if (dupResult.length > 0) {
|
||||
violations.push(`${col.column_label} (${value})`);
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
} catch (error) {
|
||||
logger.error(`UNIQUE 검증 오류: ${tableName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블에 데이터 추가
|
||||
* @returns 무시된 컬럼 정보 (디버깅용)
|
||||
|
|
@ -2438,7 +2638,7 @@ export class TableManagementService {
|
|||
async addTableData(
|
||||
tableName: string,
|
||||
data: Record<string, any>
|
||||
): Promise<{ skippedColumns: string[]; savedColumns: string[] }> {
|
||||
): Promise<{ skippedColumns: string[]; savedColumns: string[]; insertedId: string | null }> {
|
||||
try {
|
||||
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
|
||||
logger.info(`추가할 데이터:`, data);
|
||||
|
|
@ -2551,19 +2751,21 @@ export class TableManagementService {
|
|||
const insertQuery = `
|
||||
INSERT INTO "${tableName}" (${columnNames})
|
||||
VALUES (${placeholders})
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
logger.info(`실행할 쿼리: ${insertQuery}`);
|
||||
logger.info(`쿼리 파라미터:`, values);
|
||||
|
||||
await query(insertQuery, values);
|
||||
const insertResult = await query(insertQuery, values) as any[];
|
||||
const insertedId = insertResult?.[0]?.id ?? null;
|
||||
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}`);
|
||||
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`);
|
||||
|
||||
// 무시된 컬럼과 저장된 컬럼 정보 반환
|
||||
return {
|
||||
skippedColumns,
|
||||
savedColumns: existingColumns,
|
||||
insertedId,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`테이블 데이터 추가 오류: ${tableName}`, error);
|
||||
|
|
@ -2875,10 +3077,11 @@ export class TableManagementService {
|
|||
};
|
||||
}
|
||||
|
||||
// Entity 조인 설정 감지 (화면별 엔티티 설정 전달)
|
||||
// Entity 조인 설정 감지 (화면별 엔티티 설정 + 회사코드 전달)
|
||||
let joinConfigs = await entityJoinService.detectEntityJoins(
|
||||
tableName,
|
||||
options.screenEntityConfigs
|
||||
options.screenEntityConfigs,
|
||||
options.companyCode
|
||||
);
|
||||
|
||||
logger.info(
|
||||
|
|
@ -2978,31 +3181,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 +3479,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 +3509,9 @@ export class TableManagementService {
|
|||
whereClause,
|
||||
orderBy,
|
||||
limit,
|
||||
offset
|
||||
offset,
|
||||
undefined,
|
||||
referenceTableColumns // 🆕 참조 테이블 전체 컬럼 전달
|
||||
).query;
|
||||
|
||||
// 카운트 쿼리
|
||||
|
|
@ -3767,12 +4012,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 +4128,7 @@ export class TableManagementService {
|
|||
/**
|
||||
* 참조 테이블의 표시 컬럼 목록 조회
|
||||
*/
|
||||
async getReferenceTableColumns(tableName: string): Promise<
|
||||
async getReferenceTableColumns(tableName: string, companyCode?: string): Promise<
|
||||
Array<{
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
|
|
@ -3891,7 +4136,7 @@ export class TableManagementService {
|
|||
inputType?: string;
|
||||
}>
|
||||
> {
|
||||
return await entityJoinService.getReferenceTableColumns(tableName);
|
||||
return await entityJoinService.getReferenceTableColumns(tableName, companyCode);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -4310,7 +4555,8 @@ export class TableManagementService {
|
|||
END as "detailSettings",
|
||||
ttc.is_nullable as "isNullable",
|
||||
ic.data_type as "dataType",
|
||||
ttc.company_code as "companyCode"
|
||||
ttc.company_code as "companyCode",
|
||||
ttc.category_ref as "categoryRef"
|
||||
FROM table_type_columns ttc
|
||||
LEFT JOIN information_schema.columns ic
|
||||
ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name
|
||||
|
|
@ -4387,20 +4633,24 @@ export class TableManagementService {
|
|||
}
|
||||
|
||||
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
|
||||
const baseInfo = {
|
||||
const baseInfo: any = {
|
||||
tableName: tableName,
|
||||
columnName: col.columnName,
|
||||
displayName: col.displayName,
|
||||
dataType: col.dataType || "varchar",
|
||||
inputType: col.inputType,
|
||||
detailSettings: col.detailSettings,
|
||||
description: "", // 필수 필드 추가
|
||||
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
|
||||
description: "",
|
||||
isNullable: col.isNullable === "Y" ? "Y" : "N",
|
||||
isPrimaryKey: false,
|
||||
displayOrder: 0,
|
||||
isVisible: true,
|
||||
};
|
||||
|
||||
if (col.categoryRef) {
|
||||
baseInfo.categoryRef = col.categoryRef;
|
||||
}
|
||||
|
||||
// 카테고리 타입인 경우 categoryMenus 추가
|
||||
if (
|
||||
col.inputType === "category" &&
|
||||
|
|
@ -5005,14 +5255,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 +5284,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]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ export interface ColumnSettings {
|
|||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
displayOrder?: number; // 표시 순서
|
||||
isVisible?: boolean; // 표시 여부
|
||||
categoryRef?: string | null; // 카테고리 참조
|
||||
}
|
||||
|
||||
export interface TableLabels {
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ export const componentDefaults: Record<string, any> = {
|
|||
"v2-date": { type: "v2-date", webType: "date" },
|
||||
"v2-repeater": { type: "v2-repeater", webType: "custom" },
|
||||
"v2-repeat-container": { type: "v2-repeat-container", webType: "custom" },
|
||||
"v2-split-line": { type: "v2-split-line", webType: "custom", resizable: true, lineWidth: 4 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* BOM Screen - Restoration Verification
|
||||
* Screen 4168 - verify split panel, BOM list, and tree with child items
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const SCREENSHOT_DIR = join(process.cwd(), 'bom-detail-test-screenshots');
|
||||
|
||||
async function ensureDir(dir) {
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function screenshot(page, name) {
|
||||
ensureDir(SCREENSHOT_DIR);
|
||||
await page.screenshot({ path: join(SCREENSHOT_DIR, `${name}.png`), fullPage: true });
|
||||
console.log(` [Screenshot] ${name}.png`);
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
||||
|
||||
try {
|
||||
console.log('\n--- Step 1-2: Login ---');
|
||||
await page.goto('http://localhost:9771/login', { waitUntil: 'load', timeout: 45000 });
|
||||
await page.locator('input[type="text"], input[placeholder*="ID"]').first().fill('topseal_admin');
|
||||
await page.locator('input[type="password"]').first().fill('qlalfqjsgh11');
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {}),
|
||||
page.locator('button:has-text("로그인")').first().click(),
|
||||
]);
|
||||
await sleep(3000);
|
||||
|
||||
console.log('\n--- Step 4-5: Navigate to screen 4168 ---');
|
||||
await page.goto('http://localhost:9771/screens/4168', { waitUntil: 'load', timeout: 45000 });
|
||||
await sleep(5000);
|
||||
|
||||
console.log('\n--- Step 6: Screenshot after load ---');
|
||||
await screenshot(page, '10-bom-4168-initial');
|
||||
|
||||
const hasBomList = (await page.locator('text="BOM 목록"').count()) > 0;
|
||||
const hasSplitPanel = (await page.locator('text="BOM 상세정보"').count()) > 0 || hasBomList;
|
||||
const rowCount = await page.locator('table tbody tr').count();
|
||||
const hasBomRows = rowCount > 0;
|
||||
|
||||
console.log('\n========== INITIAL STATE (Step 7) ==========');
|
||||
console.log('BOM management screen loaded:', hasBomList || hasSplitPanel ? 'YES' : 'CHECK');
|
||||
console.log('Split panel (BOM list left):', hasSplitPanel ? 'YES' : 'NO');
|
||||
console.log('BOM data rows visible:', hasBomRows ? `YES (${rowCount} rows)` : 'NO');
|
||||
|
||||
if (hasBomRows) {
|
||||
console.log('\n--- Step 8-9: Click first row ---');
|
||||
await page.locator('table tbody tr').first().click();
|
||||
await sleep(5000);
|
||||
|
||||
console.log('\n--- Step 10: Screenshot after row click ---');
|
||||
await screenshot(page, '11-bom-4168-after-click');
|
||||
|
||||
const noDataMsg = (await page.locator('text="등록된 하위 품목이 없습니다"').count()) > 0;
|
||||
const treeArea = page.locator('div:has-text("BOM 구성"), div:has-text("BOM 상세정보")').first();
|
||||
const treeText = (await treeArea.textContent().catch(() => '') || '').substring(0, 600);
|
||||
const hasChildItems = !noDataMsg && (treeText.includes('품번') || treeText.includes('레벨') || treeText.length > 150);
|
||||
|
||||
console.log('\n========== AFTER ROW CLICK (Step 11) ==========');
|
||||
console.log('BOM tree shows child items:', hasChildItems ? 'YES' : noDataMsg ? 'NO (empty message)' : 'CHECK');
|
||||
console.log('Tree preview:', treeText.substring(0, 300) + (treeText.length > 300 ? '...' : ''));
|
||||
} else {
|
||||
console.log('\n--- No BOM rows to click ---');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
try { await page.screenshot({ path: join(SCREENSHOT_DIR, '99-error.png'), fullPage: true }); } catch (e) {}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
[info] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[warning] Image with src "/images/vexplor.png" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio.
|
||||
[log] 첫 번째 접근 가능한 메뉴로 이동: /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[log] 📦 메인 테이블 데이터 자동 로드: company_mng {company_code: COMPANY_7, company_name: 탑씰 테스트, writer: wace, regdate: 2026-02-27T09:28:35.342Z, status: active}
|
||||
[log] 📦 메인 테이블 데이터 자동 로드: company_mng {company_code: COMPANY_7, company_name: 탑씰 테스트, writer: wace, regdate: 2026-02-27T09:28:35.342Z, status: active}
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/138) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
|
||||
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
|
||||
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
|
||||
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
|
||||
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 1030135068124796000, error: 404}
|
||||
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
|
||||
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 666667496384701400, error: 404}
|
||||
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 88591267128165600, error: 404}
|
||||
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 1030135068124796000, error: 404}
|
||||
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 666667496384701400, error: 404}
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[error] Failed to load resource: the server responded with a status of 404 (Not Found)
|
||||
[error] ❌ 대표 이미지 로드 실패: {file: clideo_editor_cb93b93c55584c3780a53ef149e62ee5.gif, objid: 88591267128165600, error: 404}
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/138
|
||||
[info] %cDownload the React DevTools for a better development experience: https://react.dev/link/react-devtools font-weight:bold
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 0 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 0 leftGroupSumConfig: null
|
||||
[log] 📦 [SplitPanelLayout] Context에 분할 패널 등록: {splitPanelId: split-panel-comp_split_panel, panelInfo: Object}
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
|
||||
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
|
||||
[log] 📦 [SplitPanelLayout] Context에서 분할 패널 해제: split-panel-comp_split_panel
|
||||
[log] 📦 [SplitPanelLayout] Context에 분할 패널 등록: {splitPanelId: split-panel-comp_split_panel, panelInfo: Object}
|
||||
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
|
||||
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] ✅ 좌측 컬럼 라벨 로드: {id: ID, created_date: 생성일시, updated_date: 수정일시, writer: 작성자, company_code: 회사코드}
|
||||
[log] ✅ 좌측 컬럼 라벨 로드: {id: ID, created_date: 생성일시, updated_date: 수정일시, writer: 작성자, company_code: 회사코드}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] ✅ 좌측 카테고리 매핑 로드 [status]: {CAT_MM3XFDT6_YULY: Object, CAT_MM3XFA7B_ZFD6: Object}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] ✅ 좌측 카테고리 매핑 로드 [status]: {CAT_MM3XFDT6_YULY: Object, CAT_MM3XFA7B_ZFD6: Object}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] ✅ 분할 패널 좌측 선택: bom {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
|
||||
[log] 🔴 [ButtonPrimary] 저장 시 formData 디버그: {propsFormDataKeys: Array(70), screenContextFormDataKeys: Array(0), effectiveFormDataKeys: Array(70), process_code: undefined, equipment_code: undefined}
|
||||
[log] [BomTree] openEditModal 가로채기 - editData 보정 {oldVersion: 1.0, newVersion: 1.0, oldCurrentVersionId: de575ae5-266c-42f0-be49-bcc65de89ebd, newCurrentVersionId: de575ae5-266c-42f0-be49-bcc65de89ebd}
|
||||
[log] 🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침
|
||||
[log] 🔗 [분할패널] 좌측 additionalJoinColumns: [Object, Object, Object]
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] [EditModal] 모달 열림: {mode: UPDATE (수정), hasEditData: true, editDataId: 64617576-fec9-4caa-8e72-653f9e83ba45, isCreateMode: false}
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] ⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] [EditModal] 화면 데이터 로드 완료, 조건부 레이어 로드 시작: 4154
|
||||
[log] [EditModal] loadConditionalLayersAndZones 호출됨: 4154
|
||||
[log] [EditModal] API 호출 시작: getScreenLayers, getScreenZones
|
||||
[log] [EditModal] API 응답: {layers: 1, zones: 0}
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: 초기 인증 확인: 유효한 토큰 존재 (경로: /screens/4168) | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_START: refreshUserData: API로 인증 상태 확인 시작 | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터 키: [id, created_date, updated_date, writer, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, current_version_id, writer_sabun, writer_user_id, writer_user_password, writer_user_name, writer_user_name_eng, writer_user_name_cn, writer_dept_code, writer_dept_name, writer_position_code, writer_position_name, writer_email, writer_tel, writer_cell_phone, writer_user_type, writer_user_type_name, writer_regdate, writer_status, writer_end_date, writer_fax_no, writer_partner_objid, writer_photo, writer_locale, writer_data_type, writer_license_number, writer_vehicle_number, writer_signup_type, writer_branch_name, writer_department_history, writer_label, item_id_id, item_id_status, item_id_item_name, item_id_size, item_id_material, item_id_inventory_unit, item_id_weight, item_id_unit, item_id_image, item_id_division, item_id_type, item_id_meno, item_id_item_number, item_id_selling_price, item_id_standard_price, item_id_currency_code, item_id_volum, item_id_specific_gravity, item_id_user_type01, item_id_user_type02, item_id_label]
|
||||
[log] 🔗 [분할패널] API 응답 첫 번째 데이터: {id: 64617576-fec9-4caa-8e72-653f9e83ba45, created_date: 2026-02-27 10:22:39.485, updated_date: 2026-02-27 03:15:10.819, writer: wace, company_code: COMPANY_7}
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [그룹합산] leftGroupSumConfig: null
|
||||
[log] 🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[log] 🔗 [SplitPanelLayout] Context 연결 상태: {componentId: comp_split_panel, splitPanelId: split-panel-comp_split_panel, hasRegisterFunc: true, splitPanelsSize: 0}
|
||||
[log] 🔍 [SplitPanel] 왼쪽 패널 displayMode: table isDesignMode: false
|
||||
[log] 🔍 [테이블모드 렌더링] dataSource 개수: 9 leftGroupSumConfig: null
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
[debug] [AuthLog] AUTH_CHECK_SUCCESS: 사용자: topseal_admin, 인증: true | 토큰: 유효(24h0m 남음, user:topseal_admin) | /screens/4168
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
#!/bin/bash
|
||||
# ============================================================
|
||||
# 엘에스티라유텍(주) - 동부지사 (COMPANY_13) 전체 데이터 Export
|
||||
#
|
||||
# 사용법:
|
||||
# 1. SOURCE_* / TARGET_* 변수를 수정
|
||||
# 2. chmod +x migrate_company13_export.sh
|
||||
# 3. ./migrate_company13_export.sh export → SQL 파일 생성
|
||||
# 4. ./migrate_company13_export.sh import → 대상 DB에 적재
|
||||
# ============================================================
|
||||
|
||||
SOURCE_HOST="localhost"
|
||||
SOURCE_PORT="5432"
|
||||
SOURCE_DB="vexplor"
|
||||
SOURCE_USER="postgres"
|
||||
|
||||
TARGET_HOST="대상_호스트"
|
||||
TARGET_PORT="5432"
|
||||
TARGET_DB="대상_DB명"
|
||||
TARGET_USER="postgres"
|
||||
|
||||
OUTPUT_FILE="company13_migration_$(date '+%Y%m%d_%H%M%S').sql"
|
||||
|
||||
# 데이터가 있는 테이블 (의존성 순서)
|
||||
TABLES=(
|
||||
"company_mng"
|
||||
"user_info"
|
||||
"authority_master"
|
||||
"menu_info"
|
||||
"external_db_connections"
|
||||
"external_rest_api_connections"
|
||||
"screen_definitions"
|
||||
"screen_groups"
|
||||
"screen_layouts_v1"
|
||||
"screen_layouts_v2"
|
||||
"screen_layouts_v3"
|
||||
"screen_menu_assignments"
|
||||
"dashboards"
|
||||
"dashboard_elements"
|
||||
"flow_definition"
|
||||
"node_flows"
|
||||
"table_column_category_values"
|
||||
"attach_file_info"
|
||||
"tax_invoice"
|
||||
"auth_tokens"
|
||||
"batch_configs"
|
||||
"batch_execution_logs"
|
||||
"batch_mappings"
|
||||
"digital_twin_layout"
|
||||
"digital_twin_layout_template"
|
||||
"dtg_management"
|
||||
"transport_statistics"
|
||||
"vehicles"
|
||||
"vehicle_location_history"
|
||||
)
|
||||
|
||||
do_export() {
|
||||
echo "=========================================="
|
||||
echo " COMPANY_13 데이터 Export 시작"
|
||||
echo "=========================================="
|
||||
|
||||
cat > "$OUTPUT_FILE" <<'HEADER'
|
||||
-- ============================================================
|
||||
-- 엘에스티라유텍(주) - 동부지사 (COMPANY_13) 전체 데이터 마이그레이션
|
||||
--
|
||||
-- 총 29개 테이블, 약 11,500건 데이터
|
||||
--
|
||||
-- 실행 방법:
|
||||
-- psql -h HOST -U USER -d DATABASE -f 이_파일명.sql
|
||||
-- ============================================================
|
||||
|
||||
SET client_encoding TO 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
|
||||
BEGIN;
|
||||
|
||||
HEADER
|
||||
|
||||
for TABLE in "${TABLES[@]}"; do
|
||||
COUNT=$(psql -h "$SOURCE_HOST" -p "$SOURCE_PORT" -U "$SOURCE_USER" -d "$SOURCE_DB" \
|
||||
-t -A -c "SELECT COUNT(*) FROM $TABLE WHERE company_code = 'COMPANY_13'")
|
||||
COUNT=$(echo "$COUNT" | tr -d '[:space:]')
|
||||
|
||||
if [ "$COUNT" -gt 0 ]; then
|
||||
echo " $TABLE: ${COUNT}건 추출 중..."
|
||||
|
||||
echo "-- ----------------------------------------" >> "$OUTPUT_FILE"
|
||||
echo "-- $TABLE (${COUNT}건)" >> "$OUTPUT_FILE"
|
||||
echo "-- ----------------------------------------" >> "$OUTPUT_FILE"
|
||||
echo "COPY $TABLE FROM stdin;" >> "$OUTPUT_FILE"
|
||||
|
||||
psql -h "$SOURCE_HOST" -p "$SOURCE_PORT" -U "$SOURCE_USER" -d "$SOURCE_DB" \
|
||||
-t -A -c "COPY (SELECT * FROM $TABLE WHERE company_code = 'COMPANY_13') TO STDOUT" >> "$OUTPUT_FILE"
|
||||
|
||||
echo "\\." >> "$OUTPUT_FILE"
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
else
|
||||
echo " $TABLE: 데이터 없음 (건너뜀)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
echo "COMMIT;" >> "$OUTPUT_FILE"
|
||||
echo "" >> "$OUTPUT_FILE"
|
||||
echo "-- 마이그레이션 완료" >> "$OUTPUT_FILE"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Export 완료: $OUTPUT_FILE"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "대상 DB에서 실행:"
|
||||
echo " psql -h $TARGET_HOST -p $TARGET_PORT -U $TARGET_USER -d $TARGET_DB -f $OUTPUT_FILE"
|
||||
}
|
||||
|
||||
do_import() {
|
||||
SQL_FILE=$(ls -t company13_migration_*.sql 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$SQL_FILE" ]; then
|
||||
echo "마이그레이션 SQL 파일을 찾을 수 없습니다. 먼저 export를 실행하세요."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=========================================="
|
||||
echo " COMPANY_13 데이터 Import 시작"
|
||||
echo " 파일: $SQL_FILE"
|
||||
echo " 대상: $TARGET_HOST:$TARGET_PORT/$TARGET_DB"
|
||||
echo "=========================================="
|
||||
|
||||
psql -h "$TARGET_HOST" -p "$TARGET_PORT" -U "$TARGET_USER" -d "$TARGET_DB" -f "$SQL_FILE"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " Import 완료"
|
||||
echo "=========================================="
|
||||
}
|
||||
|
||||
case "${1:-export}" in
|
||||
export)
|
||||
do_export
|
||||
;;
|
||||
import)
|
||||
do_import
|
||||
;;
|
||||
*)
|
||||
echo "사용법: $0 {export|import}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
|
@ -12,7 +12,7 @@ services:
|
|||
NODE_ENV: production
|
||||
PORT: "3001"
|
||||
HOST: 0.0.0.0
|
||||
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/plm
|
||||
DATABASE_URL: postgresql://postgres:vexplor0909!!@211.115.91.141:11134/vexplor
|
||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
||||
JWT_EXPIRES_IN: 24h
|
||||
CORS_ORIGIN: https://v1.vexplor.com,https://api.vexplor.com
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
# BOM 관리 시스템 개발 현황
|
||||
|
||||
## 1. 개요
|
||||
|
||||
BOM(Bill of Materials) 관리 시스템은 제품의 구성 부품을 계층적으로 관리하는 기능입니다.
|
||||
V2 컴포넌트 기반으로 구현되어 있으며, 설정 패널을 통해 모든 기능을 동적으로 구성할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 아키텍처
|
||||
|
||||
### 2.1 전체 구조
|
||||
|
||||
```
|
||||
[프론트엔드] [백엔드] [데이터베이스]
|
||||
v2-bom-tree (트리 뷰) ──── /api/bom ────── bomService.ts ────── bom, bom_detail
|
||||
v2-bom-item-editor ──── /api/table-management ──────────── bom_history, bom_version
|
||||
V2BomTreeConfigPanel (설정 패널)
|
||||
```
|
||||
|
||||
### 2.2 관련 파일 목록
|
||||
|
||||
#### 프론트엔드
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` | BOM 트리/레벨 뷰 메인 컴포넌트 |
|
||||
| `frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx` | 버전 관리 모달 |
|
||||
| `frontend/lib/registry/components/v2-bom-tree/BomHistoryModal.tsx` | 이력 관리 모달 |
|
||||
| `frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx` | BOM 항목 수정 모달 |
|
||||
| `frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx` | 트리 렌더러 |
|
||||
| `frontend/lib/registry/components/v2-bom-tree/index.ts` | 컴포넌트 정의 (v2-bom-tree) |
|
||||
| `frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx` | BOM 트리 설정 패널 |
|
||||
| `frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx` | BOM 항목 편집기 (에디터 모드) |
|
||||
|
||||
#### 백엔드
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `backend-node/src/routes/bomRoutes.ts` | BOM API 라우트 정의 |
|
||||
| `backend-node/src/controllers/bomController.ts` | BOM 컨트롤러 (이력/버전) |
|
||||
| `backend-node/src/services/bomService.ts` | BOM 서비스 (비즈니스 로직) |
|
||||
|
||||
#### 데이터베이스
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `db/migrations/062_create_bom_history_version_tables.sql` | 이력/버전 테이블 DDL |
|
||||
|
||||
---
|
||||
|
||||
## 3. 데이터베이스 스키마
|
||||
|
||||
### 3.1 bom (BOM 헤더)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR (UUID) | PK |
|
||||
| item_id | VARCHAR | 완제품 품목 ID (item_info FK) |
|
||||
| bom_name | VARCHAR | BOM 명칭 |
|
||||
| version | VARCHAR | 현재 사용중인 버전명 |
|
||||
| revision | VARCHAR | 차수 |
|
||||
| base_qty | NUMERIC | 기준수량 |
|
||||
| unit | VARCHAR | 단위 |
|
||||
| remark | TEXT | 비고 |
|
||||
| company_code | VARCHAR | 회사 코드 (멀티테넌시) |
|
||||
|
||||
### 3.2 bom_detail (BOM 상세 - 자식 품목)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR (UUID) | PK |
|
||||
| bom_id | VARCHAR | BOM 헤더 FK |
|
||||
| parent_detail_id | VARCHAR | 부모 detail FK (NULL = 1레벨) |
|
||||
| child_item_id | VARCHAR | 자식 품목 ID (item_info FK) |
|
||||
| quantity | NUMERIC | 구성수량 (소요량) |
|
||||
| unit | VARCHAR | 단위 |
|
||||
| process_type | VARCHAR | 공정구분 (제조/외주 등) |
|
||||
| loss_rate | NUMERIC | 손실율 |
|
||||
| level | INTEGER | 레벨 |
|
||||
| base_qty | NUMERIC | 기준수량 |
|
||||
| revision | VARCHAR | 차수 |
|
||||
| remark | TEXT | 비고 |
|
||||
| company_code | VARCHAR | 회사 코드 |
|
||||
|
||||
### 3.3 bom_history (BOM 이력)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR (UUID) | PK |
|
||||
| bom_id | VARCHAR | BOM 헤더 FK |
|
||||
| revision | VARCHAR | 차수 |
|
||||
| version | VARCHAR | 버전 |
|
||||
| change_type | VARCHAR | 변경구분 (등록/수정/추가/삭제) |
|
||||
| change_description | TEXT | 변경내용 |
|
||||
| changed_by | VARCHAR | 변경자 |
|
||||
| changed_date | TIMESTAMP | 변경일시 |
|
||||
| company_code | VARCHAR | 회사 코드 |
|
||||
|
||||
### 3.4 bom_version (BOM 버전)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR (UUID) | PK |
|
||||
| bom_id | VARCHAR | BOM 헤더 FK |
|
||||
| version_name | VARCHAR | 버전명 (1.0, 2.0 ...) |
|
||||
| revision | INTEGER | 생성 시점의 차수 |
|
||||
| status | VARCHAR | 상태 (developing / active / inactive) |
|
||||
| snapshot_data | JSONB | 스냅샷 (bom 헤더 + bom_detail 전체) |
|
||||
| created_by | VARCHAR | 생성자 |
|
||||
| created_date | TIMESTAMP | 생성일시 |
|
||||
| company_code | VARCHAR | 회사 코드 |
|
||||
|
||||
---
|
||||
|
||||
## 4. API 명세
|
||||
|
||||
### 4.1 이력 API
|
||||
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/bom/:bomId/history` | 이력 목록 조회 |
|
||||
| POST | `/api/bom/:bomId/history` | 이력 등록 |
|
||||
|
||||
**Query Params**: `tableName` (설정 패널에서 지정한 이력 테이블명, 기본값: `bom_history`)
|
||||
|
||||
### 4.2 버전 API
|
||||
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/bom/:bomId/versions` | 버전 목록 조회 |
|
||||
| POST | `/api/bom/:bomId/versions` | 신규 버전 생성 |
|
||||
| POST | `/api/bom/:bomId/versions/:versionId/load` | 버전 불러오기 (데이터 복원) |
|
||||
| POST | `/api/bom/:bomId/versions/:versionId/activate` | 버전 사용 확정 |
|
||||
| DELETE | `/api/bom/:bomId/versions/:versionId` | 버전 삭제 |
|
||||
|
||||
**Body/Query**: `tableName`, `detailTable` (설정 패널에서 지정한 테이블명)
|
||||
|
||||
---
|
||||
|
||||
## 5. 버전 관리 구조
|
||||
|
||||
### 5.1 핵심 원리
|
||||
|
||||
**각 버전은 생성 시점의 BOM 전체 구조(헤더 + 모든 디테일)를 JSONB 스냅샷으로 저장합니다.**
|
||||
|
||||
```
|
||||
버전 1.0 (active)
|
||||
└─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] }
|
||||
|
||||
버전 2.0 (developing)
|
||||
└─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] }
|
||||
|
||||
버전 3.0 (inactive)
|
||||
└─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] }
|
||||
```
|
||||
|
||||
### 5.2 버전 상태 (status)
|
||||
|
||||
| 상태 | 설명 |
|
||||
|------|------|
|
||||
| `developing` | 개발중 - 신규 생성 시 기본 상태 |
|
||||
| `active` | 사용중 - "사용 확정" 후 운영 상태 |
|
||||
| `inactive` | 사용중지 - 이전에 active였다가 다른 버전이 확정된 경우 |
|
||||
|
||||
### 5.3 버전 워크플로우
|
||||
|
||||
```
|
||||
[현재 BOM 데이터]
|
||||
│
|
||||
▼
|
||||
신규 버전 생성 ───► 버전 N.0 (status: developing)
|
||||
│
|
||||
├── 불러오기: 해당 스냅샷의 데이터로 현재 BOM을 복원
|
||||
│ (status 변경 없음, BOM 헤더 version 변경 없음)
|
||||
│
|
||||
├── 사용 확정: status → active,
|
||||
│ 기존 active 버전 → inactive,
|
||||
│ BOM 헤더의 version 필드 갱신
|
||||
│
|
||||
└── 삭제: active 상태가 아닌 경우만 삭제 가능
|
||||
```
|
||||
|
||||
### 5.4 불러오기 vs 사용 확정
|
||||
|
||||
| 동작 | 불러오기 (Load) | 사용 확정 (Activate) |
|
||||
|------|----------------|---------------------|
|
||||
| BOM 데이터 복원 | O (detail 전체 교체) | X |
|
||||
| BOM 헤더 업데이트 | O (base_qty, unit 등) | version 필드만 |
|
||||
| 버전 status 변경 | X | active로 변경 |
|
||||
| 기존 active 비활성화 | X | O (→ inactive) |
|
||||
| BOM 목록 새로고침 | O (refreshTable) | O (refreshTable) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 설정 패널 구성
|
||||
|
||||
`V2BomTreeConfigPanel.tsx`에서 아래 항목을 설정할 수 있습니다:
|
||||
|
||||
### 6.1 기본 탭
|
||||
|
||||
| 설정 항목 | 설명 | 기본값 |
|
||||
|-----------|------|--------|
|
||||
| 디테일 테이블 | BOM 상세 데이터 테이블 | `bom_detail` |
|
||||
| 외래키 | BOM 헤더와의 연결 키 | `bom_id` |
|
||||
| 부모키 | 부모-자식 관계 키 | `parent_detail_id` |
|
||||
| 이력 테이블 | BOM 변경 이력 테이블 | `bom_history` |
|
||||
| 버전 테이블 | BOM 버전 관리 테이블 | `bom_version` |
|
||||
| 이력 기능 표시 | 이력 버튼 노출 여부 | `true` |
|
||||
| 버전 기능 표시 | 버전 버튼 노출 여부 | `true` |
|
||||
|
||||
### 6.2 컬럼 탭
|
||||
|
||||
- 소스 테이블 (bom/item_info 등)에서 표시할 컬럼 선택
|
||||
- 디테일 테이블에서 표시할 컬럼 선택
|
||||
- 컬럼 순서 드래그앤드롭
|
||||
- 컬럼별 라벨, 너비, 정렬 설정
|
||||
|
||||
---
|
||||
|
||||
## 7. 뷰 모드
|
||||
|
||||
### 7.1 트리 뷰 (기본)
|
||||
|
||||
- 계층적 들여쓰기로 부모-자식 관계 표현
|
||||
- 레벨별 시각 구분:
|
||||
- **0레벨 (가상 루트)**: 파란색 배경 + 파란 좌측 바
|
||||
- **1레벨**: 흰색 배경 + 초록 좌측 바
|
||||
- **2레벨**: 연회색 배경 + 주황 좌측 바
|
||||
- **3레벨 이상**: 진회색 배경 + 보라 좌측 바
|
||||
- 펼침/접힘 (정전개/역전개)
|
||||
|
||||
### 7.2 레벨 뷰
|
||||
|
||||
- 평면 테이블 형태로 표시
|
||||
- "레벨0", "레벨1", "레벨2" ... 컬럼에 체크마크로 계층 표시
|
||||
- 같은 레벨별 배경색 구분 적용
|
||||
|
||||
---
|
||||
|
||||
## 8. 주요 기능 목록
|
||||
|
||||
| 기능 | 상태 | 설명 |
|
||||
|------|------|------|
|
||||
| BOM 트리 표시 | 완료 | 계층적 트리 뷰 + 레벨 뷰 |
|
||||
| BOM 항목 편집 | 완료 | 더블클릭으로 수정 모달 (0레벨: bom, 하위: bom_detail) |
|
||||
| 이력 관리 | 완료 | 변경 이력 조회/등록 모달 |
|
||||
| 버전 관리 | 완료 | 버전 생성/불러오기/사용 확정/삭제 |
|
||||
| 설정 패널 | 완료 | 테이블/컬럼/기능 동적 설정 |
|
||||
| 디자인 모드 프리뷰 | 완료 | 실제 화면과 일치하는 디자인 모드 표시 |
|
||||
| 컬럼 크기 조절 | 완료 | 헤더 드래그로 컬럼 너비 변경 |
|
||||
| 텍스트 말줄임 | 완료 | 긴 텍스트 `...` 처리 |
|
||||
| 레벨별 시각 구분 | 완료 | 배경색 + 좌측 컬러 바 |
|
||||
| 정전개/역전개 | 완료 | 전체 펼침/접기 토글 |
|
||||
| 좌우 스크롤 | 완료 | 컬럼 크기가 커질 때 수평 스크롤 |
|
||||
| BOM 목록 자동 새로고침 | 완료 | 버전 불러오기/확정 후 좌측 패널 자동 리프레시 |
|
||||
| BOM 하위 품목 저장 | 완료 | BomItemEditorComponent에서 직접 INSERT/UPDATE/DELETE |
|
||||
| 차수 (Revision) 자동 증가 | 미구현 | BOM 변경 시 헤더 revision 자동 +1 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 보안 고려사항
|
||||
|
||||
- **SQL 인젝션 방지**: `safeTableName()` 함수로 테이블명 검증 (`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||
- **멀티테넌시**: 모든 API에서 `company_code` 필터링 적용
|
||||
- **최고 관리자**: `company_code = "*"` 시 전체 데이터 조회 가능
|
||||
- **인증**: `authenticateToken` 미들웨어로 모든 라우트 보호
|
||||
|
||||
---
|
||||
|
||||
## 10. 향후 개선 사항
|
||||
|
||||
- [ ] 차수(Revision) 자동 증가 구현 (BOM 헤더 레벨)
|
||||
- [ ] 버전 비교 기능 (두 버전 간 diff)
|
||||
- [ ] BOM 복사 기능
|
||||
- [ ] 이력 자동 등록 (수정/저장 시 자동으로 이력 생성)
|
||||
- [ ] Excel 내보내기/가져오기
|
||||
- [ ] BOM 유효성 검증 (순환참조 방지 등)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,955 @@
|
|||
# WACE ERP 시스템 전체 워크플로우 문서
|
||||
|
||||
> 작성일: 2026-02-06
|
||||
> 분석 방법: Multi-Agent System (Backend + Frontend + DB 전문가 병렬 분석)
|
||||
|
||||
---
|
||||
|
||||
## 목차
|
||||
|
||||
1. [시스템 개요](#1-시스템-개요)
|
||||
2. [기술 스택](#2-기술-스택)
|
||||
3. [전체 아키텍처](#3-전체-아키텍처)
|
||||
4. [백엔드 아키텍처](#4-백엔드-아키텍처)
|
||||
5. [프론트엔드 아키텍처](#5-프론트엔드-아키텍처)
|
||||
6. [데이터베이스 구조](#6-데이터베이스-구조)
|
||||
7. [인증/인가 워크플로우](#7-인증인가-워크플로우)
|
||||
8. [화면 디자이너 워크플로우](#8-화면-디자이너-워크플로우)
|
||||
9. [사용자 업무 워크플로우](#9-사용자-업무-워크플로우)
|
||||
10. [플로우 엔진 워크플로우](#10-플로우-엔진-워크플로우)
|
||||
11. [데이터플로우 시스템](#11-데이터플로우-시스템)
|
||||
12. [대시보드 시스템](#12-대시보드-시스템)
|
||||
13. [배치/스케줄 시스템](#13-배치스케줄-시스템)
|
||||
14. [멀티테넌시 아키텍처](#14-멀티테넌시-아키텍처)
|
||||
15. [외부 연동](#15-외부-연동)
|
||||
16. [배포 환경](#16-배포-환경)
|
||||
|
||||
---
|
||||
|
||||
## 1. 시스템 개요
|
||||
|
||||
WACE는 **로우코드(Low-Code) ERP 플랫폼**이다. 관리자가 코드 없이 드래그앤드롭으로 업무 화면을 설계하면, 사용자는 해당 화면으로 바로 업무를 처리할 수 있는 구조다.
|
||||
|
||||
### 핵심 컨셉
|
||||
|
||||
```
|
||||
관리자 → 화면 디자이너로 화면 설계 → 메뉴에 연결
|
||||
↓
|
||||
사용자 → 메뉴 클릭 → 화면 자동 렌더링 → 업무 수행
|
||||
```
|
||||
|
||||
### 주요 특징
|
||||
|
||||
- **드래그앤드롭 화면 디자이너**: 코드 없이 UI 구성
|
||||
- **동적 컴포넌트 시스템**: V2 통합 컴포넌트 10종으로 모든 UI 표현
|
||||
- **플로우 엔진**: 워크플로우(승인, 이동 등) 자동화
|
||||
- **데이터플로우**: 비즈니스 로직을 비주얼 다이어그램으로 설계
|
||||
- **멀티테넌시**: 회사별 완벽한 데이터 격리
|
||||
- **다국어 지원**: KR/EN/CN 다국어 라벨 관리
|
||||
|
||||
---
|
||||
|
||||
## 2. 기술 스택
|
||||
|
||||
| 영역 | 기술 | 비고 |
|
||||
|------|------|------|
|
||||
| **Frontend** | Next.js 15 (App Router) | React 19, TypeScript |
|
||||
| **UI 라이브러리** | shadcn/ui + Radix UI | Tailwind CSS 4 |
|
||||
| **상태 관리** | React Context + Zustand | React Query (서버 상태) |
|
||||
| **Backend** | Node.js + Express | TypeScript |
|
||||
| **Database** | PostgreSQL | Raw Query (ORM 미사용) |
|
||||
| **인증** | JWT | 자동 갱신, 세션 관리 |
|
||||
| **빌드/배포** | Docker | dev/prod 분리 |
|
||||
| **포트** | FE: 9771(dev)/5555(prod) | BE: 8080 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 전체 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 사용자 브라우저 │
|
||||
│ Next.js App (React 19 + shadcn/ui + Tailwind CSS) │
|
||||
│ ├── 인증: JWT + Cookie + localStorage │
|
||||
│ ├── 상태: Context + Zustand + React Query │
|
||||
│ └── API: Axios Client (lib/api/) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ HTTP/JSON (JWT Bearer Token)
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Express Backend (Node.js) │
|
||||
│ ├── Middleware: Helmet → CORS → RateLimit → Auth → Permission │
|
||||
│ ├── Routes: 60+ 모듈 │
|
||||
│ ├── Controllers: 69개 │
|
||||
│ ├── Services: 87개 │
|
||||
│ └── Database: pg Pool (Raw Query) │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│ TCP/SQL
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ PostgreSQL Database │
|
||||
│ ├── 시스템 테이블: 사용자, 회사, 메뉴, 권한, 화면 │
|
||||
│ ├── 메타데이터: 테이블/컬럼 정의, 코드, 카테고리 │
|
||||
│ ├── 비즈니스: 동적 생성 테이블 (화면별) │
|
||||
│ └── 멀티테넌시: 모든 테이블에 company_code │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 백엔드 아키텍처
|
||||
|
||||
### 4.1 디렉토리 구조
|
||||
|
||||
```
|
||||
backend-node/src/
|
||||
├── app.ts # Express 앱 진입점
|
||||
├── config/ # 환경설정, Multer
|
||||
├── controllers/ # 69개 컨트롤러
|
||||
├── services/ # 87개 서비스
|
||||
├── routes/ # 60+ 라우트 모듈
|
||||
├── middleware/ # 인증, 권한, 에러 처리
|
||||
│ ├── authMiddleware.ts # JWT 인증
|
||||
│ ├── permissionMiddleware.ts # 3단계 권한 체크
|
||||
│ ├── superAdminMiddleware.ts # 슈퍼관리자 전용
|
||||
│ └── errorHandler.ts # 전역 에러 처리
|
||||
├── database/ # DB 연결, 커넥터 팩토리
|
||||
│ ├── db.ts # PostgreSQL Pool
|
||||
│ ├── DatabaseConnectorFactory.ts
|
||||
│ ├── PostgreSQLConnector.ts
|
||||
│ ├── MySQLConnector.ts
|
||||
│ └── MariaDBConnector.ts
|
||||
├── types/ # TypeScript 타입 (26개)
|
||||
└── utils/ # 유틸리티 (16개)
|
||||
```
|
||||
|
||||
### 4.2 미들웨어 스택 (실행 순서)
|
||||
|
||||
```
|
||||
요청 → Helmet (보안 헤더)
|
||||
→ Compression (응답 압축)
|
||||
→ Body Parser (JSON/URLEncoded, 10MB)
|
||||
→ CORS (교차 출처 허용)
|
||||
→ Rate Limiter (10,000 req/min)
|
||||
→ Token Refresh (자동 갱신)
|
||||
→ Route Handlers (비즈니스 로직)
|
||||
→ Error Handler (전역 에러 처리)
|
||||
```
|
||||
|
||||
### 4.3 API 라우트 도메인별 분류
|
||||
|
||||
#### 인증/사용자 관리
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/auth` | 로그인, 로그아웃, 토큰 갱신, 회사 전환 |
|
||||
| `/api/admin/users` | 사용자 CRUD, 비밀번호 초기화, 상태 변경 |
|
||||
| `/api/company-management` | 회사 CRUD |
|
||||
| `/api/departments` | 부서 관리 |
|
||||
| `/api/roles` | 권한 그룹 관리 |
|
||||
|
||||
#### 화면/메뉴 관리
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/screen-management` | 화면 정의 CRUD, 그룹, 파일, 임베딩 |
|
||||
| `/api/admin/menus` | 메뉴 트리 CRUD, 화면 할당 |
|
||||
| `/api/table-management` | 테이블 CRUD, 엔티티 조인, 카테고리 |
|
||||
| `/api/common-codes` | 공통 코드/카테고리 관리 |
|
||||
| `/api/multilang` | 다국어 키/번역 관리 |
|
||||
|
||||
#### 데이터 관리
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/data` | 동적 테이블 CRUD, 조인 쿼리 |
|
||||
| `/api/data/:tableName` | 특정 테이블 데이터 조회 |
|
||||
| `/api/data/join` | 조인 쿼리 실행 |
|
||||
| `/api/dynamic-form` | 동적 폼 데이터 저장 |
|
||||
| `/api/entity-search` | 엔티티 검색 |
|
||||
| `/api/entity-reference` | 엔티티 참조 |
|
||||
| `/api/numbering-rules` | 채번 규칙 관리 |
|
||||
| `/api/cascading-*` | 연쇄 드롭다운 관계 |
|
||||
|
||||
#### 자동화
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/flow` | 플로우 정의/단계/연결/실행 |
|
||||
| `/api/dataflow` | 데이터플로우 다이어그램/실행 |
|
||||
| `/api/batch-configs` | 배치 작업 설정 |
|
||||
| `/api/batch-management` | 배치 작업 관리 |
|
||||
| `/api/batch-execution-logs` | 배치 실행 로그 |
|
||||
|
||||
#### 대시보드/리포트
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/dashboards` | 대시보드 CRUD, 쿼리 실행 |
|
||||
| `/api/reports` | 리포트 생성 |
|
||||
|
||||
#### 외부 연동
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/external-db-connections` | 외부 DB 연결 (PostgreSQL, MySQL, MariaDB, MSSQL, Oracle) |
|
||||
| `/api/external-rest-api-connections` | 외부 REST API 연결 |
|
||||
| `/api/mail` | 메일 발송/수신/템플릿 |
|
||||
| `/api/tax-invoice` | 세금계산서 |
|
||||
|
||||
#### 특수 도메인
|
||||
| 라우트 | 역할 |
|
||||
|--------|------|
|
||||
| `/api/delivery` | 배송/화물 관리 |
|
||||
| `/api/risk-alerts` | 위험 알림 |
|
||||
| `/api/todos` | 할일 관리 |
|
||||
| `/api/bookings` | 예약 관리 |
|
||||
| `/api/digital-twin` | 디지털 트윈 (야드 모니터링) |
|
||||
| `/api/schedule` | 스케줄 자동 생성 |
|
||||
| `/api/vehicle` | 차량 운행 |
|
||||
| `/api/driver` | 운전자 관리 |
|
||||
| `/api/files` | 파일 업로드/다운로드 |
|
||||
| `/api/ddl` | DDL 실행 (슈퍼관리자 전용) |
|
||||
|
||||
### 4.4 서비스 레이어 패턴
|
||||
|
||||
```typescript
|
||||
// 표준 서비스 패턴
|
||||
class ExampleService {
|
||||
// 목록 조회 (멀티테넌시 적용)
|
||||
async findAll(companyCode: string, filters?: any) {
|
||||
if (companyCode === "*") {
|
||||
// 슈퍼관리자: 전체 데이터
|
||||
return await db.query("SELECT * FROM table ORDER BY company_code");
|
||||
} else {
|
||||
// 일반 사용자: 자기 회사 데이터만
|
||||
return await db.query(
|
||||
"SELECT * FROM table WHERE company_code = $1",
|
||||
[companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 에러 처리 전략
|
||||
|
||||
```typescript
|
||||
// 전역 에러 핸들러 (errorHandler.ts)
|
||||
- PostgreSQL 에러: 중복키(23505), 외래키(23503), 널 제약(23502) 등
|
||||
- JWT 에러: 만료, 유효하지 않은 토큰
|
||||
- 일반 에러: 500 Internal Server Error
|
||||
- 개발 환경: 상세 에러 스택 포함
|
||||
- 운영 환경: 일반적인 에러 메시지만 반환
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 아키텍처
|
||||
|
||||
### 5.1 디렉토리 구조
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── app/ # Next.js App Router
|
||||
│ ├── (auth)/ # 인증 (로그인)
|
||||
│ ├── (main)/ # 메인 앱 (인증 필요)
|
||||
│ ├── (pop)/ # 모바일/팝업
|
||||
│ └── (admin)/ # 특수 관리자
|
||||
├── components/ # React 컴포넌트
|
||||
│ ├── screen/ # 화면 디자이너 & 뷰어
|
||||
│ ├── admin/ # 관리 기능
|
||||
│ ├── dashboard/ # 대시보드 위젯
|
||||
│ ├── dataflow/ # 데이터플로우 디자이너
|
||||
│ ├── v2/ # V2 통합 컴포넌트
|
||||
│ ├── ui/ # shadcn/ui 기본 컴포넌트
|
||||
│ └── report/ # 리포트 디자이너
|
||||
├── lib/
|
||||
│ ├── api/ # API 클라이언트 (57개 모듈)
|
||||
│ ├── registry/ # 컴포넌트 레지스트리 (482개)
|
||||
│ ├── utils/ # 유틸리티
|
||||
│ └── v2-core/ # V2 코어 로직
|
||||
├── contexts/ # React Context (인증, 메뉴, 화면 등)
|
||||
├── hooks/ # Custom Hooks
|
||||
├── stores/ # Zustand 상태관리
|
||||
└── middleware.ts # Next.js 인증 미들웨어
|
||||
```
|
||||
|
||||
### 5.2 페이지 라우팅 구조
|
||||
|
||||
```
|
||||
/login → 로그인
|
||||
/main → 메인 대시보드
|
||||
/screens/[screenId] → 동적 화면 뷰어 (사용자)
|
||||
|
||||
/admin/screenMng/screenMngList → 화면 관리
|
||||
/admin/screenMng/dashboardList → 대시보드 관리
|
||||
/admin/screenMng/reportList → 리포트 관리
|
||||
/admin/systemMng/tableMngList → 테이블 관리
|
||||
/admin/systemMng/commonCodeList → 공통코드 관리
|
||||
/admin/systemMng/dataflow → 데이터플로우 관리
|
||||
/admin/systemMng/i18nList → 다국어 관리
|
||||
/admin/userMng/userMngList → 사용자 관리
|
||||
/admin/userMng/companyList → 회사 관리
|
||||
/admin/userMng/rolesList → 권한 관리
|
||||
/admin/automaticMng/flowMgmtList → 플로우 관리
|
||||
/admin/automaticMng/batchmngList → 배치 관리
|
||||
/admin/automaticMng/mail/* → 메일 시스템
|
||||
/admin/menu → 메뉴 관리
|
||||
|
||||
/dashboard/[dashboardId] → 대시보드 뷰어
|
||||
/pop/work → 모바일 작업 화면
|
||||
```
|
||||
|
||||
### 5.3 V2 통합 컴포넌트 시스템
|
||||
|
||||
**"하나의 컴포넌트, 여러 모드"** 철학으로 설계된 10개 통합 컴포넌트:
|
||||
|
||||
| 컴포넌트 | 모드 | 역할 |
|
||||
|----------|------|------|
|
||||
| **V2Input** | text, number, password, slider, color | 텍스트/숫자 입력 |
|
||||
| **V2Select** | dropdown, radio, checkbox, tag, toggle | 선택 입력 |
|
||||
| **V2Date** | date, datetime, time, range | 날짜/시간 입력 |
|
||||
| **V2List** | table, card, kanban, list | 데이터 목록 표시 |
|
||||
| **V2Layout** | grid, split-panel, flex | 레이아웃 구성 |
|
||||
| **V2Group** | tab, accordion, section, modal | 그룹 컨테이너 |
|
||||
| **V2Media** | image, video, audio, file | 미디어 표시 |
|
||||
| **V2Biz** | flow, rack, numbering-rule | 비즈니스 로직 |
|
||||
| **V2Hierarchy** | tree, org-chart, BOM, cascading | 계층 구조 |
|
||||
| **V2Repeater** | inline-table, modal, button | 반복 데이터 |
|
||||
|
||||
### 5.4 API 클라이언트 규칙
|
||||
|
||||
```typescript
|
||||
// 절대 금지: fetch 직접 사용
|
||||
const res = await fetch('/api/flow/definitions'); // ❌
|
||||
|
||||
// 반드시 사용: lib/api/ 클라이언트
|
||||
import { getFlowDefinitions } from '@/lib/api/flow';
|
||||
const res = await getFlowDefinitions(); // ✅
|
||||
```
|
||||
|
||||
환경별 URL 자동 처리:
|
||||
| 환경 | 프론트엔드 | 백엔드 API |
|
||||
|------|-----------|-----------|
|
||||
| 로컬 개발 | localhost:9771 | localhost:8080/api |
|
||||
| 운영 | v1.vexplor.com | api.vexplor.com/api |
|
||||
|
||||
### 5.5 상태 관리 체계
|
||||
|
||||
```
|
||||
전역 상태
|
||||
├── AuthContext → 인증/세션/토큰
|
||||
├── MenuContext → 메뉴 트리/권한
|
||||
├── ScreenPreviewContext → 프리뷰 모드
|
||||
├── ScreenMultiLangContext → 다국어 라벨
|
||||
├── TableOptionsContext → 테이블 옵션
|
||||
└── ActiveTabContext → 활성 탭
|
||||
|
||||
로컬 상태
|
||||
├── Zustand Stores → 화면 디자이너 상태, 사용자 상태
|
||||
└── React Query → 서버 데이터 캐시 (5분 stale, 30분 GC)
|
||||
```
|
||||
|
||||
### 5.6 레지스트리 시스템
|
||||
|
||||
```typescript
|
||||
// 컴포넌트 등록 (482개 등록됨)
|
||||
ComponentRegistry.registerComponent({
|
||||
id: "v2-input",
|
||||
name: "통합 입력",
|
||||
category: ComponentCategory.V2,
|
||||
component: V2Input,
|
||||
configPanel: V2InputConfigPanel,
|
||||
defaultConfig: { inputType: "text" }
|
||||
});
|
||||
|
||||
// 동적 렌더링
|
||||
<DynamicComponentRenderer
|
||||
component={componentData}
|
||||
formData={formData}
|
||||
onFormDataChange={handleChange}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 데이터베이스 구조
|
||||
|
||||
### 6.1 테이블 도메인별 분류
|
||||
|
||||
#### 사용자/인증/회사
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `company_mng` | 회사 마스터 |
|
||||
| `user_info` | 사용자 정보 |
|
||||
| `user_info_history` | 사용자 변경 이력 |
|
||||
| `user_dept` | 사용자-부서 매핑 |
|
||||
| `dept_info` | 부서 정보 |
|
||||
| `authority_master` | 권한 그룹 마스터 |
|
||||
| `authority_sub_user` | 사용자-권한 매핑 |
|
||||
| `login_access_log` | 로그인 로그 |
|
||||
|
||||
#### 메뉴/화면
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `menu_info` | 메뉴 트리 구조 |
|
||||
| `screen_definitions` | 화면 정의 (screenId, 테이블명 등) |
|
||||
| `screen_layouts_v2` | V2 레이아웃 (JSON) |
|
||||
| `screen_layouts` | V1 레이아웃 (레거시) |
|
||||
| `screen_groups` | 화면 그룹 (계층구조) |
|
||||
| `screen_group_screens` | 화면-그룹 매핑 |
|
||||
| `screen_menu_assignments` | 화면-메뉴 할당 |
|
||||
| `screen_field_joins` | 화면 필드 조인 설정 |
|
||||
| `screen_data_flows` | 화면 데이터 플로우 |
|
||||
| `screen_table_relations` | 화면-테이블 관계 |
|
||||
|
||||
#### 메타데이터
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `table_type_columns` | 테이블 타입별 컬럼 정의 (회사별) |
|
||||
| `table_column_category_values` | 컬럼 카테고리 값 |
|
||||
| `code_category` | 공통 코드 카테고리 |
|
||||
| `code_info` | 공통 코드 값 |
|
||||
| `category_column_mapping` | 카테고리-컬럼 매핑 |
|
||||
| `cascading_relation` | 연쇄 드롭다운 관계 |
|
||||
| `numbering_rules` | 채번 규칙 |
|
||||
| `numbering_rule_parts` | 채번 규칙 파트 |
|
||||
|
||||
#### 플로우/자동화
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `flow_definition` | 플로우 정의 |
|
||||
| `flow_step` | 플로우 단계 |
|
||||
| `flow_step_connection` | 플로우 단계 연결 |
|
||||
| `node_flows` | 노드 플로우 (버튼 액션) |
|
||||
| `dataflow_diagrams` | 데이터플로우 다이어그램 |
|
||||
| `batch_definitions` | 배치 작업 정의 |
|
||||
| `batch_schedules` | 배치 스케줄 |
|
||||
| `batch_execution_logs` | 배치 실행 로그 |
|
||||
|
||||
#### 외부 연동
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `external_db_connections` | 외부 DB 연결 정보 |
|
||||
| `external_rest_api_connections` | 외부 REST API 연결 |
|
||||
|
||||
#### 다국어
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `multi_lang_key_master` | 다국어 키 마스터 |
|
||||
|
||||
#### 기타
|
||||
| 테이블 | 역할 |
|
||||
|--------|------|
|
||||
| `work_history` | 작업 이력 |
|
||||
| `todo_items` | 할일 목록 |
|
||||
| `file_uploads` | 파일 업로드 |
|
||||
| `ddl_audit_log` | DDL 감사 로그 |
|
||||
|
||||
### 6.2 동적 테이블 생성 패턴
|
||||
|
||||
관리자가 화면 생성 시 비즈니스 테이블이 동적으로 생성된다:
|
||||
|
||||
```sql
|
||||
CREATE TABLE "dynamic_table_name" (
|
||||
"id" VARCHAR(500) PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||
"created_date" TIMESTAMP DEFAULT now(),
|
||||
"updated_date" TIMESTAMP DEFAULT now(),
|
||||
"writer" VARCHAR(500),
|
||||
"company_code" VARCHAR(500), -- 멀티테넌시 필수!
|
||||
-- 사용자 정의 컬럼들 (모두 VARCHAR(500))
|
||||
"product_name" VARCHAR(500),
|
||||
"price" VARCHAR(500),
|
||||
...
|
||||
);
|
||||
CREATE INDEX idx_dynamic_company ON "dynamic_table_name"(company_code);
|
||||
```
|
||||
|
||||
### 6.3 테이블 관계도
|
||||
|
||||
```
|
||||
company_mng (company_code PK)
|
||||
│
|
||||
├── user_info (company_code FK)
|
||||
│ ├── authority_sub_user (user_id FK)
|
||||
│ └── user_dept (user_id FK)
|
||||
│
|
||||
├── menu_info (company_code)
|
||||
│ └── screen_menu_assignments (menu_objid FK)
|
||||
│
|
||||
├── screen_definitions (company_code)
|
||||
│ ├── screen_layouts_v2 (screen_id FK)
|
||||
│ ├── screen_groups → screen_group_screens (screen_id FK)
|
||||
│ └── screen_field_joins (screen_id FK)
|
||||
│
|
||||
├── authority_master (company_code)
|
||||
│ └── authority_sub_user (master_objid FK)
|
||||
│
|
||||
├── flow_definition (company_code)
|
||||
│ ├── flow_step (flow_id FK)
|
||||
│ └── flow_step_connection (flow_id FK)
|
||||
│
|
||||
└── [동적 비즈니스 테이블들] (company_code)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 인증/인가 워크플로우
|
||||
|
||||
### 7.1 로그인 프로세스
|
||||
|
||||
```
|
||||
┌─── 사용자 ───┐ ┌─── 프론트엔드 ───┐ ┌─── 백엔드 ───┐ ┌─── DB ───┐
|
||||
│ │ │ │ │ │ │ │
|
||||
│ ID/PW 입력 │────→│ POST /auth/login │────→│ 비밀번호 검증 │────→│ user_info│
|
||||
│ │ │ │ │ │ │ 조회 │
|
||||
│ │ │ │ │ JWT 토큰 생성 │ │ │
|
||||
│ │ │ │←────│ 토큰 반환 │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ localStorage 저장│ │ │ │ │
|
||||
│ │ │ Cookie 저장 │ │ │ │ │
|
||||
│ │ │ /main 리다이렉트 │ │ │ │ │
|
||||
└──────────────┘ └──────────────────┘ └──────────────┘ └──────────┘
|
||||
```
|
||||
|
||||
### 7.2 JWT 토큰 관리
|
||||
|
||||
```
|
||||
토큰 저장: localStorage (주 저장소) + Cookie (SSR 미들웨어용)
|
||||
|
||||
자동 갱신:
|
||||
├── 10분마다 만료 시간 체크
|
||||
├── 만료 30분 전: 백그라운드 자동 갱신
|
||||
├── 401 응답 시: 즉시 갱신 시도
|
||||
└── 갱신 실패 시: /login 리다이렉트
|
||||
|
||||
세션 관리:
|
||||
├── 데스크톱: 30분 비활성 → 세션 만료 (5분 전 경고)
|
||||
└── 모바일: 24시간 비활성 → 세션 만료 (1시간 전 경고)
|
||||
```
|
||||
|
||||
### 7.3 권한 체계 (3단계)
|
||||
|
||||
```
|
||||
SUPER_ADMIN (company_code = "*")
|
||||
├── 모든 회사 데이터 접근 가능
|
||||
├── DDL 실행 가능
|
||||
├── 시스템 설정 변경
|
||||
└── 다른 회사로 전환 (switch-company)
|
||||
|
||||
COMPANY_ADMIN (userType = "COMPANY_ADMIN")
|
||||
├── 자기 회사 데이터만 접근
|
||||
├── 사용자 관리 가능
|
||||
└── 메뉴/화면 관리 가능
|
||||
|
||||
USER (일반 사용자)
|
||||
├── 자기 회사 데이터만 접근
|
||||
├── 권한 그룹에 따른 메뉴 접근
|
||||
└── 할당된 화면만 사용 가능
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 화면 디자이너 워크플로우
|
||||
|
||||
### 8.1 관리자: 화면 설계
|
||||
|
||||
```
|
||||
Step 1: 화면 생성
|
||||
└→ /admin/screenMng/screenMngList
|
||||
└→ "새 화면" 클릭 → 화면명, 설명, 메인 테이블 입력
|
||||
|
||||
Step 2: 화면 디자이너 진입 (ScreenDesigner.tsx)
|
||||
├── 좌측 패널: 컴포넌트 팔레트 (V2 컴포넌트 10종)
|
||||
├── 중앙 캔버스: 드래그앤드롭 영역
|
||||
└── 우측 패널: 선택된 컴포넌트 속성 설정
|
||||
|
||||
Step 3: 컴포넌트 배치
|
||||
└→ V2Input 드래그 → 캔버스 배치 → 속성 설정:
|
||||
├── 위치: x, y 좌표
|
||||
├── 크기: width, height
|
||||
├── 데이터 바인딩: columnName = "product_name"
|
||||
├── 라벨: "제품명"
|
||||
├── 조건부 표시: 특정 조건에서만 보이기
|
||||
└── 플로우 연결: 버튼 클릭 시 실행할 플로우
|
||||
|
||||
Step 4: 레이아웃 저장
|
||||
└→ screen_layouts_v2 테이블에 JSON 형태로 저장
|
||||
└→ Zod 스키마 검증 → V2 형식 우선, V1 호환 저장
|
||||
|
||||
Step 5: 메뉴에 화면 할당
|
||||
└→ /admin/menu → 메뉴 트리에서 "제품 관리" 선택
|
||||
└→ 화면 연결 (screen_menu_assignments)
|
||||
```
|
||||
|
||||
### 8.2 화면 레이아웃 저장 구조 (V2)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v2",
|
||||
"components": [
|
||||
{
|
||||
"id": "comp-1",
|
||||
"componentType": "v2-input",
|
||||
"position": { "x": 100, "y": 50 },
|
||||
"size": { "width": 200, "height": 40 },
|
||||
"config": {
|
||||
"inputType": "text",
|
||||
"columnName": "product_name",
|
||||
"label": "제품명",
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "comp-2",
|
||||
"componentType": "v2-list",
|
||||
"position": { "x": 100, "y": 150 },
|
||||
"size": { "width": 600, "height": 400 },
|
||||
"config": {
|
||||
"listType": "table",
|
||||
"tableName": "products",
|
||||
"columns": ["product_name", "price", "quantity"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 사용자 업무 워크플로우
|
||||
|
||||
### 9.1 전체 흐름
|
||||
|
||||
```
|
||||
사용자 로그인
|
||||
↓
|
||||
메인 대시보드 (/main)
|
||||
↓
|
||||
좌측 메뉴에서 "제품 관리" 클릭
|
||||
↓
|
||||
/screens/[screenId] 라우팅
|
||||
↓
|
||||
InteractiveScreenViewer 렌더링
|
||||
├── screen_definitions에서 화면 정보 로드
|
||||
├── screen_layouts_v2에서 레이아웃 JSON 로드
|
||||
├── V2 → Legacy 변환 (호환성)
|
||||
└── 메인 테이블 데이터 자동 로드
|
||||
↓
|
||||
컴포넌트별 렌더링
|
||||
├── V2Input → formData 바인딩
|
||||
├── V2List → 테이블 데이터 표시
|
||||
├── V2Select → 드롭다운/라디오 선택
|
||||
└── Button → 플로우/액션 연결
|
||||
↓
|
||||
사용자 인터랙션
|
||||
├── 폼 입력 → formData 업데이트
|
||||
├── 테이블 행 선택 → selectedRowsData 업데이트
|
||||
└── 버튼 클릭 → 플로우 실행
|
||||
↓
|
||||
플로우 실행 (nodeFlowButtonExecutor)
|
||||
├── Step 1: 데이터 검증
|
||||
├── Step 2: API 호출 (INSERT/UPDATE/DELETE)
|
||||
├── Step 3: 성공/실패 처리
|
||||
└── Step 4: 테이블 자동 새로고침
|
||||
```
|
||||
|
||||
### 9.2 조건부 표시 워크플로우
|
||||
|
||||
```
|
||||
관리자 설정:
|
||||
"특별 할인 입력" 컴포넌트
|
||||
└→ 조건: product_type === "PREMIUM" 일 때만 표시
|
||||
|
||||
사용자 사용:
|
||||
1. 화면 진입 → evaluateConditional() 실행
|
||||
2. product_type ≠ "PREMIUM" → "특별 할인 입력" 숨김
|
||||
3. 사용자가 product_type을 "PREMIUM"으로 변경
|
||||
4. formData 업데이트 → evaluateConditional() 재평가
|
||||
5. product_type === "PREMIUM" → "특별 할인 입력" 표시!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 플로우 엔진 워크플로우
|
||||
|
||||
### 10.1 플로우 정의 (관리자)
|
||||
|
||||
```
|
||||
/admin/automaticMng/flowMgmtList
|
||||
↓
|
||||
플로우 생성:
|
||||
├── 이름: "제품 승인 플로우"
|
||||
├── 테이블: "products"
|
||||
└── 단계 정의:
|
||||
Step 1: "신청" (requester)
|
||||
Step 2: "부서장 승인" (manager)
|
||||
Step 3: "최종 승인" (director)
|
||||
연결: Step 1 → Step 2 → Step 3
|
||||
```
|
||||
|
||||
### 10.2 플로우 실행 (사용자)
|
||||
|
||||
```
|
||||
1. 사용자: 제품 신청
|
||||
└→ "저장" 버튼 클릭
|
||||
└→ flowApi.startFlow() → 상태: "부서장 승인 대기"
|
||||
|
||||
2. 부서장: 승인 화면
|
||||
└→ V2Biz (flow) 컴포넌트 → 현재 단계 표시
|
||||
└→ [승인] 클릭 → flowApi.approveStep()
|
||||
└→ 상태: "최종 승인 대기"
|
||||
|
||||
3. 이사: 최종 승인
|
||||
└→ [승인] 클릭 → flowApi.approveStep()
|
||||
└→ 상태: "완료"
|
||||
└→ products.approval_status = "APPROVED"
|
||||
```
|
||||
|
||||
### 10.3 데이터 이동 (moveData)
|
||||
|
||||
```
|
||||
플로우의 핵심 동작: 데이터를 한 스텝에서 다음 스텝으로 이동
|
||||
|
||||
Step 1 (접수) → Step 2 (검토) → Step 3 (완료)
|
||||
├── 단건 이동: moveData(flowId, dataId, fromStep, toStep)
|
||||
└── 배치 이동: moveBatchData(flowId, dataIds[], fromStep, toStep)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 데이터플로우 시스템
|
||||
|
||||
### 11.1 개요
|
||||
|
||||
데이터플로우는 비즈니스 로직을 **비주얼 다이어그램**으로 설계하는 시스템이다.
|
||||
|
||||
```
|
||||
/admin/systemMng/dataflow
|
||||
↓
|
||||
React Flow 기반 캔버스
|
||||
├── InputNode: 데이터 입력 (폼 데이터, 테이블 데이터)
|
||||
├── TransformNode: 데이터 변환 (매핑, 필터링, 계산)
|
||||
├── DatabaseNode: DB 조회/저장
|
||||
├── RestApiNode: 외부 API 호출
|
||||
├── ConditionNode: 조건 분기
|
||||
├── LoopNode: 반복 처리
|
||||
├── MergeNode: 데이터 합치기
|
||||
└── OutputNode: 결과 출력
|
||||
```
|
||||
|
||||
### 11.2 데이터플로우 실행
|
||||
|
||||
```
|
||||
버튼 클릭 → 데이터플로우 트리거
|
||||
↓
|
||||
InputNode: formData 수집
|
||||
↓
|
||||
TransformNode: 데이터 가공
|
||||
↓
|
||||
ConditionNode: 조건 분기 (가격 > 10000?)
|
||||
├── Yes → DatabaseNode: INSERT INTO premium_products
|
||||
└── No → DatabaseNode: INSERT INTO standard_products
|
||||
↓
|
||||
OutputNode: 결과 반환 → toast.success("저장 완료")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 대시보드 시스템
|
||||
|
||||
### 12.1 구조
|
||||
|
||||
```
|
||||
관리자: /admin/screenMng/dashboardList
|
||||
└→ 대시보드 생성 → 위젯 추가 → 레이아웃 저장
|
||||
|
||||
사용자: /dashboard/[dashboardId]
|
||||
└→ 위젯 그리드 렌더링 → 실시간 데이터 표시
|
||||
```
|
||||
|
||||
### 12.2 위젯 종류
|
||||
|
||||
| 카테고리 | 위젯 | 역할 |
|
||||
|----------|------|------|
|
||||
| 시각화 | CustomMetricWidget | 커스텀 메트릭 표시 |
|
||||
| | StatusSummaryWidget | 상태 요약 |
|
||||
| 리스트 | CargoListWidget | 화물 목록 |
|
||||
| | VehicleListWidget | 차량 목록 |
|
||||
| 지도 | MapTestWidget | 지도 표시 |
|
||||
| | WeatherMapWidget | 날씨 지도 |
|
||||
| 작업 | TodoWidget | 할일 목록 |
|
||||
| | WorkHistoryWidget | 작업 이력 |
|
||||
| 알림 | BookingAlertWidget | 예약 알림 |
|
||||
| | RiskAlertWidget | 위험 알림 |
|
||||
| 기타 | ClockWidget | 시계 |
|
||||
| | CalendarWidget | 캘린더 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 배치/스케줄 시스템
|
||||
|
||||
### 13.1 구조
|
||||
|
||||
```
|
||||
관리자: /admin/automaticMng/batchmngList
|
||||
↓
|
||||
배치 작업 생성:
|
||||
├── 이름: "일일 재고 집계"
|
||||
├── 실행 쿼리: SQL 또는 데이터플로우 ID
|
||||
├── 스케줄: Cron 표현식 ("0 0 * * *" = 매일 자정)
|
||||
└── 활성화/비활성화
|
||||
↓
|
||||
배치 스케줄러 (batch_schedules)
|
||||
↓
|
||||
자동 실행 → 실행 로그 (batch_execution_logs)
|
||||
```
|
||||
|
||||
### 13.2 배치 실행 흐름
|
||||
|
||||
```
|
||||
Cron 트리거 → 배치 정의 조회 → SQL/데이터플로우 실행
|
||||
↓
|
||||
성공: execution_log에 "SUCCESS" 기록
|
||||
실패: execution_log에 "FAILED" + 에러 메시지 기록
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 14. 멀티테넌시 아키텍처
|
||||
|
||||
### 14.1 핵심 원칙
|
||||
|
||||
```
|
||||
모든 비즈니스 테이블: company_code 컬럼 필수
|
||||
모든 쿼리: WHERE company_code = $1 필수
|
||||
모든 JOIN: ON a.company_code = b.company_code 필수
|
||||
모든 집계: GROUP BY company_code 필수
|
||||
```
|
||||
|
||||
### 14.2 데이터 격리
|
||||
|
||||
```
|
||||
회사 A (company_code = "COMPANY_A"):
|
||||
└→ 자기 데이터만 조회/수정/삭제 가능
|
||||
|
||||
회사 B (company_code = "COMPANY_B"):
|
||||
└→ 자기 데이터만 조회/수정/삭제 가능
|
||||
|
||||
슈퍼관리자 (company_code = "*"):
|
||||
└→ 모든 회사 데이터 조회 가능
|
||||
└→ 일반 회사는 "*" 데이터를 볼 수 없음
|
||||
|
||||
중요: company_code = "*"는 공통 데이터가 아니라 슈퍼관리자 전용 데이터!
|
||||
```
|
||||
|
||||
### 14.3 코드 패턴
|
||||
|
||||
```typescript
|
||||
// 백엔드 표준 패턴
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 슈퍼관리자: 전체 데이터
|
||||
query = "SELECT * FROM table ORDER BY company_code";
|
||||
} else {
|
||||
// 일반 사용자: 자기 회사만, "*" 제외
|
||||
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
|
||||
params = [companyCode];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. 외부 연동
|
||||
|
||||
### 15.1 외부 DB 연결
|
||||
|
||||
```
|
||||
지원 DB: PostgreSQL, MySQL, MariaDB, MSSQL, Oracle
|
||||
|
||||
관리: /api/external-db-connections
|
||||
├── 연결 정보 등록 (host, port, database, credentials)
|
||||
├── 연결 테스트
|
||||
├── 쿼리 실행
|
||||
└── 데이터플로우에서 DatabaseNode로 사용
|
||||
```
|
||||
|
||||
### 15.2 외부 REST API 연결
|
||||
|
||||
```
|
||||
관리: /api/external-rest-api-connections
|
||||
├── API 엔드포인트 등록 (URL, method, headers)
|
||||
├── 인증 설정 (Bearer, Basic, API Key)
|
||||
├── 테스트 호출
|
||||
└── 데이터플로우에서 RestApiNode로 사용
|
||||
```
|
||||
|
||||
### 15.3 메일 시스템
|
||||
|
||||
```
|
||||
관리: /admin/automaticMng/mail/*
|
||||
├── 메일 템플릿 관리
|
||||
├── 메일 발송 (개별/대량)
|
||||
├── 수신 메일 확인
|
||||
└── 발송 이력 조회
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 배포 환경
|
||||
|
||||
### 16.1 Docker 구성
|
||||
|
||||
```
|
||||
개발 환경 (Mac):
|
||||
├── docker/dev/docker-compose.backend.mac.yml (BE: 8080)
|
||||
└── docker/dev/docker-compose.frontend.mac.yml (FE: 9771)
|
||||
|
||||
운영 환경:
|
||||
├── docker/prod/docker-compose.backend.prod.yml (BE: 8080)
|
||||
└── docker/prod/docker-compose.frontend.prod.yml (FE: 5555)
|
||||
```
|
||||
|
||||
### 16.2 서버 정보
|
||||
|
||||
| 환경 | 서버 | 포트 | DB |
|
||||
|------|------|------|-----|
|
||||
| 개발 | 39.117.244.52 | FE:9771, BE:8080 | 39.117.244.52:11132 |
|
||||
| 운영 | 211.115.91.141 | FE:5555, BE:8080 | 211.115.91.141:11134 |
|
||||
|
||||
### 16.3 백엔드 시작 시 자동 작업
|
||||
|
||||
```
|
||||
서버 시작 (app.ts)
|
||||
├── 마이그레이션 실행 (DB 스키마 업데이트)
|
||||
├── 배치 스케줄러 초기화
|
||||
├── 위험 알림 캐시 로드
|
||||
└── 메일 정리 Cron 시작
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 부록: 업무 진행 요약
|
||||
|
||||
### 새로운 업무 화면을 만드는 전체 프로세스
|
||||
|
||||
```
|
||||
1. [DB] 테이블 관리에서 비즈니스 테이블 생성
|
||||
└→ 컬럼 정의, 타입 설정
|
||||
|
||||
2. [화면] 화면 관리에서 새 화면 생성
|
||||
└→ 메인 테이블 지정
|
||||
|
||||
3. [디자인] 화면 디자이너에서 UI 구성
|
||||
└→ V2 컴포넌트 배치, 데이터 바인딩
|
||||
|
||||
4. [로직] 데이터플로우 설계 (필요시)
|
||||
└→ 저장/수정/삭제 로직 다이어그램
|
||||
|
||||
5. [플로우] 플로우 정의 (승인 프로세스 필요시)
|
||||
└→ 단계 정의, 연결
|
||||
|
||||
6. [메뉴] 메뉴에 화면 할당
|
||||
└→ 사용자가 접근할 수 있게 메뉴 트리 배치
|
||||
|
||||
7. [권한] 권한 그룹에 메뉴 할당
|
||||
└→ 특정 사용자 그룹만 접근 가능하게
|
||||
|
||||
8. [사용] 사용자가 메뉴 클릭 → 업무 시작!
|
||||
```
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
# WACE ERP Backend - 분석 문서 인덱스
|
||||
|
||||
> **분석 완료일**: 2026-02-06
|
||||
> **분석자**: Backend Specialist
|
||||
|
||||
---
|
||||
|
||||
## 📚 문서 목록
|
||||
|
||||
### 1. 📖 상세 분석 문서
|
||||
**파일**: `backend-architecture-detailed-analysis.md`
|
||||
**내용**: 백엔드 전체 아키텍처 상세 분석 (16개 섹션)
|
||||
|
||||
- 전체 개요 및 기술 스택
|
||||
- 디렉토리 구조
|
||||
- 미들웨어 스택 구성
|
||||
- 인증/인가 시스템 (JWT, 3단계 권한)
|
||||
- 멀티테넌시 구현 방식
|
||||
- API 라우트 전체 목록
|
||||
- 비즈니스 도메인별 모듈 (8개 도메인)
|
||||
- 데이터베이스 접근 방식 (Raw Query)
|
||||
- 외부 시스템 연동 (DB/REST API)
|
||||
- 배치/스케줄 처리 (node-cron)
|
||||
- 파일 처리 (multer)
|
||||
- 에러 핸들링
|
||||
- 로깅 시스템 (Winston)
|
||||
- 보안 및 권한 관리
|
||||
- 성능 최적화
|
||||
|
||||
**특징**: 워크플로우 문서에 통합하기 위한 완전한 아키텍처 분석
|
||||
|
||||
---
|
||||
|
||||
### 2. 📄 요약 문서
|
||||
**파일**: `backend-architecture-summary.md`
|
||||
**내용**: 백엔드 아키텍처 핵심 요약 (16개 섹션 압축)
|
||||
|
||||
- 기술 스택 요약
|
||||
- 계층 구조 다이어그램
|
||||
- 디렉토리 구조
|
||||
- 미들웨어 스택 순서
|
||||
- 인증/인가 흐름도
|
||||
- 멀티테넌시 핵심 원칙
|
||||
- API 라우트 카테고리별 정리
|
||||
- 비즈니스 도메인 8개 요약
|
||||
- 데이터베이스 접근 패턴
|
||||
- 외부 연동 아키텍처
|
||||
- 배치 스케줄러 시스템
|
||||
- 파일 처리 흐름
|
||||
- 보안 정책
|
||||
- 에러 핸들링 전략
|
||||
- 로깅 구조
|
||||
- 성능 최적화 전략
|
||||
- **핵심 체크리스트** (개발 시 필수 규칙 8개)
|
||||
|
||||
**특징**: 빠른 참조를 위한 간결한 요약
|
||||
|
||||
---
|
||||
|
||||
### 3. 🔗 API 라우트 완전 매핑
|
||||
**파일**: `backend-api-route-mapping.md`
|
||||
**내용**: 프론트엔드 개발자용 API 엔드포인트 전체 목록 (200+개)
|
||||
|
||||
#### 포함된 API 카테고리
|
||||
1. 인증 API (7개)
|
||||
2. 관리자 API (15개)
|
||||
3. 테이블 관리 API (30개)
|
||||
4. 화면 관리 API (10개)
|
||||
5. 플로우 API (15개)
|
||||
6. 데이터플로우 API (10개)
|
||||
7. 외부 연동 API (15개)
|
||||
8. 배치 API (10개)
|
||||
9. 메일 API (5개)
|
||||
10. 파일 API (5개)
|
||||
11. 대시보드 API (5개)
|
||||
12. 공통코드 API (3개)
|
||||
13. 다국어 API (3개)
|
||||
14. 회사 관리 API (4개)
|
||||
15. 부서 API (2개)
|
||||
16. 권한 그룹 API (2개)
|
||||
17. DDL 실행 API (1개)
|
||||
18. 외부 API 프록시 (2개)
|
||||
19. 디지털 트윈 API (3개)
|
||||
20. 3D 필드 API (2개)
|
||||
21. 스케줄 API (1개)
|
||||
22. 채번 규칙 API (3개)
|
||||
23. 엔티티 검색 API (2개)
|
||||
24. To-Do API (3개)
|
||||
25. 예약 요청 API (2개)
|
||||
26. 리스크/알림 API (2개)
|
||||
27. 헬스 체크 (1개)
|
||||
|
||||
#### 각 API 정보 포함
|
||||
- HTTP 메서드
|
||||
- 엔드포인트 경로
|
||||
- 필요 권한 (공개/인증/관리자/슈퍼관리자)
|
||||
- 기능 설명
|
||||
- Request Body/Query Params
|
||||
- Response 형식
|
||||
|
||||
#### 추가 정보
|
||||
- Base URL (개발/운영)
|
||||
- 공통 헤더 (Authorization)
|
||||
- 응답 형식 (성공/에러)
|
||||
- 에러 코드 목록
|
||||
|
||||
**특징**: 프론트엔드에서 API 호출 시 즉시 참조 가능
|
||||
|
||||
---
|
||||
|
||||
### 4. 📊 JSON 응답 요약
|
||||
**파일**: `backend-analysis-response.json`
|
||||
**내용**: 구조화된 JSON 형식의 분석 결과
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"confidence": "high",
|
||||
"result": {
|
||||
"summary": "...",
|
||||
"details": "...",
|
||||
"files_affected": [...],
|
||||
"key_findings": {
|
||||
"architecture_pattern": "...",
|
||||
"tech_stack": {...},
|
||||
"middleware_stack": [...],
|
||||
"authentication_flow": {...},
|
||||
"permission_levels": {...},
|
||||
"multi_tenancy": {...},
|
||||
"business_domains": {...},
|
||||
"database_access": {...},
|
||||
"security": {...},
|
||||
"performance_optimization": {...}
|
||||
},
|
||||
"critical_rules": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**특징**: 프로그래밍 방식으로 분석 결과 활용 가능
|
||||
|
||||
---
|
||||
|
||||
## 🎯 핵심 요약
|
||||
|
||||
### 아키텍처
|
||||
- **패턴**: Layered Architecture (Controller → Service → Database)
|
||||
- **언어**: TypeScript (Strict Mode)
|
||||
- **프레임워크**: Express.js
|
||||
- **데이터베이스**: PostgreSQL (Raw Query, Connection Pool)
|
||||
- **인증**: JWT (24시간 만료, 자동 갱신)
|
||||
|
||||
### 멀티테넌시
|
||||
```typescript
|
||||
// ✅ 핵심 원칙
|
||||
const companyCode = req.user!.companyCode; // JWT에서 추출
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 슈퍼관리자: 모든 데이터
|
||||
query = "SELECT * FROM table ORDER BY company_code";
|
||||
} else {
|
||||
// 일반 사용자: 자기 회사만 + 슈퍼관리자 숨김
|
||||
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
|
||||
params = [companyCode];
|
||||
}
|
||||
```
|
||||
|
||||
### 권한 체계 (3단계)
|
||||
1. **SUPER_ADMIN** (`company_code = "*"`)
|
||||
- 전체 회사 데이터 접근
|
||||
- DDL 실행, 회사 생성/삭제
|
||||
|
||||
2. **COMPANY_ADMIN** (`company_code = "ILSHIN"`)
|
||||
- 자기 회사 데이터만 접근
|
||||
- 사용자/설정 관리
|
||||
|
||||
3. **USER** (`company_code = "ILSHIN"`)
|
||||
- 자기 회사 데이터만 접근
|
||||
- 읽기/쓰기만
|
||||
|
||||
### 주요 도메인 (8개)
|
||||
1. **관리자** - 사용자/메뉴/권한
|
||||
2. **테이블/화면** - 메타데이터, 동적 화면
|
||||
3. **플로우** - 워크플로우 엔진
|
||||
4. **데이터플로우** - ERD, 관계도
|
||||
5. **외부 연동** - 외부 DB/REST API
|
||||
6. **배치** - Cron 스케줄러
|
||||
7. **메일** - 발송/수신
|
||||
8. **파일** - 업로드/다운로드
|
||||
|
||||
### API 통계
|
||||
- **총 라우트**: 70+개
|
||||
- **총 API**: 200+개
|
||||
- **컨트롤러**: 70+개
|
||||
- **서비스**: 80+개
|
||||
- **미들웨어**: 4개
|
||||
|
||||
---
|
||||
|
||||
## 🚨 개발 시 필수 규칙
|
||||
|
||||
✅ **모든 쿼리에 `company_code` 필터 추가**
|
||||
✅ **JWT 토큰에서 `company_code` 추출 (클라이언트 신뢰 금지)**
|
||||
✅ **Parameterized Query 사용 (SQL Injection 방지)**
|
||||
✅ **슈퍼관리자 데이터 숨김 (`company_code != '*'`)**
|
||||
✅ **비밀번호는 bcrypt, 민감정보는 AES-256**
|
||||
✅ **에러 핸들링 try/catch 필수**
|
||||
✅ **트랜잭션이 필요한 경우 `transaction()` 사용**
|
||||
✅ **파일 업로드는 회사별 디렉토리 분리**
|
||||
|
||||
---
|
||||
|
||||
## 📁 문서 위치
|
||||
|
||||
```
|
||||
ERP-node/docs/
|
||||
├── backend-architecture-detailed-analysis.md (상세 분석, 16개 섹션)
|
||||
├── backend-architecture-summary.md (요약, 간결한 참조)
|
||||
├── backend-api-route-mapping.md (API 200+개 전체 매핑)
|
||||
└── backend-analysis-response.json (JSON 구조화 데이터)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 문서 사용 가이드
|
||||
|
||||
### 처음 백엔드를 이해하려면
|
||||
→ `backend-architecture-summary.md` 읽기 (20분)
|
||||
|
||||
### 특정 기능을 구현하려면
|
||||
→ `backend-architecture-detailed-analysis.md`에서 해당 도메인 섹션 참조
|
||||
|
||||
### API를 호출하려면
|
||||
→ `backend-api-route-mapping.md`에서 엔드포인트 검색
|
||||
|
||||
### 워크플로우 문서에 통합하려면
|
||||
→ `backend-architecture-detailed-analysis.md` 전체 복사
|
||||
|
||||
### 프로그래밍 방식으로 활용하려면
|
||||
→ `backend-analysis-response.json` 파싱
|
||||
|
||||
---
|
||||
|
||||
**문서 버전**: 1.0
|
||||
**마지막 업데이트**: 2026-02-06
|
||||
**다음 업데이트 예정**: 신규 API 추가 시
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
{
|
||||
"status": "success",
|
||||
"confidence": "high",
|
||||
"result": {
|
||||
"summary": "WACE ERP 백엔드 전체 아키텍처 분석 완료",
|
||||
"details": "Node.js + Express + TypeScript + PostgreSQL Raw Query 기반 멀티테넌시 시스템. 70+ 라우트, 70+ 컨트롤러, 80+ 서비스로 구성된 계층형 아키텍처. JWT 인증, 3단계 권한 체계(SUPER_ADMIN/COMPANY_ADMIN/USER), company_code 기반 완전한 데이터 격리 구현.",
|
||||
"files_affected": [
|
||||
"docs/backend-architecture-detailed-analysis.md (상세 분석 문서)",
|
||||
"docs/backend-architecture-summary.md (요약 문서)",
|
||||
"docs/backend-api-route-mapping.md (API 라우트 전체 매핑)"
|
||||
],
|
||||
"key_findings": {
|
||||
"architecture_pattern": "Layered Architecture (Controller → Service → Database)",
|
||||
"tech_stack": {
|
||||
"language": "TypeScript",
|
||||
"runtime": "Node.js 20.10.0+",
|
||||
"framework": "Express.js",
|
||||
"database": "PostgreSQL (pg 라이브러리, Raw Query)",
|
||||
"authentication": "JWT (jsonwebtoken)",
|
||||
"scheduler": "node-cron",
|
||||
"external_db_support": ["PostgreSQL", "MySQL", "MSSQL", "Oracle"]
|
||||
},
|
||||
"directory_structure": {
|
||||
"controllers": "70+ 파일 (API 요청 수신, 응답 생성)",
|
||||
"services": "80+ 파일 (비즈니스 로직, 트랜잭션 관리)",
|
||||
"routes": "70+ 파일 (API 라우팅)",
|
||||
"middleware": "4개 (인증, 권한, 슈퍼관리자, 에러핸들러)",
|
||||
"types": "26개 (TypeScript 타입 정의)",
|
||||
"utils": "유틸리티 함수 (JWT, 암호화, 로거)"
|
||||
},
|
||||
"middleware_stack": [
|
||||
"1. Process Level Exception Handlers",
|
||||
"2. Helmet (보안 헤더)",
|
||||
"3. Compression (Gzip)",
|
||||
"4. Body Parser (10MB limit)",
|
||||
"5. Static Files (/uploads)",
|
||||
"6. CORS (credentials: true)",
|
||||
"7. Rate Limiting (1분 10000회)",
|
||||
"8. Token Auto Refresh (1시간 이내 만료 시 갱신)",
|
||||
"9. API Routes (70+개)",
|
||||
"10. 404 Handler",
|
||||
"11. Error Handler"
|
||||
],
|
||||
"authentication_flow": {
|
||||
"step1": "로그인 요청 → AuthController.login()",
|
||||
"step2": "AuthService.processLogin() → loginPwdCheck() (bcrypt 검증)",
|
||||
"step3": "getPersonBeanFromSession() → 사용자 정보 조회",
|
||||
"step4": "insertLoginAccessLog() → 로그인 이력 저장",
|
||||
"step5": "JwtUtils.generateToken() → JWT 토큰 생성",
|
||||
"step6": "응답: { token, userInfo, firstMenuPath }"
|
||||
},
|
||||
"jwt_payload": {
|
||||
"userId": "사용자 ID",
|
||||
"userName": "사용자명",
|
||||
"companyCode": "회사 코드 (멀티테넌시 키)",
|
||||
"userType": "권한 레벨 (SUPER_ADMIN/COMPANY_ADMIN/USER)",
|
||||
"exp": "만료 시간 (24시간)"
|
||||
},
|
||||
"permission_levels": {
|
||||
"SUPER_ADMIN": {
|
||||
"company_code": "*",
|
||||
"userType": "SUPER_ADMIN",
|
||||
"capabilities": [
|
||||
"전체 회사 데이터 접근",
|
||||
"DDL 실행",
|
||||
"회사 생성/삭제",
|
||||
"시스템 설정 변경"
|
||||
]
|
||||
},
|
||||
"COMPANY_ADMIN": {
|
||||
"company_code": "특정 회사 (예: ILSHIN)",
|
||||
"userType": "COMPANY_ADMIN",
|
||||
"capabilities": [
|
||||
"자기 회사 데이터만 접근",
|
||||
"자기 회사 사용자 관리",
|
||||
"회사 설정 변경"
|
||||
]
|
||||
},
|
||||
"USER": {
|
||||
"company_code": "특정 회사",
|
||||
"userType": "USER",
|
||||
"capabilities": [
|
||||
"자기 회사 데이터만 접근",
|
||||
"읽기/쓰기 권한만"
|
||||
]
|
||||
}
|
||||
},
|
||||
"multi_tenancy": {
|
||||
"principle": "모든 쿼리에 company_code 필터 필수",
|
||||
"pattern": "JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)",
|
||||
"super_admin_visibility": "일반 회사 사용자에게 슈퍼관리자(company_code='*') 숨김",
|
||||
"correct_pattern": "WHERE company_code = $1 AND company_code != '*'",
|
||||
"wrong_pattern": "req.body.companyCode 사용 (보안 위험!)"
|
||||
},
|
||||
"api_routes": {
|
||||
"total_count": "200+개",
|
||||
"categories": {
|
||||
"인증/관리자": "15개",
|
||||
"테이블/화면": "40개",
|
||||
"플로우": "15개",
|
||||
"데이터플로우": "5개",
|
||||
"외부 연동": "15개",
|
||||
"배치": "10개",
|
||||
"메일": "5개",
|
||||
"파일": "5개",
|
||||
"기타": "90개"
|
||||
}
|
||||
},
|
||||
"business_domains": {
|
||||
"관리자": {
|
||||
"controller": "adminController.ts",
|
||||
"service": "adminService.ts",
|
||||
"features": ["사용자 관리", "메뉴 관리", "권한 그룹 관리", "시스템 설정"]
|
||||
},
|
||||
"테이블/화면": {
|
||||
"controller": "tableManagementController.ts, screenManagementController.ts",
|
||||
"service": "tableManagementService.ts, screenManagementService.ts",
|
||||
"features": ["테이블 메타데이터", "화면 정의", "화면 그룹", "테이블 로그", "엔티티 관계"]
|
||||
},
|
||||
"플로우": {
|
||||
"controller": "flowController.ts",
|
||||
"service": "flowExecutionService.ts, flowDefinitionService.ts",
|
||||
"features": ["워크플로우 설계", "단계 관리", "데이터 이동", "조건부 이동", "오딧 로그"]
|
||||
},
|
||||
"데이터플로우": {
|
||||
"controller": "dataflowController.ts, dataflowDiagramController.ts",
|
||||
"service": "dataflowService.ts, dataflowDiagramService.ts",
|
||||
"features": ["테이블 관계 정의", "ERD", "다이어그램 시각화", "관계 실행"]
|
||||
},
|
||||
"외부 연동": {
|
||||
"controller": "externalDbConnectionController.ts, externalRestApiConnectionController.ts",
|
||||
"service": "externalDbConnectionService.ts, dbConnectionManager.ts",
|
||||
"features": ["외부 DB 연결", "Connection Pool 관리", "REST API 프록시"]
|
||||
},
|
||||
"배치": {
|
||||
"controller": "batchController.ts, batchManagementController.ts",
|
||||
"service": "batchService.ts, batchSchedulerService.ts",
|
||||
"features": ["Cron 스케줄러", "외부 DB → 내부 DB 동기화", "컬럼 매핑", "실행 이력"]
|
||||
},
|
||||
"메일": {
|
||||
"controller": "mailSendSimpleController.ts, mailReceiveBasicController.ts",
|
||||
"service": "mailSendSimpleService.ts, mailReceiveBasicService.ts",
|
||||
"features": ["메일 발송 (nodemailer)", "메일 수신 (IMAP)", "템플릿 관리", "첨부파일"]
|
||||
},
|
||||
"파일": {
|
||||
"controller": "fileController.ts, screenFileController.ts",
|
||||
"service": "fileSystemManager.ts",
|
||||
"features": ["파일 업로드 (multer)", "파일 다운로드", "화면별 파일 관리"]
|
||||
}
|
||||
},
|
||||
"database_access": {
|
||||
"connection_pool": {
|
||||
"min": "2~5 (환경별)",
|
||||
"max": "10~20 (환경별)",
|
||||
"connectionTimeout": "30000ms",
|
||||
"idleTimeout": "600000ms",
|
||||
"statementTimeout": "60000ms"
|
||||
},
|
||||
"query_patterns": {
|
||||
"multi_row": "query('SELECT ...', [params])",
|
||||
"single_row": "queryOne('SELECT ...', [params])",
|
||||
"transaction": "transaction(async (client) => { ... })"
|
||||
},
|
||||
"sql_injection_prevention": "Parameterized Query 사용 (pg 라이브러리)"
|
||||
},
|
||||
"external_integration": {
|
||||
"supported_databases": ["PostgreSQL", "MySQL", "MSSQL", "Oracle"],
|
||||
"connector_pattern": "Factory Pattern (DatabaseConnectorFactory)",
|
||||
"rest_api": "axios 기반 프록시"
|
||||
},
|
||||
"batch_scheduler": {
|
||||
"library": "node-cron",
|
||||
"timezone": "Asia/Seoul",
|
||||
"cron_examples": {
|
||||
"매일 새벽 2시": "0 2 * * *",
|
||||
"5분마다": "*/5 * * * *",
|
||||
"평일 오전 8시": "0 8 * * 1-5"
|
||||
},
|
||||
"execution_flow": [
|
||||
"1. 소스 DB에서 데이터 조회",
|
||||
"2. 컬럼 매핑 적용",
|
||||
"3. 타겟 DB에 INSERT/UPDATE",
|
||||
"4. 실행 로그 기록"
|
||||
]
|
||||
},
|
||||
"file_handling": {
|
||||
"upload_path": "uploads/{company_code}/{timestamp}-{uuid}-{filename}",
|
||||
"max_file_size": "10MB",
|
||||
"allowed_types": ["이미지", "PDF", "Office 문서"],
|
||||
"library": "multer"
|
||||
},
|
||||
"security": {
|
||||
"password_encryption": "bcrypt (12 rounds)",
|
||||
"sensitive_data_encryption": "AES-256-CBC (외부 DB 비밀번호)",
|
||||
"jwt_secret": "환경변수 관리",
|
||||
"security_headers": ["Helmet (CSP, X-Frame-Options)", "CORS (credentials: true)", "Rate Limiting (1분 10000회)"],
|
||||
"sql_injection_prevention": "Parameterized Query"
|
||||
},
|
||||
"error_handling": {
|
||||
"postgres_error_codes": {
|
||||
"23505": "중복된 데이터",
|
||||
"23503": "참조 무결성 위반",
|
||||
"23502": "필수 입력값 누락"
|
||||
},
|
||||
"process_level": {
|
||||
"unhandledRejection": "로깅 (서버 유지)",
|
||||
"uncaughtException": "로깅 (서버 유지, 주의)",
|
||||
"SIGTERM/SIGINT": "Graceful Shutdown"
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"library": "Winston",
|
||||
"log_files": {
|
||||
"error.log": "에러만 (10MB × 5파일)",
|
||||
"combined.log": "전체 로그 (10MB × 10파일)"
|
||||
},
|
||||
"log_levels": "error (0) → warn (1) → info (2) → debug (5)"
|
||||
},
|
||||
"performance_optimization": {
|
||||
"pool_monitoring": "5분마다 상태 체크, 대기 연결 5개 이상 시 경고",
|
||||
"slow_query_detection": "1초 이상 걸린 쿼리 자동 경고",
|
||||
"caching": "Redis (메뉴: 10분 TTL, 공통코드: 30분 TTL)",
|
||||
"compression": "Gzip (1KB 이상 응답, 레벨 6)"
|
||||
}
|
||||
},
|
||||
"critical_rules": [
|
||||
"✅ 모든 쿼리에 company_code 필터 추가",
|
||||
"✅ JWT 토큰에서 company_code 추출 (클라이언트 신뢰 금지)",
|
||||
"✅ Parameterized Query 사용 (SQL Injection 방지)",
|
||||
"✅ 슈퍼관리자 데이터 숨김 (company_code != '*')",
|
||||
"✅ 비밀번호는 bcrypt, 민감정보는 AES-256",
|
||||
"✅ 에러 핸들링 try/catch 필수",
|
||||
"✅ 트랜잭션이 필요한 경우 transaction() 사용",
|
||||
"✅ 파일 업로드는 회사별 디렉토리 분리"
|
||||
]
|
||||
},
|
||||
"needs_from_others": [],
|
||||
"questions": []
|
||||
}
|
||||
|
|
@ -0,0 +1,542 @@
|
|||
# WACE ERP Backend - API 라우트 완전 매핑
|
||||
|
||||
> **작성일**: 2026-02-06
|
||||
> **목적**: 프론트엔드 개발자용 API 엔드포인트 전체 목록
|
||||
|
||||
---
|
||||
|
||||
## 📌 공통 규칙
|
||||
|
||||
### Base URL
|
||||
```
|
||||
개발: http://localhost:8080
|
||||
운영: http://39.117.244.52:8080
|
||||
```
|
||||
|
||||
### 헤더
|
||||
```http
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer {JWT_TOKEN}
|
||||
```
|
||||
|
||||
### 응답 형식
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "성공 메시지",
|
||||
"data": { ... }
|
||||
}
|
||||
|
||||
// 에러 시
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"details": "에러 상세"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 인증 API (`/api/auth`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/auth/login` | 공개 | 로그인 | `{ userId, password }` | `{ token, userInfo, firstMenuPath }` |
|
||||
| POST | `/auth/logout` | 인증 | 로그아웃 | - | `{ success: true }` |
|
||||
| GET | `/auth/me` | 인증 | 현재 사용자 정보 | - | `{ userInfo }` |
|
||||
| GET | `/auth/status` | 공개 | 인증 상태 확인 | - | `{ isLoggedIn, isAdmin }` |
|
||||
| POST | `/auth/refresh` | 인증 | 토큰 갱신 | - | `{ token }` |
|
||||
| POST | `/auth/signup` | 공개 | 회원가입 (공차중계) | `{ userId, password, userName, phoneNumber, licenseNumber, vehicleNumber }` | `{ success: true }` |
|
||||
| POST | `/auth/switch-company` | 슈퍼관리자 | 회사 전환 | `{ companyCode }` | `{ token, companyCode }` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 관리자 API (`/api/admin`)
|
||||
|
||||
### 2.1 사용자 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/admin/users` | 관리자 | 사용자 목록 | `page, limit, search` | `{ users[], total }` |
|
||||
| POST | `/admin/users` | 관리자 | 사용자 생성 | - | `{ user }` |
|
||||
| PUT | `/admin/users/:userId` | 관리자 | 사용자 수정 | - | `{ user }` |
|
||||
| DELETE | `/admin/users/:userId` | 관리자 | 사용자 삭제 | - | `{ success: true }` |
|
||||
| GET | `/admin/users/:userId/history` | 관리자 | 사용자 이력 | - | `{ history[] }` |
|
||||
|
||||
### 2.2 메뉴 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/admin/menus` | 인증 | 메뉴 목록 (트리) | `userId, userLang` | `{ menus[] }` |
|
||||
| POST | `/admin/menus` | 관리자 | 메뉴 생성 | - | `{ menu }` |
|
||||
| PUT | `/admin/menus/:menuId` | 관리자 | 메뉴 수정 | - | `{ menu }` |
|
||||
| DELETE | `/admin/menus/:menuId` | 관리자 | 메뉴 삭제 | - | `{ success: true }` |
|
||||
|
||||
### 2.3 표준 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/admin/web-types` | 인증 | 웹타입 표준 목록 | `{ webTypes[] }` |
|
||||
| GET | `/admin/button-actions` | 인증 | 버튼 액션 표준 | `{ buttonActions[] }` |
|
||||
| GET | `/admin/component-standards` | 인증 | 컴포넌트 표준 | `{ components[] }` |
|
||||
| GET | `/admin/template-standards` | 인증 | 템플릿 표준 | `{ templates[] }` |
|
||||
| GET | `/admin/reports` | 인증 | 리포트 목록 | `{ reports[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 테이블 관리 API (`/api/table-management`)
|
||||
|
||||
### 3.1 테이블 메타데이터
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/table-management/tables` | 인증 | 테이블 목록 | `{ tables[] }` |
|
||||
| GET | `/table-management/tables/:table/columns` | 인증 | 컬럼 목록 | `{ columns[] }` |
|
||||
| GET | `/table-management/tables/:table/schema` | 인증 | 테이블 스키마 | `{ schema }` |
|
||||
| GET | `/table-management/tables/:table/exists` | 인증 | 테이블 존재 여부 | `{ exists: boolean }` |
|
||||
| GET | `/table-management/tables/:table/web-types` | 인증 | 웹타입 정보 | `{ webTypes }` |
|
||||
|
||||
### 3.2 컬럼 설정
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body |
|
||||
|--------|------|------|------|--------------|
|
||||
| POST | `/table-management/tables/:table/columns/:column/settings` | 인증 | 컬럼 설정 업데이트 | `{ web_type, input_type, ... }` |
|
||||
| POST | `/table-management/tables/:table/columns/settings` | 인증 | 전체 컬럼 일괄 업데이트 | `{ columns[] }` |
|
||||
| PUT | `/table-management/tables/:table/label` | 인증 | 테이블 라벨 설정 | `{ label }` |
|
||||
|
||||
### 3.3 데이터 CRUD
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/table-management/tables/:table/data` | 인증 | 데이터 조회 (페이징) | `{ page, limit, filters, sort }` | `{ data[], total }` |
|
||||
| POST | `/table-management/tables/:table/record` | 인증 | 단일 레코드 조회 | `{ conditions }` | `{ record }` |
|
||||
| POST | `/table-management/tables/:table/add` | 인증 | 데이터 추가 | `{ data }` | `{ success: true, id }` |
|
||||
| PUT | `/table-management/tables/:table/edit` | 인증 | 데이터 수정 | `{ conditions, data }` | `{ success: true }` |
|
||||
| DELETE | `/table-management/tables/:table/delete` | 인증 | 데이터 삭제 | `{ conditions }` | `{ success: true }` |
|
||||
|
||||
### 3.4 다중 테이블 저장
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body |
|
||||
|--------|------|------|------|--------------|
|
||||
| POST | `/table-management/multi-table-save` | 인증 | 메인+서브 테이블 저장 | `{ mainTable, mainData, subTables: [{ table, data[] }] }` |
|
||||
|
||||
### 3.5 로그 시스템
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body |
|
||||
|--------|------|------|------|--------------|
|
||||
| POST | `/table-management/tables/:table/log` | 관리자 | 로그 테이블 생성 | - |
|
||||
| GET | `/table-management/tables/:table/log/config` | 인증 | 로그 설정 조회 | - |
|
||||
| GET | `/table-management/tables/:table/log` | 인증 | 로그 데이터 조회 | - |
|
||||
| POST | `/table-management/tables/:table/log/toggle` | 관리자 | 로그 활성화/비활성화 | `{ is_active }` |
|
||||
|
||||
### 3.6 엔티티 관계
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params |
|
||||
|--------|------|------|------|--------------|
|
||||
| GET | `/table-management/tables/entity-relations` | 인증 | 두 테이블 간 관계 조회 | `leftTable, rightTable` |
|
||||
| GET | `/table-management/columns/:table/referenced-by` | 인증 | 현재 테이블 참조 목록 | - |
|
||||
|
||||
### 3.7 카테고리 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/table-management/category-columns` | 인증 | 회사별 카테고리 컬럼 | `{ categoryColumns[] }` |
|
||||
| GET | `/table-management/menu/:menuObjid/category-columns` | 인증 | 메뉴별 카테고리 컬럼 | `{ categoryColumns[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 화면 관리 API (`/api/screen-management`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/screen-management/screens` | 인증 | 화면 목록 | `page, limit` | `{ screens[], total }` |
|
||||
| GET | `/screen-management/screens/:id` | 인증 | 화면 상세 | - | `{ screen }` |
|
||||
| POST | `/screen-management/screens` | 관리자 | 화면 생성 | - | `{ screen }` |
|
||||
| PUT | `/screen-management/screens/:id` | 관리자 | 화면 수정 | - | `{ screen }` |
|
||||
| DELETE | `/screen-management/screens/:id` | 관리자 | 화면 삭제 | - | `{ success: true }` |
|
||||
|
||||
### 화면 그룹
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/screen-groups` | 인증 | 화면 그룹 목록 | `{ screenGroups[] }` |
|
||||
| POST | `/screen-groups` | 관리자 | 그룹 생성 | `{ group }` |
|
||||
|
||||
### 화면 파일
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/screen-files` | 인증 | 화면 파일 목록 | `{ files[] }` |
|
||||
| POST | `/screen-files` | 관리자 | 파일 업로드 | `{ file }` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 플로우 API (`/api/flow`)
|
||||
|
||||
### 5.1 플로우 정의
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/flow/definitions` | 인증 | 플로우 목록 | - | `{ flows[] }` |
|
||||
| GET | `/flow/definitions/:id` | 인증 | 플로우 상세 | - | `{ flow }` |
|
||||
| POST | `/flow/definitions` | 인증 | 플로우 생성 | `{ name, description, targetTable }` | `{ flow }` |
|
||||
| PUT | `/flow/definitions/:id` | 인증 | 플로우 수정 | `{ name, description }` | `{ flow }` |
|
||||
| DELETE | `/flow/definitions/:id` | 인증 | 플로우 삭제 | - | `{ success: true }` |
|
||||
|
||||
### 5.2 단계 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/flow/definitions/:flowId/steps` | 인증 | 단계 목록 | - | `{ steps[] }` |
|
||||
| POST | `/flow/definitions/:flowId/steps` | 인증 | 단계 생성 | `{ name, type, settings }` | `{ step }` |
|
||||
| PUT | `/flow/steps/:stepId` | 인증 | 단계 수정 | `{ name, settings }` | `{ step }` |
|
||||
| DELETE | `/flow/steps/:stepId` | 인증 | 단계 삭제 | - | `{ success: true }` |
|
||||
|
||||
### 5.3 연결 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/flow/connections/:flowId` | 인증 | 연결 목록 | - | `{ connections[] }` |
|
||||
| POST | `/flow/connections` | 인증 | 연결 생성 | `{ fromStepId, toStepId, condition }` | `{ connection }` |
|
||||
| DELETE | `/flow/connections/:connectionId` | 인증 | 연결 삭제 | - | `{ success: true }` |
|
||||
|
||||
### 5.4 데이터 이동
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/flow/move` | 인증 | 데이터 이동 (단건) | `{ flowId, fromStepId, toStepId, recordId }` | `{ success: true }` |
|
||||
| POST | `/flow/move-batch` | 인증 | 데이터 이동 (다건) | `{ flowId, fromStepId, toStepId, recordIds[] }` | `{ success: true, movedCount }` |
|
||||
|
||||
### 5.5 단계 데이터 조회
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/flow/:flowId/step/:stepId/count` | 인증 | 단계 데이터 개수 | - | `{ count }` |
|
||||
| GET | `/flow/:flowId/step/:stepId/list` | 인증 | 단계 데이터 목록 | `page, limit` | `{ data[], total }` |
|
||||
| GET | `/flow/:flowId/step/:stepId/column-labels` | 인증 | 컬럼 라벨 조회 | - | `{ labels }` |
|
||||
| GET | `/flow/:flowId/steps/counts` | 인증 | 모든 단계 카운트 | - | `{ counts[] }` |
|
||||
|
||||
### 5.6 단계 데이터 수정
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| PUT | `/flow/:flowId/step/:stepId/data/:recordId` | 인증 | 인라인 편집 | `{ data }` | `{ success: true }` |
|
||||
|
||||
### 5.7 오딧 로그
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/flow/audit/:flowId/:recordId` | 인증 | 레코드별 오딧 로그 | `{ auditLogs[] }` |
|
||||
| GET | `/flow/audit/:flowId` | 인증 | 플로우 전체 오딧 로그 | `{ auditLogs[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 데이터플로우 API (`/api/dataflow`)
|
||||
|
||||
### 6.1 관계 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/dataflow/relationships` | 인증 | 관계 목록 | - | `{ relationships[] }` |
|
||||
| POST | `/dataflow/relationships` | 인증 | 관계 생성 | `{ fromTable, toTable, fromColumn, toColumn, type }` | `{ relationship }` |
|
||||
| PUT | `/dataflow/relationships/:id` | 인증 | 관계 수정 | `{ name, type }` | `{ relationship }` |
|
||||
| DELETE | `/dataflow/relationships/:id` | 인증 | 관계 삭제 | - | `{ success: true }` |
|
||||
|
||||
### 6.2 다이어그램
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/dataflow-diagrams` | 인증 | 다이어그램 목록 | - | `{ diagrams[] }` |
|
||||
| GET | `/dataflow-diagrams/:id` | 인증 | 다이어그램 상세 | - | `{ diagram }` |
|
||||
| POST | `/dataflow-diagrams` | 인증 | 다이어그램 생성 | `{ name, description }` | `{ diagram }` |
|
||||
|
||||
### 6.3 실행
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/dataflow` | 인증 | 데이터플로우 실행 | `{ relationshipId, params }` | `{ result[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 7. 외부 연동 API
|
||||
|
||||
### 7.1 외부 DB 연결 (`/api/external-db-connections`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/external-db-connections` | 인증 | 연결 목록 | - | `{ connections[] }` |
|
||||
| GET | `/external-db-connections/:id` | 인증 | 연결 상세 | - | `{ connection }` |
|
||||
| POST | `/external-db-connections` | 관리자 | 연결 생성 | `{ connectionName, dbType, host, port, database, username, password }` | `{ connection }` |
|
||||
| PUT | `/external-db-connections/:id` | 관리자 | 연결 수정 | `{ connectionName, ... }` | `{ connection }` |
|
||||
| DELETE | `/external-db-connections/:id` | 관리자 | 연결 삭제 | - | `{ success: true }` |
|
||||
| POST | `/external-db-connections/:id/test` | 인증 | 연결 테스트 | - | `{ success: boolean, message }` |
|
||||
| GET | `/external-db-connections/:id/tables` | 인증 | 테이블 목록 조회 | - | `{ tables[] }` |
|
||||
| GET | `/external-db-connections/:id/tables/:table/columns` | 인증 | 컬럼 목록 조회 | - | `{ columns[] }` |
|
||||
|
||||
### 7.2 외부 REST API (`/api/external-rest-api-connections`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/external-rest-api-connections` | 인증 | API 연결 목록 | - | `{ connections[] }` |
|
||||
| POST | `/external-rest-api-connections` | 관리자 | API 연결 생성 | `{ name, baseUrl, authType, ... }` | `{ connection }` |
|
||||
| POST | `/external-rest-api-connections/:id/test` | 인증 | API 테스트 | `{ endpoint, method }` | `{ response }` |
|
||||
|
||||
### 7.3 멀티 커넥션 (`/api/multi-connection`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/multi-connection/query` | 인증 | 멀티 DB 쿼리 | `{ connections: [{ connectionId, sql }] }` | `{ results[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 8. 배치 API
|
||||
|
||||
### 8.1 배치 설정 (`/api/batch-configs`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/batch-configs` | 인증 | 배치 설정 목록 | - | `{ batchConfigs[] }` |
|
||||
| GET | `/batch-configs/:id` | 인증 | 배치 설정 상세 | - | `{ batchConfig }` |
|
||||
| POST | `/batch-configs` | 관리자 | 배치 설정 생성 | `{ batchName, cronSchedule, sourceConnection, targetTable, mappings }` | `{ batchConfig }` |
|
||||
| PUT | `/batch-configs/:id` | 관리자 | 배치 설정 수정 | `{ batchName, ... }` | `{ batchConfig }` |
|
||||
| DELETE | `/batch-configs/:id` | 관리자 | 배치 설정 삭제 | - | `{ success: true }` |
|
||||
| GET | `/batch-configs/connections` | 관리자 | 사용 가능한 커넥션 목록 | - | `{ connections[] }` |
|
||||
| GET | `/batch-configs/connections/:type/tables` | 관리자 | 테이블 목록 조회 | - | `{ tables[] }` |
|
||||
|
||||
### 8.2 배치 실행 (`/api/batch-management`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/batch-management/:id/execute` | 관리자 | 배치 즉시 실행 | - | `{ success: true, executionLogId }` |
|
||||
|
||||
### 8.3 실행 이력 (`/api/batch-execution-logs`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/batch-execution-logs` | 인증 | 실행 이력 목록 | `batchConfigId, page, limit` | `{ logs[], total }` |
|
||||
| GET | `/batch-execution-logs/:id` | 인증 | 실행 이력 상세 | - | `{ log }` |
|
||||
|
||||
---
|
||||
|
||||
## 9. 메일 API (`/api/mail`)
|
||||
|
||||
### 9.1 계정 관리
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/mail/accounts` | 인증 | 계정 목록 | - | `{ accounts[] }` |
|
||||
| POST | `/mail/accounts` | 관리자 | 계정 추가 | `{ email, smtpHost, smtpPort, password }` | `{ account }` |
|
||||
|
||||
### 9.2 템플릿
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/mail/templates-file` | 인증 | 템플릿 목록 | - | `{ templates[] }` |
|
||||
| POST | `/mail/templates-file` | 관리자 | 템플릿 생성 | `{ name, subject, body }` | `{ template }` |
|
||||
|
||||
### 9.3 발송/수신
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/mail/send` | 인증 | 메일 발송 | `{ accountId, to, subject, body, attachments[] }` | `{ success: true, messageId }` |
|
||||
| GET | `/mail/sent` | 인증 | 발송 이력 | `page, limit` | `{ mails[], total }` |
|
||||
| POST | `/mail/receive` | 인증 | 메일 수신 | `{ accountId }` | `{ mails[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 10. 파일 API (`/api/files`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/files/upload` | 인증 | 파일 업로드 (multipart) | `FormData { file }` | `{ fileId, fileName, filePath, fileSize }` |
|
||||
| GET | `/files` | 인증 | 파일 목록 | `page, limit` | `{ files[], total }` |
|
||||
| GET | `/files/:id` | 인증 | 파일 정보 조회 | - | `{ file }` |
|
||||
| GET | `/files/download/:id` | 인증 | 파일 다운로드 | - | `(파일 스트림)` |
|
||||
| DELETE | `/files/:id` | 인증 | 파일 삭제 | - | `{ success: true }` |
|
||||
| GET | `/uploads/:filename` | 공개 | 정적 파일 서빙 | - | `(파일 스트림)` |
|
||||
|
||||
---
|
||||
|
||||
## 11. 대시보드 API (`/api/dashboards`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/dashboards` | 인증 | 대시보드 목록 | - | `{ dashboards[] }` |
|
||||
| GET | `/dashboards/:id` | 인증 | 대시보드 상세 | - | `{ dashboard }` |
|
||||
| POST | `/dashboards` | 관리자 | 대시보드 생성 | - | `{ dashboard }` |
|
||||
| GET | `/dashboards/:id/widgets` | 인증 | 위젯 데이터 조회 | - | `{ widgets[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 12. 공통코드 API (`/api/common-codes`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/common-codes` | 인증 | 공통코드 목록 | `codeGroup` | `{ codes[] }` |
|
||||
| GET | `/common-codes/:codeGroup/:code` | 인증 | 공통코드 상세 | - | `{ code }` |
|
||||
| POST | `/common-codes` | 관리자 | 공통코드 생성 | `{ codeGroup, code, name }` | `{ code }` |
|
||||
|
||||
---
|
||||
|
||||
## 13. 다국어 API (`/api/multilang`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/multilang` | 인증 | 다국어 키 목록 | `lang` | `{ translations{} }` |
|
||||
| GET | `/multilang/:key` | 인증 | 특정 키 조회 | `lang` | `{ key, value }` |
|
||||
| POST | `/multilang` | 관리자 | 다국어 추가 | `{ key, ko, en, cn }` | `{ translation }` |
|
||||
|
||||
---
|
||||
|
||||
## 14. 회사 관리 API (`/api/company-management`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/company-management` | 슈퍼관리자 | 회사 목록 | - | `{ companies[] }` |
|
||||
| POST | `/company-management` | 슈퍼관리자 | 회사 생성 | `{ companyCode, companyName }` | `{ company }` |
|
||||
| PUT | `/company-management/:code` | 슈퍼관리자 | 회사 수정 | `{ companyName }` | `{ company }` |
|
||||
| DELETE | `/company-management/:code` | 슈퍼관리자 | 회사 삭제 | - | `{ success: true }` |
|
||||
|
||||
---
|
||||
|
||||
## 15. 부서 API (`/api/departments`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/departments` | 인증 | 부서 목록 (트리) | - | `{ departments[] }` |
|
||||
| POST | `/departments` | 관리자 | 부서 생성 | `{ deptCode, deptName, parentDeptCode }` | `{ department }` |
|
||||
|
||||
---
|
||||
|
||||
## 16. 권한 그룹 API (`/api/roles`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/roles` | 인증 | 권한 그룹 목록 | - | `{ roles[] }` |
|
||||
| POST | `/roles` | 관리자 | 권한 그룹 생성 | `{ roleName, permissions[] }` | `{ role }` |
|
||||
|
||||
---
|
||||
|
||||
## 17. DDL 실행 API (`/api/ddl`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/ddl` | 슈퍼관리자 | DDL 실행 | `{ sql }` | `{ success: true, result }` |
|
||||
|
||||
---
|
||||
|
||||
## 18. 외부 API 프록시 (`/api/open-api`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/open-api/weather` | 인증 | 날씨 정보 조회 | `location` | `{ weather }` |
|
||||
| GET | `/open-api/exchange` | 인증 | 환율 정보 조회 | `fromCurrency, toCurrency` | `{ rate }` |
|
||||
|
||||
---
|
||||
|
||||
## 19. 디지털 트윈 API (`/api/digital-twin`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/digital-twin/layouts` | 인증 | 레이아웃 목록 | - | `{ layouts[] }` |
|
||||
| GET | `/digital-twin/templates` | 인증 | 템플릿 목록 | - | `{ templates[] }` |
|
||||
| GET | `/digital-twin/data` | 인증 | 실시간 데이터 | - | `{ data[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 20. 3D 필드 API (`/api/yard-layouts`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/yard-layouts` | 인증 | 필드 레이아웃 목록 | - | `{ yardLayouts[] }` |
|
||||
| POST | `/yard-layouts` | 인증 | 레이아웃 저장 | `{ layout }` | `{ success: true }` |
|
||||
|
||||
---
|
||||
|
||||
## 21. 스케줄 API (`/api/schedule`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/schedule` | 인증 | 스케줄 자동 생성 | `{ params }` | `{ schedule }` |
|
||||
|
||||
---
|
||||
|
||||
## 22. 채번 규칙 API (`/api/numbering-rules`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/numbering-rules` | 인증 | 채번 규칙 목록 | - | `{ rules[] }` |
|
||||
| POST | `/numbering-rules` | 관리자 | 규칙 생성 | `{ ruleName, prefix, format }` | `{ rule }` |
|
||||
| POST | `/numbering-rules/:id/generate` | 인증 | 번호 생성 | - | `{ number }` |
|
||||
|
||||
---
|
||||
|
||||
## 23. 엔티티 검색 API (`/api/entity-search`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| POST | `/entity-search` | 인증 | 엔티티 검색 | `{ table, filters, page, limit }` | `{ results[], total }` |
|
||||
| GET | `/entity/:table/options` | 인증 | V2Select용 옵션 | `search, limit` | `{ options[] }` |
|
||||
|
||||
---
|
||||
|
||||
## 24. To-Do API (`/api/todos`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/todos` | 인증 | To-Do 목록 | `status, assignee` | `{ todos[] }` |
|
||||
| POST | `/todos` | 인증 | To-Do 생성 | `{ title, description, dueDate }` | `{ todo }` |
|
||||
| PUT | `/todos/:id` | 인증 | To-Do 수정 | `{ status }` | `{ todo }` |
|
||||
|
||||
---
|
||||
|
||||
## 25. 예약 요청 API (`/api/bookings`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Request Body | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/bookings` | 인증 | 예약 목록 | - | `{ bookings[] }` |
|
||||
| POST | `/bookings` | 인증 | 예약 생성 | `{ resourceId, startTime, endTime }` | `{ booking }` |
|
||||
|
||||
---
|
||||
|
||||
## 26. 리스크/알림 API (`/api/risk-alerts`)
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Query Params | Response |
|
||||
|--------|------|------|------|--------------|----------|
|
||||
| GET | `/risk-alerts` | 인증 | 리스크/알림 목록 | `priority, status` | `{ alerts[] }` |
|
||||
| POST | `/risk-alerts` | 인증 | 알림 생성 | `{ title, content, priority }` | `{ alert }` |
|
||||
|
||||
---
|
||||
|
||||
## 27. 헬스 체크
|
||||
|
||||
| 메서드 | 경로 | 권한 | 기능 | Response |
|
||||
|--------|------|------|------|----------|
|
||||
| GET | `/health` | 공개 | 서버 상태 확인 | `{ status: "OK", timestamp, uptime, environment }` |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 에러 코드 목록
|
||||
|
||||
| 코드 | HTTP Status | 설명 |
|
||||
|------|-------------|------|
|
||||
| `TOKEN_MISSING` | 401 | 인증 토큰 누락 |
|
||||
| `TOKEN_EXPIRED` | 401 | 토큰 만료 |
|
||||
| `INVALID_TOKEN` | 401 | 유효하지 않은 토큰 |
|
||||
| `AUTHENTICATION_REQUIRED` | 401 | 인증 필요 |
|
||||
| `INSUFFICIENT_PERMISSION` | 403 | 권한 부족 |
|
||||
| `SUPER_ADMIN_REQUIRED` | 403 | 슈퍼관리자 권한 필요 |
|
||||
| `COMPANY_ACCESS_DENIED` | 403 | 회사 데이터 접근 거부 |
|
||||
| `INVALID_INPUT` | 400 | 잘못된 입력 |
|
||||
| `RESOURCE_NOT_FOUND` | 404 | 리소스 없음 |
|
||||
| `DUPLICATE_ENTRY` | 400 | 중복 데이터 |
|
||||
| `FOREIGN_KEY_VIOLATION` | 400 | 참조 무결성 위반 |
|
||||
| `SERVER_ERROR` | 500 | 서버 오류 |
|
||||
|
||||
---
|
||||
|
||||
**문서 버전**: 1.0
|
||||
**마지막 업데이트**: 2026-02-06
|
||||
**총 API 개수**: 200+개
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,342 @@
|
|||
# WACE ERP Backend - 아키텍처 요약
|
||||
|
||||
> **작성일**: 2026-02-06
|
||||
> **목적**: 워크플로우 문서 통합용 백엔드 아키텍처 요약
|
||||
|
||||
---
|
||||
|
||||
## 1. 기술 스택
|
||||
|
||||
```
|
||||
언어: TypeScript (Node.js 20.10.0+)
|
||||
프레임워크: Express.js
|
||||
데이터베이스: PostgreSQL (pg 라이브러리, Raw Query)
|
||||
인증: JWT (jsonwebtoken)
|
||||
스케줄러: node-cron
|
||||
메일: nodemailer + IMAP
|
||||
파일업로드: multer
|
||||
외부DB: MySQL, MSSQL, Oracle 지원
|
||||
```
|
||||
|
||||
## 2. 계층 구조
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ Controller │ ← API 요청 수신, 응답 생성
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Service │ ← 비즈니스 로직, 트랜잭션 관리
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────────▼────────┐
|
||||
│ Database │ ← PostgreSQL Raw Query
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 3. 디렉토리 구조
|
||||
|
||||
```
|
||||
backend-node/src/
|
||||
├── app.ts # Express 앱 진입점
|
||||
├── config/ # 환경설정
|
||||
├── controllers/ # 70+ 컨트롤러
|
||||
├── services/ # 80+ 서비스
|
||||
├── routes/ # 70+ 라우터
|
||||
├── middleware/ # 인증/권한/에러핸들러
|
||||
├── database/ # DB 연결 (pg Pool)
|
||||
├── types/ # TypeScript 타입 (26개)
|
||||
└── utils/ # 유틸리티 (JWT, 암호화, 로거)
|
||||
```
|
||||
|
||||
## 4. 미들웨어 스택 순서
|
||||
|
||||
```typescript
|
||||
1. Process Level Exception Handlers (unhandledRejection, uncaughtException)
|
||||
2. Helmet (보안 헤더)
|
||||
3. Compression (Gzip)
|
||||
4. Body Parser (JSON, URL-encoded, 10MB limit)
|
||||
5. Static Files (/uploads)
|
||||
6. CORS (credentials: true)
|
||||
7. Rate Limiting (1분 10000회)
|
||||
8. Token Auto Refresh (1시간 이내 만료 시 갱신)
|
||||
9. API Routes (70+개)
|
||||
10. 404 Handler
|
||||
11. Error Handler
|
||||
```
|
||||
|
||||
## 5. 인증/인가 시스템
|
||||
|
||||
### 5.1 인증 흐름
|
||||
|
||||
```
|
||||
로그인 요청
|
||||
↓
|
||||
AuthController.login()
|
||||
↓
|
||||
AuthService.processLogin()
|
||||
├─ loginPwdCheck() → 비밀번호 검증 (bcrypt)
|
||||
├─ getPersonBeanFromSession() → 사용자 정보 조회
|
||||
├─ insertLoginAccessLog() → 로그인 이력 저장
|
||||
└─ JwtUtils.generateToken() → JWT 토큰 생성
|
||||
↓
|
||||
응답: { token, userInfo, firstMenuPath }
|
||||
```
|
||||
|
||||
### 5.2 JWT Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"userId": "user123",
|
||||
"userName": "홍길동",
|
||||
"companyCode": "ILSHIN",
|
||||
"userType": "COMPANY_ADMIN",
|
||||
"iat": 1234567890,
|
||||
"exp": 1234654290,
|
||||
"iss": "PMS-System"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 권한 체계 (3단계)
|
||||
|
||||
| 권한 | company_code | userType | 권한 범위 |
|
||||
|------|--------------|----------|-----------|
|
||||
| **SUPER_ADMIN** | `*` | `SUPER_ADMIN` | 전체 회사, DDL 실행, 회사 생성/삭제 |
|
||||
| **COMPANY_ADMIN** | `ILSHIN` | `COMPANY_ADMIN` | 자기 회사만, 사용자/설정 관리 |
|
||||
| **USER** | `ILSHIN` | `USER` | 자기 회사만, 읽기/쓰기 |
|
||||
|
||||
## 6. 멀티테넌시 구현
|
||||
|
||||
### 핵심 원칙
|
||||
```typescript
|
||||
// ✅ 올바른 패턴
|
||||
const companyCode = req.user!.companyCode; // JWT에서 추출
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 슈퍼관리자: 모든 데이터 조회
|
||||
query = "SELECT * FROM table ORDER BY company_code";
|
||||
} else {
|
||||
// 일반 사용자: 자기 회사 + 슈퍼관리자 데이터 제외
|
||||
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
|
||||
params = [companyCode];
|
||||
}
|
||||
|
||||
// ❌ 잘못된 패턴 (보안 위험!)
|
||||
const companyCode = req.body.companyCode; // 클라이언트에서 받음
|
||||
```
|
||||
|
||||
### 슈퍼관리자 숨김 규칙
|
||||
```sql
|
||||
-- 일반 회사 사용자에게 슈퍼관리자(company_code='*')는 보이면 안 됨
|
||||
SELECT * FROM user_info
|
||||
WHERE company_code = $1
|
||||
AND company_code != '*' -- 필수!
|
||||
```
|
||||
|
||||
## 7. API 라우트 (70+개)
|
||||
|
||||
### 7.1 인증/관리자
|
||||
- `POST /api/auth/login` - 로그인
|
||||
- `GET /api/auth/me` - 현재 사용자 정보
|
||||
- `POST /api/auth/switch-company` - 회사 전환 (슈퍼관리자)
|
||||
- `GET /api/admin/users` - 사용자 목록
|
||||
- `GET /api/admin/menus` - 메뉴 목록
|
||||
|
||||
### 7.2 테이블/화면
|
||||
- `GET /api/table-management/tables` - 테이블 목록
|
||||
- `POST /api/table-management/tables/:table/data` - 데이터 조회
|
||||
- `POST /api/table-management/multi-table-save` - 다중 테이블 저장
|
||||
- `GET /api/screen-management/screens` - 화면 목록
|
||||
|
||||
### 7.3 플로우
|
||||
- `GET /api/flow/definitions` - 플로우 정의 목록
|
||||
- `POST /api/flow/move` - 데이터 이동 (단건)
|
||||
- `POST /api/flow/move-batch` - 데이터 이동 (다건)
|
||||
|
||||
### 7.4 외부 연동
|
||||
- `GET /api/external-db-connections` - 외부 DB 연결 목록
|
||||
- `POST /api/external-db-connections/:id/test` - 연결 테스트
|
||||
- `POST /api/multi-connection/query` - 멀티 DB 쿼리
|
||||
|
||||
### 7.5 배치
|
||||
- `GET /api/batch-configs` - 배치 설정 목록
|
||||
- `POST /api/batch-management/:id/execute` - 배치 즉시 실행
|
||||
|
||||
### 7.6 메일
|
||||
- `POST /api/mail/send` - 메일 발송
|
||||
- `GET /api/mail/sent` - 발송 이력
|
||||
|
||||
### 7.7 파일
|
||||
- `POST /api/files/upload` - 파일 업로드
|
||||
- `GET /uploads/:filename` - 정적 파일 서빙
|
||||
|
||||
## 8. 비즈니스 도메인 (8개)
|
||||
|
||||
| 도메인 | 컨트롤러 | 주요 기능 |
|
||||
|--------|----------|-----------|
|
||||
| **관리자** | `adminController` | 사용자/메뉴/권한 관리 |
|
||||
| **테이블/화면** | `tableManagementController` | 메타데이터, 동적 화면 생성 |
|
||||
| **플로우** | `flowController` | 워크플로우 엔진, 데이터 이동 |
|
||||
| **데이터플로우** | `dataflowController` | ERD, 관계도 |
|
||||
| **외부 연동** | `externalDbConnectionController` | 외부 DB/REST API |
|
||||
| **배치** | `batchController` | Cron 스케줄러, 데이터 동기화 |
|
||||
| **메일** | `mailSendSimpleController` | 메일 발송/수신 |
|
||||
| **파일** | `fileController` | 파일 업로드/다운로드 |
|
||||
|
||||
## 9. 데이터베이스 접근
|
||||
|
||||
### Connection Pool 설정
|
||||
```typescript
|
||||
{
|
||||
min: 2~5, // 최소 연결 수
|
||||
max: 10~20, // 최대 연결 수
|
||||
connectionTimeout: 30000, // 30초
|
||||
idleTimeout: 600000, // 10분
|
||||
statementTimeout: 60000 // 쿼리 실행 60초
|
||||
}
|
||||
```
|
||||
|
||||
### Raw Query 패턴
|
||||
```typescript
|
||||
// 1. 다중 행
|
||||
const users = await query('SELECT * FROM user_info WHERE company_code = $1', [companyCode]);
|
||||
|
||||
// 2. 단일 행
|
||||
const user = await queryOne('SELECT * FROM user_info WHERE user_id = $1', [userId]);
|
||||
|
||||
// 3. 트랜잭션
|
||||
await transaction(async (client) => {
|
||||
await client.query('INSERT INTO table1 ...', [...]);
|
||||
await client.query('INSERT INTO table2 ...', [...]);
|
||||
});
|
||||
```
|
||||
|
||||
## 10. 외부 시스템 연동
|
||||
|
||||
### 지원 데이터베이스
|
||||
- PostgreSQL
|
||||
- MySQL
|
||||
- Microsoft SQL Server
|
||||
- Oracle
|
||||
|
||||
### Connector Factory Pattern
|
||||
```typescript
|
||||
DatabaseConnectorFactory
|
||||
├── PostgreSQLConnector
|
||||
├── MySQLConnector
|
||||
├── MSSQLConnector
|
||||
└── OracleConnector
|
||||
```
|
||||
|
||||
## 11. 배치/스케줄 시스템
|
||||
|
||||
### Cron 스케줄러
|
||||
```typescript
|
||||
// node-cron 기반
|
||||
// 매일 새벽 2시: "0 2 * * *"
|
||||
// 5분마다: "*/5 * * * *"
|
||||
// 평일 오전 8시: "0 8 * * 1-5"
|
||||
|
||||
// 서버 시작 시 자동 초기화
|
||||
BatchSchedulerService.initializeScheduler();
|
||||
```
|
||||
|
||||
### 배치 실행 흐름
|
||||
```
|
||||
1. 소스 DB에서 데이터 조회
|
||||
↓
|
||||
2. 컬럼 매핑 적용
|
||||
↓
|
||||
3. 타겟 DB에 INSERT/UPDATE
|
||||
↓
|
||||
4. 실행 로그 기록 (batch_execution_logs)
|
||||
```
|
||||
|
||||
## 12. 파일 처리
|
||||
|
||||
### 업로드 경로
|
||||
```
|
||||
uploads/
|
||||
└── {company_code}/
|
||||
└── {timestamp}-{uuid}-{filename}
|
||||
```
|
||||
|
||||
### Multer 설정
|
||||
- 최대 파일 크기: 10MB
|
||||
- 허용 타입: 이미지, PDF, Office 문서
|
||||
- 파일명 중복 방지: 타임스탬프 + UUID
|
||||
|
||||
## 13. 보안
|
||||
|
||||
### 암호화
|
||||
- **비밀번호**: bcrypt (12 rounds)
|
||||
- **민감정보**: AES-256-CBC (외부 DB 비밀번호 등)
|
||||
- **JWT Secret**: 환경변수 관리
|
||||
|
||||
### 보안 헤더
|
||||
- Helmet (CSP, X-Frame-Options)
|
||||
- CORS (credentials: true)
|
||||
- Rate Limiting (1분 10000회)
|
||||
|
||||
### SQL Injection 방지
|
||||
- Parameterized Query 사용 (pg 라이브러리)
|
||||
- 동적 쿼리 빌더 패턴
|
||||
|
||||
## 14. 에러 핸들링
|
||||
|
||||
### PostgreSQL 에러 코드 매핑
|
||||
- `23505` → "중복된 데이터"
|
||||
- `23503` → "참조 무결성 위반"
|
||||
- `23502` → "필수 입력값 누락"
|
||||
|
||||
### 프로세스 레벨
|
||||
- `unhandledRejection` → 로깅 (서버 유지)
|
||||
- `uncaughtException` → 로깅 (서버 유지, 주의)
|
||||
- `SIGTERM/SIGINT` → Graceful Shutdown
|
||||
|
||||
## 15. 로깅 (Winston)
|
||||
|
||||
### 로그 파일
|
||||
- `logs/error.log` - 에러만 (10MB × 5파일)
|
||||
- `logs/combined.log` - 전체 로그 (10MB × 10파일)
|
||||
|
||||
### 로그 레벨
|
||||
```
|
||||
error (0) → warn (1) → info (2) → debug (5)
|
||||
```
|
||||
|
||||
## 16. 성능 최적화
|
||||
|
||||
### Pool 모니터링
|
||||
- 5분마다 상태 체크
|
||||
- 대기 연결 5개 이상 시 경고
|
||||
|
||||
### 느린 쿼리 감지
|
||||
- 1초 이상 걸린 쿼리 자동 경고
|
||||
|
||||
### 캐싱 (Redis)
|
||||
- 메뉴 목록: 10분 TTL
|
||||
- 공통코드: 30분 TTL
|
||||
|
||||
### Gzip 압축
|
||||
- 1KB 이상 응답만 압축 (레벨 6)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 핵심 체크리스트
|
||||
|
||||
### 개발 시 반드시 지켜야 할 규칙
|
||||
|
||||
✅ **모든 쿼리에 `company_code` 필터 추가**
|
||||
✅ **JWT 토큰에서 `company_code` 추출 (클라이언트 신뢰 금지)**
|
||||
✅ **Parameterized Query 사용 (SQL Injection 방지)**
|
||||
✅ **슈퍼관리자 데이터 숨김 (`company_code != '*'`)**
|
||||
✅ **비밀번호는 bcrypt, 민감정보는 AES-256**
|
||||
✅ **에러 핸들링 try/catch 필수**
|
||||
✅ **트랜잭션이 필요한 경우 `transaction()` 사용**
|
||||
✅ **파일 업로드는 회사별 디렉토리 분리**
|
||||
|
||||
---
|
||||
|
||||
**문서 버전**: 1.0
|
||||
**마지막 업데이트**: 2026-02-06
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# formData 콘솔 로그 수동 테스트 가이드
|
||||
|
||||
## 테스트 시나리오
|
||||
|
||||
1. http://localhost:9771/screens/1599?menuObjid=1762422235300 접속
|
||||
2. 로그인 필요 시: `topseal_admin` / `1234`
|
||||
3. 5초 대기 (페이지 로드)
|
||||
4. 첫 번째 탭 "공정 마스터" 확인
|
||||
5. 좌측 패널에서 **P003** 행 클릭
|
||||
6. 우측 패널에서 **추가** 버튼 클릭
|
||||
7. 모달에서 설비(equipment) 드롭다운에서 항목 선택
|
||||
8. **저장** 버튼 클릭 **전** 콘솔 스냅샷 확인
|
||||
9. **저장** 버튼 클릭 **후** 콘솔 로그 확인
|
||||
|
||||
## 확인할 콘솔 로그
|
||||
|
||||
### 1. ADD 모드 formData 설정 (ScreenModal)
|
||||
|
||||
```
|
||||
🔵 [ScreenModal] ADD모드 formData 설정: {...}
|
||||
```
|
||||
|
||||
- **위치**: `frontend/components/common/ScreenModal.tsx` 358행
|
||||
- **의미**: 모달이 ADD 모드로 열릴 때 부모 데이터(splitPanelParentData)로 설정된 초기 formData
|
||||
- **확인**: `process_code`가 P003으로 포함되어 있는지
|
||||
|
||||
### 2. formData 변경 시 (ScreenModal)
|
||||
|
||||
```
|
||||
🟡 [ScreenModal] onFormDataChange: equipment_code → E001 | formData keys: [...] | process_code: P003
|
||||
```
|
||||
|
||||
- **위치**: `frontend/components/common/ScreenModal.tsx` 1184행
|
||||
- **의미**: 사용자가 설비를 선택할 때마다 발생
|
||||
- **확인**: `process_code`가 유지되는지, `equipment_code`가 추가되는지
|
||||
|
||||
### 3. 저장 시 formData 디버그 (ButtonPrimary)
|
||||
|
||||
```
|
||||
🔴 [ButtonPrimary] 저장 시 formData 디버그: {
|
||||
propsFormDataKeys: [...],
|
||||
screenContextFormDataKeys: [...],
|
||||
effectiveFormDataKeys: [...],
|
||||
process_code: "P003",
|
||||
equipment_code: "E001",
|
||||
fullData: "{...}"
|
||||
}
|
||||
```
|
||||
|
||||
- **위치**: `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` 1110행
|
||||
- **의미**: 저장 버튼 클릭 시 실제로 API에 전달되는 formData
|
||||
- **확인**: `process_code`, `equipment_code`가 모두 포함되어 있는지
|
||||
|
||||
## 추가로 확인할 로그
|
||||
|
||||
- `process_code` 포함 로그
|
||||
- `splitPanelParentData` 포함 로그
|
||||
- `🆕 [추가모달] screenId 기반 모달 열기:` (SplitPanelLayoutComponent 1639행)
|
||||
|
||||
## 에러 확인
|
||||
|
||||
콘솔에 빨간색으로 표시되는 에러 메시지가 있는지 확인하세요.
|
||||
|
||||
## 사전 조건
|
||||
|
||||
- **process_mng** 테이블에 P003 데이터가 있어야 함 (company_code = 로그인 사용자 회사)
|
||||
- **equipment_mng** 테이블에 설비 데이터가 있어야 함
|
||||
- 로그인 사용자가 해당 회사(COMPANY_7 등) 권한이 있어야 함
|
||||
|
||||
## 자동 테스트 스크립트
|
||||
|
||||
데이터가 준비된 환경에서:
|
||||
|
||||
```bash
|
||||
cd frontend && npx tsx scripts/test-formdata-logs.ts
|
||||
```
|
||||
|
||||
데이터가 없으면 "좌측 테이블에 데이터가 없습니다" 오류가 발생합니다.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,78 @@
|
|||
# BOM 엑셀 업로드 기능 개발 계획
|
||||
|
||||
## 개요
|
||||
탑씰(COMPANY_7) BOM관리 화면(screen_id=4168)에 엑셀 업로드 기능을 추가한다.
|
||||
BOM은 트리 구조(parent_detail_id 자기참조)이므로 범용 엑셀 업로드를 사용할 수 없고,
|
||||
BOM 전용 엑셀 업로드 컴포넌트를 개발한다.
|
||||
|
||||
## 핵심 구조
|
||||
|
||||
### DB 테이블
|
||||
- `bom` (마스터): id(UUID), item_id(→item_info), version, current_version_id
|
||||
- `bom_detail` (디테일-트리): id(UUID), bom_id(FK), parent_detail_id(자기참조), child_item_id(→item_info), level, seq_no, quantity, unit, loss_rate, process_type, version_id
|
||||
- `item_info`: id, item_number(품번), item_name(품명), division(구분), unit, size, material
|
||||
|
||||
### 엑셀 포맷 설계 (화면과 동일한 레벨 체계)
|
||||
엑셀 파일은 다음 컬럼으로 구성:
|
||||
|
||||
| 레벨 | 품번 | 품명 | 소요량 | 단위 | 로스율(%) | 공정구분 | 비고 |
|
||||
|------|------|------|--------|------|-----------|----------|------|
|
||||
| 0 | PROD-001 | 완제품A | 1 | EA | 0 | | ← BOM 헤더 (건너뜀) |
|
||||
| 1 | P-001 | 부품A | 2 | EA | 0 | | ← 직접 자품목 |
|
||||
| 2 | P-002 | 부품B | 3 | EA | 5 | 가공 | ← P-001의 하위 |
|
||||
| 1 | P-003 | 부품C | 1 | KG | 0 | | ← 직접 자품목 |
|
||||
| 2 | P-004 | 부품D | 4 | EA | 0 | 조립 | ← P-003의 하위 |
|
||||
| 1 | P-005 | 부품E | 1 | EA | 0 | | ← 직접 자품목 |
|
||||
|
||||
- 레벨 0: BOM 헤더 (최상위 품목) → 업로드 시 건너뜀 (이미 존재)
|
||||
- 레벨 1: 직접 자품목 → bom_detail (parent_detail_id=null, DB level=0)
|
||||
- 레벨 2: 자품목의 하위 → bom_detail (parent_detail_id=부모ID, DB level=1)
|
||||
- 레벨 N: → bom_detail (DB level=N-1)
|
||||
- 품번으로 item_info를 조회하여 child_item_id 자동 매핑
|
||||
|
||||
### 트리 변환 로직 (레벨 1 이상만 처리)
|
||||
엑셀 행을 순서대로 순회하면서 (레벨 0 건너뜀):
|
||||
1. 각 행의 엑셀 레벨에서 -1하여 DB 레벨 계산
|
||||
2. 스택으로 부모-자식 관계 추적
|
||||
|
||||
```
|
||||
행1(레벨0) → BOM 헤더, 건너뜀
|
||||
행2(레벨1) → DB level=0, 스택: [행2] → parent_detail_id = null
|
||||
행3(레벨2) → DB level=1, 스택: [행2, 행3] → parent_detail_id = 행2.id
|
||||
행4(레벨1) → DB level=0, 스택: [행4] → parent_detail_id = null
|
||||
행5(레벨2) → DB level=1, 스택: [행4, 행5] → parent_detail_id = 행4.id
|
||||
행6(레벨1) → DB level=0, 스택: [행6] → parent_detail_id = null
|
||||
```
|
||||
|
||||
## 테스트 계획
|
||||
|
||||
### 1단계: 백엔드 API
|
||||
- [x] 테스트 1: 품번으로 item_info 일괄 조회 (존재하는 품번)
|
||||
- [x] 테스트 2: 존재하지 않는 품번 에러 처리
|
||||
- [x] 테스트 3: 플랫 데이터 → 트리 구조 변환 (parent_detail_id 계산)
|
||||
- [x] 테스트 4: bom_detail INSERT (version_id 포함)
|
||||
- [x] 테스트 5: 기존 디테일 처리 (추가 모드 vs 전체교체 모드)
|
||||
|
||||
### 2단계: 프론트엔드 모달
|
||||
- [x] 테스트 6: 엑셀 파일 파싱 및 미리보기
|
||||
- [x] 테스트 7: 품번 매핑 결과 표시 (성공/실패)
|
||||
- [x] 테스트 8: 업로드 실행 및 결과 표시
|
||||
|
||||
### 3단계: 통합
|
||||
- [x] 테스트 9: BomTreeComponent에 엑셀 업로드 버튼 추가
|
||||
- [x] 테스트 10: 업로드 후 트리 자동 새로고침
|
||||
|
||||
## 구현 파일 목록
|
||||
|
||||
### 백엔드
|
||||
1. `backend-node/src/services/bomService.ts` - `uploadBomExcel()` 함수 추가
|
||||
2. `backend-node/src/controllers/bomController.ts` - `uploadBomExcel` 핸들러 추가
|
||||
3. `backend-node/src/routes/bomRoutes.ts` - `POST /:bomId/excel-upload` 라우트 추가
|
||||
|
||||
### 프론트엔드
|
||||
4. `frontend/lib/registry/components/v2-bom-tree/BomExcelUploadModal.tsx` - 전용 모달 신규
|
||||
5. `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` - 업로드 버튼 추가
|
||||
|
||||
## 진행 상태
|
||||
- 완료된 테스트는 [x]로 표시
|
||||
- 현재 진행 중인 테스트는 [진행중]으로 표시
|
||||
|
|
@ -0,0 +1,427 @@
|
|||
# 공정 작업기준 컴포넌트 (v2-process-work-standard) 구현 계획
|
||||
|
||||
> **작성일**: 2026-02-24
|
||||
> **컴포넌트 ID**: `v2-process-work-standard`
|
||||
> **성격**: 도메인 특화 컴포넌트 (v2-rack-structure와 동일 패턴)
|
||||
|
||||
---
|
||||
|
||||
## 1. 현황 분석
|
||||
|
||||
### 1.1 기존 DB 테이블 (참조용, 이미 존재)
|
||||
|
||||
| 테이블 | 역할 | 핵심 컬럼 |
|
||||
|--------|------|----------|
|
||||
| `item_info` | 품목 마스터 | id, item_name, item_number, company_code |
|
||||
| `item_routing_version` | 라우팅 버전 | id, item_code, version_name, company_code |
|
||||
| `item_routing_detail` | 라우팅 상세 (공정 배정) | id, routing_version_id, seq_no, process_code, company_code |
|
||||
| `process_mng` | 공정 마스터 | id, process_code, process_name, company_code |
|
||||
|
||||
### 1.2 신규 생성 필요 테이블
|
||||
|
||||
**`process_work_item`** - 작업 항목 (검사 장비 준비, 외관 검사 등)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR PK | UUID |
|
||||
| company_code | VARCHAR NOT NULL | 멀티테넌시 |
|
||||
| routing_detail_id | VARCHAR NOT NULL | item_routing_detail.id FK |
|
||||
| work_phase | VARCHAR NOT NULL | Config의 phases[].key 값 (예: 'PRE', 'IN', 'POST' 또는 사용자 정의) |
|
||||
| title | VARCHAR NOT NULL | 항목 제목 (예: 검사 장비 준비) |
|
||||
| is_required | VARCHAR | 'Y' / 'N' |
|
||||
| sort_order | INTEGER | 표시 순서 |
|
||||
| description | TEXT | 비고/설명 |
|
||||
| created_date | TIMESTAMP | 생성일 |
|
||||
| updated_date | TIMESTAMP | 수정일 |
|
||||
| writer | VARCHAR | 작성자 |
|
||||
|
||||
**`process_work_item_detail`** - 작업 항목 상세 (버니어 캘리퍼스 상태 소정 등)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | VARCHAR PK | UUID |
|
||||
| company_code | VARCHAR NOT NULL | 멀티테넌시 |
|
||||
| work_item_id | VARCHAR NOT NULL | process_work_item.id FK |
|
||||
| detail_type | VARCHAR | 'CHECK' / 'INSPECTION' / 'MEASUREMENT' 등 |
|
||||
| content | VARCHAR NOT NULL | 상세 내용 |
|
||||
| is_required | VARCHAR | 'Y' / 'N' |
|
||||
| sort_order | INTEGER | 표시 순서 |
|
||||
| remark | TEXT | 비고 |
|
||||
| created_date | TIMESTAMP | 생성일 |
|
||||
| updated_date | TIMESTAMP | 수정일 |
|
||||
| writer | VARCHAR | 작성자 |
|
||||
|
||||
### 1.3 데이터 흐름 (5단계 연쇄)
|
||||
|
||||
```
|
||||
item_info (품목)
|
||||
└─→ item_routing_version (라우팅 버전)
|
||||
└─→ item_routing_detail (공정 배정) ← JOIN → process_mng (공정명)
|
||||
└─→ process_work_item (작업 항목, phase별)
|
||||
└─→ process_work_item_detail (상세)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 파일 구조 계획
|
||||
|
||||
### 2.1 프론트엔드 (컴포넌트 등록)
|
||||
|
||||
```
|
||||
frontend/lib/registry/components/v2-process-work-standard/
|
||||
├── index.ts # createComponentDefinition
|
||||
├── types.ts # 타입 정의
|
||||
├── config.ts # 기본 설정
|
||||
├── ProcessWorkStandardRenderer.tsx # AutoRegisteringComponentRenderer
|
||||
├── ProcessWorkStandardConfigPanel.tsx # 설정 패널
|
||||
├── ProcessWorkStandardComponent.tsx # 메인 UI (좌우 분할)
|
||||
├── components/
|
||||
│ ├── ItemProcessSelector.tsx # 좌측: 품목/라우팅/공정 아코디언 트리
|
||||
│ ├── WorkStandardEditor.tsx # 우측: 작업기준 편집 영역 전체
|
||||
│ ├── WorkPhaseSection.tsx # Pre/In/Post 섹션 (3회 재사용)
|
||||
│ ├── WorkItemCard.tsx # 작업 항목 카드
|
||||
│ ├── WorkItemDetailList.tsx # 상세 리스트
|
||||
│ └── WorkItemAddModal.tsx # 작업 항목 추가/수정 모달
|
||||
├── hooks/
|
||||
│ ├── useProcessWorkStandard.ts # 전체 데이터 관리 훅
|
||||
│ ├── useItemProcessTree.ts # 좌측 트리 데이터 훅
|
||||
│ └── useWorkItems.ts # 작업 항목 CRUD 훅
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### 2.2 백엔드 (API)
|
||||
|
||||
```
|
||||
backend-node/src/
|
||||
├── routes/processWorkStandardRoutes.ts # 라우트 정의
|
||||
└── controllers/processWorkStandardController.ts # 컨트롤러
|
||||
```
|
||||
|
||||
### 2.3 DB 마이그레이션
|
||||
|
||||
```
|
||||
db/migrations/XXX_create_process_work_standard_tables.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. API 설계
|
||||
|
||||
| Method | Endpoint | 설명 |
|
||||
|--------|----------|------|
|
||||
| GET | `/api/process-work-standard/items` | 품목 목록 (라우팅 있는 품목만) |
|
||||
| GET | `/api/process-work-standard/items/:itemCode/routings` | 품목별 라우팅 버전 + 공정 목록 |
|
||||
| GET | `/api/process-work-standard/routing-detail/:routingDetailId/work-items` | 공정별 작업 항목 목록 (phase별 그룹) |
|
||||
| POST | `/api/process-work-standard/work-items` | 작업 항목 추가 |
|
||||
| PUT | `/api/process-work-standard/work-items/:id` | 작업 항목 수정 |
|
||||
| DELETE | `/api/process-work-standard/work-items/:id` | 작업 항목 삭제 |
|
||||
| GET | `/api/process-work-standard/work-items/:workItemId/details` | 작업 항목 상세 목록 |
|
||||
| POST | `/api/process-work-standard/work-item-details` | 상세 추가 |
|
||||
| PUT | `/api/process-work-standard/work-item-details/:id` | 상세 수정 |
|
||||
| DELETE | `/api/process-work-standard/work-item-details/:id` | 상세 삭제 |
|
||||
| PUT | `/api/process-work-standard/save-all` | 전체 저장 (작업 항목 + 상세 일괄) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 구현 단계 (TDD 기반)
|
||||
|
||||
### Phase 1: DB + API 기반
|
||||
|
||||
- [ ] 1-1. 마이그레이션 SQL 작성 (process_work_item, process_work_item_detail)
|
||||
- [ ] 1-2. 마이그레이션 실행 및 테이블 생성 확인
|
||||
- [ ] 1-3. 백엔드 라우트/컨트롤러 작성 (CRUD API)
|
||||
- [ ] 1-4. API 테스트 (품목 목록, 라우팅 조회, 작업항목 CRUD)
|
||||
|
||||
### Phase 2: 컴포넌트 기본 구조
|
||||
|
||||
- [ ] 2-1. types.ts, config.ts, index.ts 작성 (컴포넌트 정의)
|
||||
- [ ] 2-2. Renderer, ConfigPanel 작성 (V2 시스템 등록)
|
||||
- [ ] 2-3. components/index.ts에 import 추가
|
||||
- [ ] 2-4. getComponentConfigPanel.tsx에 매핑 추가
|
||||
- [ ] 2-5. 화면 디자이너에서 컴포넌트 배치 가능 확인
|
||||
|
||||
### Phase 3: 좌측 패널 (품목/공정 선택)
|
||||
|
||||
- [ ] 3-1. useItemProcessTree 훅 구현 (품목 목록 + 라우팅 조회)
|
||||
- [ ] 3-2. ItemProcessSelector 컴포넌트 (아코디언 + 공정 리스트)
|
||||
- [ ] 3-3. 검색 기능 (품목명/공정명 검색)
|
||||
- [ ] 3-4. 선택 상태 관리 + 우측 패널 연동
|
||||
|
||||
### Phase 4: 우측 패널 (작업기준 편집)
|
||||
|
||||
- [ ] 4-1. WorkStandardEditor 기본 레이아웃 (Pre/In/Post 3단 섹션)
|
||||
- [ ] 4-2. useWorkItems 훅 (작업 항목 + 상세 CRUD)
|
||||
- [ ] 4-3. WorkPhaseSection 컴포넌트 (섹션 헤더 + 카드 영역 + 상세 영역)
|
||||
- [ ] 4-4. WorkItemCard 컴포넌트 (카드 UI + 카운트 배지)
|
||||
- [ ] 4-5. WorkItemDetailList 컴포넌트 (상세 목록 + 인라인 편집)
|
||||
- [ ] 4-6. WorkItemAddModal (작업 항목 추가/수정 모달 + 상세 추가)
|
||||
|
||||
### Phase 5: 통합 + 전체 저장
|
||||
|
||||
- [ ] 5-1. 전체 저장 기능 (변경사항 일괄 저장 API 연동)
|
||||
- [ ] 5-2. 공정 선택 시 데이터 로딩/전환 처리
|
||||
- [ ] 5-3. Empty State 처리 (데이터 없을 때 안내 UI)
|
||||
- [ ] 5-4. 로딩/에러 상태 처리
|
||||
|
||||
### Phase 6: 마무리
|
||||
|
||||
- [ ] 6-1. 멀티테넌시 검증 (company_code 필터링)
|
||||
- [ ] 6-2. 반응형 디자인 점검
|
||||
- [ ] 6-3. README.md 작성
|
||||
|
||||
---
|
||||
|
||||
## 5. 핵심 UI 설계
|
||||
|
||||
### 5.1 전체 레이아웃
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ v2-process-work-standard │
|
||||
├────────────────────┬────────────────────────────────────────────────┤
|
||||
│ 품목 및 공정 선택 │ [품목명] - [공정명] [전체 저장] │
|
||||
│ │ │
|
||||
│ [검색 입력] │ ── 작업 전 (Pre-Work) N개 항목 ── [+항목추가] │
|
||||
│ │ ┌────────┐ ┌─────────────────────────────┐ │
|
||||
│ ▼ 볼트 M8x20 │ │카드 │ │ 상세 리스트 (선택 시 표시) │ │
|
||||
│ ★ 기본 라우팅 │ │ │ │ │ │
|
||||
│ ◉ 재단 │ └────────┘ └─────────────────────────────┘ │
|
||||
│ ◉ 검사 ← 선택 │ │
|
||||
│ ★ 버전2 │ ── 작업 중 (In-Work) N개 항목 ── [+항목추가] │
|
||||
│ │ ┌────────┐ ┌────────┐ │
|
||||
│ ▶ 기어 50T │ │카드1 │ │카드2 │ (상세: 우측 표시) │
|
||||
│ ▶ 샤프트 D30 │ └────────┘ └────────┘ │
|
||||
│ │ │
|
||||
│ │ ── 작업 후 (Post-Work) N개 항목 ── [+항목추가] │
|
||||
│ │ (동일 구조) │
|
||||
├────────────────────┴────────────────────────────────────────────────┤
|
||||
│ 30% │ 70% │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.2 WorkPhaseSection 내부 구조
|
||||
|
||||
```
|
||||
── 작업 전 (Pre-Work) 4개 항목 ────────────────── [+ 작업항목 추가]
|
||||
┌──────────────────────────────┬──────────────────────────────────────┐
|
||||
│ 작업 항목 카드 목록 │ 선택된 항목 상세 │
|
||||
│ │ │
|
||||
│ ┌──────────────────────┐ │ [항목 제목] [+ 상세추가]│
|
||||
│ │ ≡ 검사 장비 준비 ✏️ 🗑 │ │ ─────────────────────────────────── │
|
||||
│ │ 4개 필수 │ │ 순서│유형 │내용 │필수│관리│
|
||||
│ └──────────────────────┘ │ 1 │체크 │버니어 캘리퍼스... │필수│✏️🗑│
|
||||
│ │ 2 │체크 │마이크로미터... │선택│✏️🗑│
|
||||
│ ┌──────────────────────┐ │ 3 │체크 │검사대 청소 │선택│✏️🗑│
|
||||
│ │ ≡ 측정 도구 확인 ✏️ 🗑 │ │ 4 │체크 │검사 기록지 준비 │필수│✏️🗑│
|
||||
│ │ 2개 선택 │ │ │
|
||||
│ └──────────────────────┘ │ │
|
||||
└──────────────────────────────┴──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 5.3 작업 항목 추가 모달
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 작업 항목 추가 ✕ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ 기본 정보 │
|
||||
│ │
|
||||
│ 항목 제목 * 필수 여부 │
|
||||
│ [ ] [필수 ▼] │
|
||||
│ │
|
||||
│ 비고 │
|
||||
│ [ ] │
|
||||
│ │
|
||||
│ 상세 항목 [+ 상세 추가] │
|
||||
│ ┌───┬──────┬──────────────┬────┬────┐ │
|
||||
│ │순서│유형 │내용 │필수│관리│ │
|
||||
│ ├───┼──────┼──────────────┼────┼────┤ │
|
||||
│ │ 1 │체크 │ │필수│ 🗑 │ │
|
||||
│ └───┴──────┴──────────────┴────┴────┘ │
|
||||
│ │
|
||||
│ [취소] [저장] │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 컴포넌트 Config 설계
|
||||
|
||||
### 6.1 설정 패널 UI 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ 공정 작업기준 설정 │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ── 데이터 소스 설정 ────────────────────────── │
|
||||
│ │
|
||||
│ 품목 테이블 │
|
||||
│ [item_info ▼] │
|
||||
│ 품목명 컬럼 품목코드 컬럼 │
|
||||
│ [item_name ▼] [item_number ▼] │
|
||||
│ │
|
||||
│ 라우팅 버전 테이블 │
|
||||
│ [item_routing_version ▼] │
|
||||
│ 품목 연결 컬럼 (FK) │
|
||||
│ [item_code ▼] │
|
||||
│ │
|
||||
│ 라우팅 상세 테이블 │
|
||||
│ [item_routing_detail ▼] │
|
||||
│ │
|
||||
│ 공정 마스터 테이블 │
|
||||
│ [process_mng ▼] │
|
||||
│ │
|
||||
│ ── 작업 단계 설정 ────────────────────────── │
|
||||
│ │
|
||||
│ ┌────┬────────────────────┬─────────────┬───┐ │
|
||||
│ │순서│ 단계 키(DB저장용) │ 표시 이름 │관리│ │
|
||||
│ ├────┼────────────────────┼─────────────┼───┤ │
|
||||
│ │ 1 │ PRE │ 작업 전 │ 🗑 │ │
|
||||
│ │ 2 │ IN │ 작업 중 │ 🗑 │ │
|
||||
│ │ 3 │ POST │ 작업 후 │ 🗑 │ │
|
||||
│ └────┴────────────────────┴─────────────┴───┘ │
|
||||
│ [+ 단계 추가] │
|
||||
│ │
|
||||
│ ── 상세 유형 옵션 ────────────────────────── │
|
||||
│ │
|
||||
│ ┌────────────────────┬─────────────┬───┐ │
|
||||
│ │ 유형 값(DB저장용) │ 표시 이름 │관리│ │
|
||||
│ ├────────────────────┼─────────────┼───┤ │
|
||||
│ │ CHECK │ 체크 │ 🗑 │ │
|
||||
│ │ INSPECTION │ 검사 │ 🗑 │ │
|
||||
│ │ MEASUREMENT │ 측정 │ 🗑 │ │
|
||||
│ └────────────────────┴─────────────┴───┘ │
|
||||
│ [+ 유형 추가] │
|
||||
│ │
|
||||
│ ── UI 설정 ────────────────────────── │
|
||||
│ │
|
||||
│ 좌우 분할 비율 │
|
||||
│ [30 ] % │
|
||||
│ │
|
||||
│ 좌측 패널 제목 │
|
||||
│ [품목 및 공정 선택 ] │
|
||||
│ │
|
||||
│ 읽기 전용 모드 │
|
||||
│ [ ] 활성화 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 Config 타입 정의
|
||||
|
||||
```typescript
|
||||
// 작업 단계 정의 (사용자가 추가/삭제/이름변경 가능)
|
||||
interface WorkPhaseDefinition {
|
||||
key: string; // DB 저장용 키 (예: "PRE", "IN", "POST", "QC")
|
||||
label: string; // 화면 표시명 (예: "작업 전 (Pre-Work)")
|
||||
sortOrder: number; // 표시 순서
|
||||
}
|
||||
|
||||
// 상세 유형 정의 (사용자가 추가/삭제 가능)
|
||||
interface DetailTypeDefinition {
|
||||
value: string; // DB 저장용 값 (예: "CHECK")
|
||||
label: string; // 화면 표시명 (예: "체크")
|
||||
}
|
||||
|
||||
// 데이터 소스 설정 (사용자가 테이블 지정 가능)
|
||||
interface DataSourceConfig {
|
||||
// 품목 테이블
|
||||
itemTable: string; // 기본: "item_info"
|
||||
itemNameColumn: string; // 기본: "item_name"
|
||||
itemCodeColumn: string; // 기본: "item_number"
|
||||
|
||||
// 라우팅 버전 테이블
|
||||
routingVersionTable: string; // 기본: "item_routing_version"
|
||||
routingItemFkColumn: string; // 기본: "item_code" (품목과 연결하는 FK)
|
||||
routingVersionNameColumn: string; // 기본: "version_name"
|
||||
|
||||
// 라우팅 상세 테이블
|
||||
routingDetailTable: string; // 기본: "item_routing_detail"
|
||||
|
||||
// 공정 마스터 테이블
|
||||
processTable: string; // 기본: "process_mng"
|
||||
processNameColumn: string; // 기본: "process_name"
|
||||
processCodeColumn: string; // 기본: "process_code"
|
||||
}
|
||||
|
||||
// 전체 Config
|
||||
interface ProcessWorkStandardConfig {
|
||||
// 데이터 소스 설정
|
||||
dataSource: DataSourceConfig;
|
||||
|
||||
// 작업 단계 정의 (기본 3개, 사용자가 추가/삭제/수정 가능)
|
||||
phases: WorkPhaseDefinition[];
|
||||
// 기본값: [
|
||||
// { key: "PRE", label: "작업 전 (Pre-Work)", sortOrder: 1 },
|
||||
// { key: "IN", label: "작업 중 (In-Work)", sortOrder: 2 },
|
||||
// { key: "POST", label: "작업 후 (Post-Work)", sortOrder: 3 },
|
||||
// ]
|
||||
|
||||
// 상세 유형 옵션 (사용자가 추가/삭제 가능)
|
||||
detailTypes: DetailTypeDefinition[];
|
||||
// 기본값: [
|
||||
// { value: "CHECK", label: "체크" },
|
||||
// { value: "INSPECTION", label: "검사" },
|
||||
// { value: "MEASUREMENT", label: "측정" },
|
||||
// ]
|
||||
|
||||
// UI 설정
|
||||
splitRatio?: number; // 좌우 분할 비율, 기본: 30
|
||||
leftPanelTitle?: string; // 좌측 패널 제목, 기본: "품목 및 공정 선택"
|
||||
readonly?: boolean; // 읽기 전용 모드, 기본: false
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 커스터마이징 시나리오 예시
|
||||
|
||||
**시나리오 A: 제조업 (기본)**
|
||||
```
|
||||
단계: 작업 전 → 작업 중 → 작업 후
|
||||
유형: 체크, 검사, 측정
|
||||
```
|
||||
|
||||
**시나리오 B: 품질검사 강화 회사**
|
||||
```
|
||||
단계: 준비 → 검사 → 판정 → 기록 → 보관
|
||||
유형: 육안검사, 치수검사, 강도검사, 내구검사, 기능검사
|
||||
```
|
||||
|
||||
**시나리오 C: 단순 2단계 회사**
|
||||
```
|
||||
단계: 사전점검 → 사후점검
|
||||
유형: 확인, 기록
|
||||
```
|
||||
|
||||
**시나리오 D: 다른 테이블 사용 회사**
|
||||
```
|
||||
품목 테이블: product_master (item_info 대신)
|
||||
공정 테이블: operation_mng (process_mng 대신)
|
||||
```
|
||||
|
||||
### 6.4 DB 설계 반영 사항
|
||||
|
||||
`work_phase` 컬럼은 고정 ENUM이 아니라 **사용자 정의 키(VARCHAR)** 로 저장합니다.
|
||||
- Config에서 `phases[].key` 로 정의한 값이 DB에 저장됨
|
||||
- 예: "PRE", "IN", "POST" 또는 "PREPARE", "INSPECT", "JUDGE", "RECORD", "STORE"
|
||||
- 회사별 Config에 따라 다른 값이 저장되므로, 조회 시 Config의 phases 정의를 기준으로 섹션을 렌더링
|
||||
|
||||
---
|
||||
|
||||
## 7. 등록 체크리스트
|
||||
|
||||
| 항목 | 파일 | 작업 |
|
||||
|------|------|------|
|
||||
| 컴포넌트 정의 | `v2-process-work-standard/index.ts` | createComponentDefinition |
|
||||
| 렌더러 등록 | `v2-process-work-standard/...Renderer.tsx` | registerSelf() |
|
||||
| 컴포넌트 로드 | `components/index.ts` | import 추가 |
|
||||
| 설정 패널 매핑 | `getComponentConfigPanel.tsx` | CONFIG_PANEL_MAP 추가 |
|
||||
| 라우트 등록 | `backend-node/src/app.ts` | router.use() 추가 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 의존성
|
||||
|
||||
- 외부 라이브러리 추가: 없음 (기존 shadcn/ui + Lucide 아이콘만 사용)
|
||||
- 기존 API 재사용: dataRoutes의 범용 CRUD는 사용하지 않고 전용 API 개발
|
||||
- 이유: 5단계 JOIN + phase별 그룹핑 등 범용 API로는 처리 불가
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
> **목적**: 다양한 회사에서 V2 컴포넌트를 활용하여 화면을 개발할 때 참고하는 범용 가이드
|
||||
> **대상**: 화면 설계자, 개발자
|
||||
> **버전**: 1.0.0
|
||||
> **작성일**: 2026-01-30
|
||||
> **버전**: 1.1.0
|
||||
> **작성일**: 2026-02-23 (최종 업데이트)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -19,60 +19,63 @@
|
|||
| 카드 뷰 | 이미지+정보 카드 형태 | 설비정보, 대시보드 |
|
||||
| 피벗 분석 | 다차원 집계 | 매출분석, 재고현황 |
|
||||
| 반복 컨테이너 | 데이터 수만큼 UI 반복 | 주문 상세, 항목 리스트 |
|
||||
| 그룹화 테이블 | 그룹핑 기능 포함 테이블 | 카테고리별 집계, 부서별 현황 |
|
||||
| 타임라인/스케줄 | 시간축 기반 일정 관리 | 생산일정, 작업스케줄 |
|
||||
|
||||
### 1.2 불가능한 화면 유형 (별도 개발 필요)
|
||||
|
||||
| 화면 유형 | 이유 | 해결 방안 |
|
||||
|-----------|------|----------|
|
||||
| 간트 차트 / 타임라인 | 시간축 기반 UI 없음 | 별도 컴포넌트 개발 or 외부 라이브러리 |
|
||||
| 트리 뷰 (계층 구조) | 트리 컴포넌트 미존재 | `v2-tree-view` 개발 필요 |
|
||||
| 그룹화 테이블 | 그룹핑 기능 미지원 | `v2-grouped-table` 개발 필요 |
|
||||
| 드래그앤드롭 보드 | 칸반 스타일 UI 없음 | 별도 개발 |
|
||||
| 모바일 앱 스타일 | 네이티브 앱 UI | 별도 개발 |
|
||||
| 복잡한 차트 | 기본 집계 외 시각화 | 차트 라이브러리 연동 |
|
||||
|
||||
> **참고**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)는 v1.1에서 추가되어 이제 지원됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. V2 컴포넌트 전체 목록 (23개)
|
||||
## 2. V2 컴포넌트 전체 목록 (25개)
|
||||
|
||||
### 2.1 입력 컴포넌트 (3개)
|
||||
### 2.1 입력 컴포넌트 (4개)
|
||||
|
||||
| ID | 이름 | 용도 | 주요 옵션 |
|
||||
|----|------|------|----------|
|
||||
| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 이메일, 전화번호, URL, 여러 줄 | inputType, required, readonly, maxLength |
|
||||
| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크박스 | mode, source(distinct/static/code/entity), multiple |
|
||||
| `v2-date` | 날짜 | 날짜, 시간, 날짜시간, 날짜범위, 월, 연도 | dateType, format, showTime |
|
||||
| `v2-input` | 입력 | 텍스트, 숫자, 비밀번호, 슬라이더, 컬러 | inputType(text/number/password/slider/color/button), format(email/tel/url/currency/biz_no), required, readonly, maxLength, min, max, step |
|
||||
| `v2-select` | 선택 | 드롭다운, 콤보박스, 라디오, 체크, 태그, 토글, 스왑 | mode(dropdown/combobox/radio/check/tag/tagbox/toggle/swap), source(static/code/db/api/entity/category/distinct/select), searchable, multiple, cascading |
|
||||
| `v2-date` | 날짜 | 날짜, 시간, 날짜시간 | dateType(date/time/datetime), format, range, minDate, maxDate, showToday |
|
||||
| `v2-file-upload` | 파일 업로드 | 파일/이미지 업로드 | - |
|
||||
|
||||
### 2.2 표시 컴포넌트 (3개)
|
||||
|
||||
| ID | 이름 | 용도 | 주요 옵션 |
|
||||
|----|------|------|----------|
|
||||
| `v2-text-display` | 텍스트 표시 | 라벨, 제목, 설명 텍스트 | fontSize, fontWeight, color, textAlign |
|
||||
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, showImage, columnMapping |
|
||||
| `v2-card-display` | 카드 디스플레이 | 테이블 데이터를 카드 형태로 표시 | cardsPerRow, cardSpacing, columnMapping(titleColumn/subtitleColumn/descriptionColumn/imageColumn), cardStyle(imagePosition/imageSize), dataSource(table/static/api) |
|
||||
| `v2-aggregation-widget` | 집계 위젯 | 합계, 평균, 개수, 최대, 최소 | items, filters, layout |
|
||||
|
||||
### 2.3 테이블/데이터 컴포넌트 (3개)
|
||||
### 2.3 테이블/데이터 컴포넌트 (4개)
|
||||
|
||||
| ID | 이름 | 용도 | 주요 옵션 |
|
||||
|----|------|------|----------|
|
||||
| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter |
|
||||
| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector |
|
||||
| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields, totals, aggregation |
|
||||
| `v2-table-list` | 테이블 리스트 | 데이터 조회/편집 테이블 | selectedTable, columns, pagination, filter, displayMode(table/card), checkbox, horizontalScroll, linkedFilters, excludeFilter, toolbar, tableStyle, autoLoad |
|
||||
| `v2-table-search-widget` | 검색 필터 | 테이블 검색/필터/그룹 | autoSelectFirstTable, showTableSelector, title |
|
||||
| `v2-pivot-grid` | 피벗 그리드 | 다차원 분석 (행/열/데이터 영역) | fields(area: row/column/data/filter, summaryType: sum/avg/count/min/max/countDistinct, groupInterval: year/quarter/month/week/day), dataSource(type: table/api/static, joinConfigs, filterConditions) |
|
||||
| `v2-table-grouped` | 그룹화 테이블 | 그룹핑 기능이 포함된 테이블 | - |
|
||||
|
||||
### 2.4 레이아웃 컴포넌트 (8개)
|
||||
### 2.4 레이아웃 컴포넌트 (7개)
|
||||
|
||||
| ID | 이름 | 용도 | 주요 옵션 |
|
||||
|----|------|------|----------|
|
||||
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, relation, **displayMode: custom** |
|
||||
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs, activeTabId |
|
||||
| `v2-split-panel-layout` | 분할 패널 | 마스터-디테일 좌우 분할 | splitRatio, resizable, minLeftWidth, minRightWidth, syncSelection, panel별: displayMode(list/table/custom), relation(type/foreignKey), editButton, addButton, deleteButton, additionalTabs |
|
||||
| `v2-tabs-widget` | 탭 위젯 | 탭 전환, 탭 내 컴포넌트 | tabs(id/label/order/disabled/components), defaultTab, orientation(horizontal/vertical), allowCloseable, persistSelection |
|
||||
| `v2-section-card` | 섹션 카드 | 제목+테두리 그룹화 | title, collapsible, padding |
|
||||
| `v2-section-paper` | 섹션 페이퍼 | 배경색 그룹화 | backgroundColor, padding, shadow |
|
||||
| `v2-divider-line` | 구분선 | 영역 구분 | orientation, thickness |
|
||||
| `v2-repeat-container` | 리피터 컨테이너 | 데이터 수만큼 반복 렌더링 | dataSourceType, layout, gridColumns |
|
||||
| `v2-repeater` | 리피터 | 반복 컨트롤 | - |
|
||||
| `v2-repeat-screen-modal` | 반복 화면 모달 | 모달 반복 | - |
|
||||
| `v2-repeater` | 리피터 | 반복 컨트롤 (inline/modal) | - |
|
||||
|
||||
### 2.5 액션/특수 컴포넌트 (6개)
|
||||
### 2.5 액션/특수 컴포넌트 (7개)
|
||||
|
||||
| ID | 이름 | 용도 | 주요 옵션 |
|
||||
|----|------|------|----------|
|
||||
|
|
@ -82,6 +85,7 @@
|
|||
| `v2-location-swap-selector` | 위치 교환 | 위치 선택/교환 | - |
|
||||
| `v2-rack-structure` | 랙 구조 | 창고 랙 시각화 | - |
|
||||
| `v2-media` | 미디어 | 이미지/동영상 표시 | - |
|
||||
| `v2-timeline-scheduler` | 타임라인 스케줄러 | 시간축 기반 일정/작업 관리 | - |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -261,8 +265,26 @@
|
|||
],
|
||||
pagination: {
|
||||
enabled: true,
|
||||
pageSize: 20
|
||||
}
|
||||
pageSize: 20,
|
||||
showSizeSelector: true,
|
||||
showPageInfo: true
|
||||
},
|
||||
displayMode: "table", // "table" | "card"
|
||||
checkbox: {
|
||||
enabled: true,
|
||||
multiple: true,
|
||||
position: "left",
|
||||
selectAll: true
|
||||
},
|
||||
horizontalScroll: { // 가로 스크롤 설정
|
||||
enabled: true,
|
||||
maxVisibleColumns: 8
|
||||
},
|
||||
linkedFilters: [], // 연결 필터 (다른 컴포넌트와 연동)
|
||||
excludeFilter: {}, // 제외 필터
|
||||
autoLoad: true, // 자동 데이터 로드
|
||||
stickyHeader: false, // 헤더 고정
|
||||
autoWidth: true // 자동 너비 조정
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -271,16 +293,44 @@
|
|||
```typescript
|
||||
{
|
||||
leftPanel: {
|
||||
tableName: "마스터_테이블명"
|
||||
displayMode: "table", // "list" | "table" | "custom"
|
||||
tableName: "마스터_테이블명",
|
||||
columns: [], // 컬럼 설정
|
||||
editButton: { // 수정 버튼 설정
|
||||
enabled: true,
|
||||
mode: "auto", // "auto" | "modal"
|
||||
modalScreenId: "" // 모달 모드 시 화면 ID
|
||||
},
|
||||
addButton: { // 추가 버튼 설정
|
||||
enabled: true,
|
||||
mode: "auto",
|
||||
modalScreenId: ""
|
||||
},
|
||||
deleteButton: { // 삭제 버튼 설정
|
||||
enabled: true,
|
||||
buttonLabel: "삭제",
|
||||
confirmMessage: "삭제하시겠습니까?"
|
||||
},
|
||||
addModalColumns: [], // 추가 모달 전용 컬럼
|
||||
additionalTabs: [] // 추가 탭 설정
|
||||
},
|
||||
rightPanel: {
|
||||
displayMode: "table",
|
||||
tableName: "디테일_테이블명",
|
||||
relation: {
|
||||
type: "detail", // join | detail | custom
|
||||
foreignKey: "master_id" // 연결 키
|
||||
type: "detail", // "join" | "detail" | "custom"
|
||||
foreignKey: "master_id", // 연결 키
|
||||
leftColumn: "", // 좌측 연결 컬럼
|
||||
rightColumn: "", // 우측 연결 컬럼
|
||||
keys: [] // 복합 키
|
||||
}
|
||||
},
|
||||
splitRatio: 30 // 좌측 비율
|
||||
splitRatio: 30, // 좌측 비율 (0-100)
|
||||
resizable: true, // 리사이즈 가능
|
||||
minLeftWidth: 200, // 좌측 최소 너비
|
||||
minRightWidth: 300, // 우측 최소 너비
|
||||
syncSelection: true, // 선택 동기화
|
||||
autoLoad: true // 자동 로드
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -347,12 +397,12 @@
|
|||
| 기능 | 상태 | 대안 |
|
||||
|------|------|------|
|
||||
| 트리 뷰 (BOM, 조직도) | ❌ 미지원 | 테이블로 대체 or 별도 개발 |
|
||||
| 그룹화 테이블 | ❌ 미지원 | 일반 테이블로 대체 or 별도 개발 |
|
||||
| 간트 차트 | ❌ 미지원 | 별도 개발 필요 |
|
||||
| 드래그앤드롭 정렬 | ❌ 미지원 | 순서 컬럼으로 대체 |
|
||||
| 인라인 편집 | ⚠️ 제한적 | 모달 편집으로 대체 |
|
||||
| 복잡한 차트 | ❌ 미지원 | 외부 라이브러리 연동 |
|
||||
|
||||
> **v1.1 업데이트**: 그룹화 테이블(`v2-table-grouped`)과 타임라인 스케줄러(`v2-timeline-scheduler`)가 추가되어 해당 기능은 이제 지원됩니다.
|
||||
|
||||
### 5.2 권장하지 않는 조합
|
||||
|
||||
| 조합 | 이유 |
|
||||
|
|
@ -555,9 +605,10 @@
|
|||
| 탭 화면 | ✅ 완전 | v2-tabs-widget |
|
||||
| 카드 뷰 | ✅ 완전 | v2-card-display |
|
||||
| 피벗 분석 | ✅ 완전 | v2-pivot-grid |
|
||||
| 그룹화 테이블 | ❌ 미지원 | 개발 필요 |
|
||||
| 그룹화 테이블 | ✅ 지원 | v2-table-grouped |
|
||||
| 타임라인/스케줄 | ✅ 지원 | v2-timeline-scheduler |
|
||||
| 파일 업로드 | ✅ 지원 | v2-file-upload |
|
||||
| 트리 뷰 | ❌ 미지원 | 개발 필요 |
|
||||
| 간트 차트 | ❌ 미지원 | 개발 필요 |
|
||||
|
||||
### 개발 시 핵심 원칙
|
||||
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ childItemsSection: {
|
|||
"config": {
|
||||
"masterPanel": {
|
||||
"title": "BOM 목록",
|
||||
"entityId": "bom_header",
|
||||
"entityId": "bom",
|
||||
"columns": [
|
||||
{ "id": "item_code", "label": "품목코드", "width": 100 },
|
||||
{ "id": "item_name", "label": "품목명", "width": 150 },
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -62,6 +63,7 @@ interface ColumnTypeInfo {
|
|||
detailSettings: string;
|
||||
description: string;
|
||||
isNullable: string;
|
||||
isUnique: string;
|
||||
defaultValue?: string;
|
||||
maxLength?: number;
|
||||
numericPrecision?: number;
|
||||
|
|
@ -71,9 +73,10 @@ interface ColumnTypeInfo {
|
|||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열
|
||||
hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할
|
||||
numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID
|
||||
categoryMenus?: number[];
|
||||
hierarchyRole?: "large" | "medium" | "small";
|
||||
numberingRuleId?: string;
|
||||
categoryRef?: string | null;
|
||||
}
|
||||
|
||||
interface SecondLevelMenu {
|
||||
|
|
@ -140,11 +143,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());
|
||||
|
||||
|
|
@ -370,10 +384,12 @@ export default function TableManagementPage() {
|
|||
|
||||
return {
|
||||
...col,
|
||||
inputType: col.inputType || "text", // 기본값: text
|
||||
numberingRuleId, // 🆕 채번규칙 ID
|
||||
categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보
|
||||
hierarchyRole, // 계층구조 역할
|
||||
inputType: col.inputType || "text",
|
||||
isUnique: col.isUnique || "NO",
|
||||
numberingRuleId,
|
||||
categoryMenus: col.categoryMenus || [],
|
||||
hierarchyRole,
|
||||
categoryRef: col.categoryRef || null,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -397,6 +413,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 +439,9 @@ export default function TableManagementPage() {
|
|||
setTableDescription(tableInfo?.description || "");
|
||||
|
||||
loadColumnTypes(tableName, 1, pageSize);
|
||||
loadConstraints(tableName);
|
||||
},
|
||||
[loadColumnTypes, pageSize, tables],
|
||||
[loadColumnTypes, loadConstraints, pageSize, tables],
|
||||
);
|
||||
|
||||
// 입력 타입 변경
|
||||
|
|
@ -642,15 +672,16 @@ export default function TableManagementPage() {
|
|||
}
|
||||
|
||||
const columnSetting = {
|
||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
||||
columnName: column.columnName,
|
||||
columnLabel: column.displayName,
|
||||
inputType: column.inputType || "text",
|
||||
detailSettings: finalDetailSettings,
|
||||
codeCategory: column.codeCategory || "",
|
||||
codeValue: column.codeValue || "",
|
||||
referenceTable: column.referenceTable || "",
|
||||
referenceColumn: column.referenceColumn || "",
|
||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
displayColumn: column.displayColumn || "",
|
||||
categoryRef: column.categoryRef || null,
|
||||
};
|
||||
|
||||
// console.log("저장할 컬럼 설정:", columnSetting);
|
||||
|
|
@ -677,9 +708,9 @@ export default function TableManagementPage() {
|
|||
length: column.categoryMenus?.length || 0,
|
||||
});
|
||||
|
||||
if (column.inputType === "category") {
|
||||
// 1. 먼저 기존 매핑 모두 삭제
|
||||
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", {
|
||||
if (column.inputType === "category" && !column.categoryRef) {
|
||||
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리
|
||||
console.log("기존 카테고리 메뉴 매핑 삭제 시작:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.columnName,
|
||||
});
|
||||
|
|
@ -757,7 +788,9 @@ export default function TableManagementPage() {
|
|||
// 전체 저장 (테이블 라벨 + 모든 컬럼 설정)
|
||||
const saveAllSettings = async () => {
|
||||
if (!selectedTable) return;
|
||||
if (isSaving) return; // 저장 중 중복 실행 방지
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// 1. 테이블 라벨 저장 (변경된 경우에만)
|
||||
if (tableLabel !== selectedTable || tableDescription) {
|
||||
|
|
@ -836,8 +869,8 @@ export default function TableManagementPage() {
|
|||
}
|
||||
|
||||
return {
|
||||
columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
|
||||
columnLabel: column.displayName, // 사용자가 입력한 표시명
|
||||
columnName: column.columnName,
|
||||
columnLabel: column.displayName,
|
||||
inputType: column.inputType || "text",
|
||||
detailSettings: finalDetailSettings,
|
||||
description: column.description || "",
|
||||
|
|
@ -845,7 +878,8 @@ export default function TableManagementPage() {
|
|||
codeValue: column.codeValue || "",
|
||||
referenceTable: column.referenceTable || "",
|
||||
referenceColumn: column.referenceColumn || "",
|
||||
displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
|
||||
displayColumn: column.displayColumn || "",
|
||||
categoryRef: column.categoryRef || null,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -858,8 +892,8 @@ export default function TableManagementPage() {
|
|||
);
|
||||
|
||||
if (response.data.success) {
|
||||
// 🆕 Category 타입 컬럼들의 메뉴 매핑 처리
|
||||
const categoryColumns = columns.filter((col) => col.inputType === "category");
|
||||
// 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외)
|
||||
const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef);
|
||||
|
||||
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
|
||||
totalColumns: columns.length,
|
||||
|
|
@ -952,9 +986,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 +1055,150 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
|
||||
const handleIndexToggle = useCallback(
|
||||
async (columnName: string, indexType: "index", 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,
|
||||
);
|
||||
return { isPk, hasIndex };
|
||||
},
|
||||
[constraints],
|
||||
);
|
||||
|
||||
// UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴)
|
||||
const handleUniqueToggle = useCallback(
|
||||
async (columnName: string, currentIsUnique: string) => {
|
||||
if (!selectedTable) return;
|
||||
const isCurrentlyUnique = currentIsUnique === "YES";
|
||||
const newUnique = !isCurrentlyUnique;
|
||||
try {
|
||||
const response = await apiClient.put(
|
||||
`/table-management/tables/${selectedTable}/columns/${columnName}/unique`,
|
||||
{ unique: newUnique },
|
||||
);
|
||||
if (response.data.success) {
|
||||
toast.success(response.data.message);
|
||||
setColumns((prev) =>
|
||||
prev.map((col) =>
|
||||
col.columnName === columnName
|
||||
? { ...col, isUnique: newUnique ? "YES" : "NO" }
|
||||
: col,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
toast.error(response.data.message || "UNIQUE 설정 실패");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다.");
|
||||
}
|
||||
},
|
||||
[selectedTable],
|
||||
);
|
||||
|
||||
// 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 +1566,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 +1594,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 +1617,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 +1633,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">
|
||||
{/* 입력 타입 선택 */}
|
||||
|
|
@ -1486,7 +1695,30 @@ export default function TableManagementPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */}
|
||||
{/* 카테고리 타입: 참조 설정 */}
|
||||
{column.inputType === "category" && (
|
||||
<div className="w-56">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">카테고리 참조 (선택)</label>
|
||||
<Input
|
||||
value={column.categoryRef || ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value || null;
|
||||
setColumns((prev) =>
|
||||
prev.map((c) =>
|
||||
c.columnName === column.columnName
|
||||
? { ...c, categoryRef: val }
|
||||
: c
|
||||
)
|
||||
);
|
||||
}}
|
||||
placeholder="테이블명.컬럼명"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
다른 테이블의 카테고리 값 참조 시 입력
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||
{column.inputType === "entity" && (
|
||||
<>
|
||||
|
|
@ -1689,141 +1921,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 +2055,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={column.isUnique === "YES"}
|
||||
onCheckedChange={() =>
|
||||
handleUniqueToggle(column.columnName, column.isUnique)
|
||||
}
|
||||
aria-label={`${column.columnName} 유니크 설정`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 로딩 표시 */}
|
||||
{columnsLoading && (
|
||||
|
|
@ -2120,6 +2263,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>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* /screen/{screenCode} → /screens/{screenId} 리다이렉트
|
||||
* 메뉴 URL이 screenCode 기반이므로, screenId로 변환 후 이동
|
||||
*/
|
||||
export default function ScreenCodeRedirectPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const screenCode = params.screenCode as string;
|
||||
|
||||
useEffect(() => {
|
||||
if (!screenCode) return;
|
||||
|
||||
const numericId = parseInt(screenCode);
|
||||
if (!isNaN(numericId)) {
|
||||
router.replace(`/screens/${numericId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolve = async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/screen-management/screens", {
|
||||
params: { searchTerm: screenCode, size: 50 },
|
||||
});
|
||||
const items = res.data?.data?.data || res.data?.data || [];
|
||||
const arr = Array.isArray(items) ? items : [];
|
||||
const exact = arr.find((s: any) => s.screenCode === screenCode);
|
||||
const target = exact || arr[0];
|
||||
if (target) {
|
||||
router.replace(`/screens/${target.screenId || target.screen_id}`);
|
||||
} else {
|
||||
router.replace("/");
|
||||
}
|
||||
} catch {
|
||||
router.replace("/");
|
||||
}
|
||||
};
|
||||
resolve();
|
||||
}, [screenCode, router]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Loader2 } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen";
|
||||
import { LayerDefinition } from "@/types/screen-management";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
|
|
@ -86,6 +87,13 @@ 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[]>([]);
|
||||
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
|
||||
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
|
||||
|
||||
// 편집 모달 상태
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editModalConfig, setEditModalConfig] = useState<{
|
||||
|
|
@ -171,7 +179,25 @@ function ScreenViewPage() {
|
|||
} else {
|
||||
// V1 레이아웃 또는 빈 레이아웃
|
||||
const layoutData = await screenApi.getLayout(screenId);
|
||||
setLayout(layoutData);
|
||||
if (layoutData?.components?.length > 0) {
|
||||
setLayout(layoutData);
|
||||
} else {
|
||||
console.warn("[ScreenViewPage] getLayout 실패, getLayerLayout(1) fallback:", screenId);
|
||||
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
|
||||
if (baseLayerData && isValidV2Layout(baseLayerData)) {
|
||||
const converted = convertV2ToLegacy(baseLayerData);
|
||||
if (converted) {
|
||||
setLayout({
|
||||
...converted,
|
||||
screenResolution: baseLayerData.screenResolution || converted.screenResolution,
|
||||
} as LayoutData);
|
||||
} else {
|
||||
setLayout(layoutData);
|
||||
}
|
||||
} else {
|
||||
setLayout(layoutData);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (layoutError) {
|
||||
console.warn("레이아웃 로드 실패, 빈 레이아웃 사용:", layoutError);
|
||||
|
|
@ -204,8 +230,219 @@ 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 강제 활성화된 레이어 ID 병합
|
||||
for (const forcedId of forceActivatedLayerIds) {
|
||||
if (!newActiveIds.includes(forcedId)) {
|
||||
newActiveIds.push(forcedId);
|
||||
}
|
||||
}
|
||||
|
||||
return newActiveIds;
|
||||
}, [formData, conditionalLayers, layout, forceActivatedLayerIds]);
|
||||
|
||||
// 데이터 전달에 의한 레이어 강제 활성화 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleActivateLayer = (e: Event) => {
|
||||
const { componentId, targetLayerId } = (e as CustomEvent).detail || {};
|
||||
if (!componentId && !targetLayerId) return;
|
||||
|
||||
// targetLayerId가 직접 지정된 경우
|
||||
if (targetLayerId) {
|
||||
setForceActivatedLayerIds((prev) =>
|
||||
prev.includes(targetLayerId) ? prev : [...prev, targetLayerId],
|
||||
);
|
||||
console.log(`🔓 [레이어 강제 활성화] layerId: ${targetLayerId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// componentId로 해당 컴포넌트가 속한 레이어를 찾아 활성화
|
||||
for (const layer of conditionalLayers) {
|
||||
const found = layer.components.some((comp) => comp.id === componentId);
|
||||
if (found) {
|
||||
setForceActivatedLayerIds((prev) =>
|
||||
prev.includes(layer.id) ? prev : [...prev, layer.id],
|
||||
);
|
||||
console.log(`🔓 [레이어 강제 활성화] componentId: ${componentId} → layerId: ${layer.id}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("activateLayerForComponent", handleActivateLayer);
|
||||
return () => {
|
||||
window.removeEventListener("activateLayerForComponent", handleActivateLayer);
|
||||
};
|
||||
}, [conditionalLayers]);
|
||||
|
||||
// 메인 테이블 데이터 자동 로드 (단일 레코드 폼)
|
||||
useEffect(() => {
|
||||
const loadMainTableData = async () => {
|
||||
if (!screen || !layout || !layout.components || !companyCode) {
|
||||
|
|
@ -513,6 +750,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 +868,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 +1206,81 @@ function ScreenViewPage() {
|
|||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 🆕 조건부 레이어 컴포넌트 렌더링 (Zone 기반) */}
|
||||
{conditionalLayers.map((layer) => {
|
||||
const isActive = activeLayerIds.includes(layer.id);
|
||||
if (!isActive || !layer.components || layer.components.length === 0) return null;
|
||||
|
||||
// Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정
|
||||
const zone = layer.zoneId ? zones.find(z => z.zone_id === layer.zoneId) : null;
|
||||
const region = zone
|
||||
? { x: zone.x, y: zone.y, width: zone.width, height: zone.height }
|
||||
: layer.displayRegion;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`conditional-layer-${layer.id}`}
|
||||
data-conditional-layer="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: region ? `${region.x}px` : "0px",
|
||||
top: region ? `${region.y}px` : "0px",
|
||||
width: region ? `${region.width}px` : "100%",
|
||||
height: region ? `${region.height}px` : "auto",
|
||||
zIndex: layer.zIndex || 20,
|
||||
overflow: "hidden",
|
||||
transition: "none",
|
||||
}}
|
||||
>
|
||||
{layer.components
|
||||
.filter((comp) => !comp.parentId)
|
||||
.map((comp) => (
|
||||
<RealtimePreview
|
||||
key={comp.id}
|
||||
component={comp}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
menuObjid={menuObjid}
|
||||
screenId={screenId}
|
||||
tableName={screen?.tableName}
|
||||
userId={user?.userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
selectedRowsData={selectedRowsData}
|
||||
sortBy={tableSortBy}
|
||||
sortOrder={tableSortOrder}
|
||||
columnOrder={tableColumnOrder}
|
||||
tableDisplayData={tableDisplayData}
|
||||
onSelectedRowsChange={(
|
||||
_,
|
||||
selectedData,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
columnOrder,
|
||||
tableDisplayData,
|
||||
) => {
|
||||
setSelectedRowsData(selectedData);
|
||||
setTableSortBy(sortBy);
|
||||
setTableSortOrder(sortOrder || "asc");
|
||||
setTableColumnOrder(columnOrder);
|
||||
setTableDisplayData(tableDisplayData || []);
|
||||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]);
|
||||
}}
|
||||
formData={formData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldName]: value }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
|
|
@ -263,12 +263,20 @@ input,
|
|||
textarea,
|
||||
select {
|
||||
transition-property:
|
||||
color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter,
|
||||
color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, filter,
|
||||
backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
/* 런타임 화면에서 컴포넌트 위치 변경 시 모든 애니메이션/트랜지션 완전 제거 */
|
||||
[data-screen-runtime] [id^="component-"] {
|
||||
transition: none !important;
|
||||
}
|
||||
[data-screen-runtime] [data-conditional-layer] {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Disable animations for users who prefer reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
|
|
@ -281,6 +289,20 @@ select {
|
|||
}
|
||||
}
|
||||
|
||||
/* ===== Sonner 토스트 애니메이션 완전 제거 ===== */
|
||||
[data-sonner-toaster] [data-sonner-toast] {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
[data-sonner-toaster] [data-sonner-toast][data-mounted="true"] {
|
||||
animation: none !important;
|
||||
}
|
||||
[data-sonner-toaster] [data-sonner-toast][data-removed="true"] {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
/* ===== Print Styles ===== */
|
||||
@media print {
|
||||
* {
|
||||
|
|
|
|||
|
|
@ -145,13 +145,12 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
|||
const isFormValid = useMemo(() => {
|
||||
// 수정 모드에서는 비밀번호 선택 사항 (변경할 경우만 입력)
|
||||
const requiredFields = isEditMode
|
||||
? [formData.userId.trim(), formData.userName.trim(), formData.companyCode, formData.deptCode]
|
||||
? [formData.userId.trim(), formData.userName.trim(), formData.companyCode]
|
||||
: [
|
||||
formData.userId.trim(),
|
||||
formData.userPassword.trim(),
|
||||
formData.userName.trim(),
|
||||
formData.companyCode,
|
||||
formData.deptCode,
|
||||
];
|
||||
|
||||
// 모든 필수 필드가 입력되었는지 확인
|
||||
|
|
@ -327,11 +326,6 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
|||
return false;
|
||||
}
|
||||
|
||||
if (!formData.deptCode) {
|
||||
showAlert("입력 오류", "부서를 선택해주세요.", "error");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 이메일 형식 검사 (입력된 경우만)
|
||||
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
showAlert("입력 오류", "올바른 이메일 형식을 입력해주세요.", "error");
|
||||
|
|
@ -581,7 +575,7 @@ export function UserFormModal({ isOpen, onClose, onSuccess, editingUser }: UserF
|
|||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="deptCode" className="text-sm font-medium">
|
||||
부서 <span className="text-red-500">*</span>
|
||||
부서
|
||||
</Label>
|
||||
<Select value={formData.deptCode} onValueChange={(value) => handleInputChange("deptCode", value)}>
|
||||
<SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, ReactNode, useState } from "react";
|
||||
import { useEffect, ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { AuthLogger } from "@/lib/authLogger";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode;
|
||||
|
|
@ -15,6 +17,8 @@ interface AuthGuardProps {
|
|||
/**
|
||||
* 인증 보호 컴포넌트
|
||||
* 로그인 상태 및 권한에 따라 접근을 제어
|
||||
* - 토큰 갱신/401 처리는 client.ts 인터셉터가 담당
|
||||
* - 이 컴포넌트는 인증 상태 기반 라우팅 가드 역할만 수행
|
||||
*/
|
||||
export function AuthGuard({
|
||||
children,
|
||||
|
|
@ -23,145 +27,69 @@ export function AuthGuard({
|
|||
redirectTo = "/login",
|
||||
fallback,
|
||||
}: AuthGuardProps) {
|
||||
const { isLoggedIn, isAdmin, loading, error } = useAuth();
|
||||
const { isLoggedIn, isAdmin, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
const [redirectCountdown, setRedirectCountdown] = useState<number | null>(null);
|
||||
const [authDebugInfo, setAuthDebugInfo] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
console.log("=== AuthGuard 디버깅 ===");
|
||||
console.log("requireAuth:", requireAuth);
|
||||
console.log("requireAdmin:", requireAdmin);
|
||||
console.log("loading:", loading);
|
||||
console.log("isLoggedIn:", isLoggedIn);
|
||||
console.log("isAdmin:", isAdmin);
|
||||
console.log("error:", error);
|
||||
if (loading) return;
|
||||
|
||||
// 토큰 확인을 더 정확하게
|
||||
const token = localStorage.getItem("authToken");
|
||||
console.log("AuthGuard localStorage 토큰:", token ? "존재" : "없음");
|
||||
console.log("현재 경로:", window.location.pathname);
|
||||
|
||||
// 디버깅 정보 수집
|
||||
setAuthDebugInfo({
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
loading,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
error,
|
||||
hasToken: !!token,
|
||||
currentPath: window.location.pathname,
|
||||
timestamp: new Date().toISOString(),
|
||||
tokenLength: token ? token.length : 0,
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
console.log("AuthGuard: 로딩 중 - 대기");
|
||||
return;
|
||||
// 토큰이 있는데 아직 인증 확인 중이면 대기
|
||||
if (typeof window !== "undefined") {
|
||||
const token = localStorage.getItem("authToken");
|
||||
if (token && !isLoggedIn && !loading) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰이 있는데도 인증이 안 된 경우, 잠시 대기
|
||||
if (token && !isLoggedIn && !loading) {
|
||||
console.log("AuthGuard: 토큰은 있지만 인증이 안됨 - 잠시 대기");
|
||||
return;
|
||||
}
|
||||
|
||||
// 인증이 필요한데 로그인되지 않은 경우
|
||||
if (requireAuth && !isLoggedIn) {
|
||||
console.log("AuthGuard: 인증 필요하지만 로그인되지 않음 - 5초 후 리다이렉트");
|
||||
console.log("리다이렉트 대상:", redirectTo);
|
||||
|
||||
setRedirectCountdown(5);
|
||||
const countdownInterval = setInterval(() => {
|
||||
setRedirectCountdown((prev) => {
|
||||
if (prev === null || prev <= 1) {
|
||||
clearInterval(countdownInterval);
|
||||
router.push(redirectTo);
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`);
|
||||
router.push(redirectTo);
|
||||
return;
|
||||
}
|
||||
|
||||
// 관리자 권한이 필요한데 관리자가 아닌 경우
|
||||
if (requireAdmin && !isAdmin) {
|
||||
console.log("AuthGuard: 관리자 권한 필요하지만 관리자가 아님 - 5초 후 리다이렉트");
|
||||
console.log("리다이렉트 대상:", redirectTo);
|
||||
|
||||
setRedirectCountdown(5);
|
||||
const countdownInterval = setInterval(() => {
|
||||
setRedirectCountdown((prev) => {
|
||||
if (prev === null || prev <= 1) {
|
||||
clearInterval(countdownInterval);
|
||||
router.push(redirectTo);
|
||||
return null;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`);
|
||||
router.push(redirectTo);
|
||||
return;
|
||||
}
|
||||
}, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, redirectTo, router]);
|
||||
|
||||
console.log("AuthGuard: 모든 인증 조건 통과 - 컴포넌트 렌더링");
|
||||
}, [requireAuth, requireAdmin, loading, isLoggedIn, isAdmin, error, redirectTo, router]);
|
||||
|
||||
// 로딩 중일 때 fallback 또는 기본 로딩 표시
|
||||
if (loading) {
|
||||
console.log("AuthGuard: 로딩 중 - fallback 표시");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 rounded bg-primary/20 p-4">
|
||||
<h3 className="font-bold">AuthGuard 로딩 중...</h3>
|
||||
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
|
||||
fallback || (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
{fallback || <div>로딩 중...</div>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 인증 실패 시 fallback 또는 기본 메시지 표시
|
||||
if (requireAuth && !isLoggedIn) {
|
||||
console.log("AuthGuard: 인증 실패 - fallback 표시");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 rounded bg-destructive/20 p-4">
|
||||
<h3 className="font-bold">인증 실패</h3>
|
||||
{redirectCountdown !== null && (
|
||||
<div className="mb-2 text-destructive">
|
||||
<strong>리다이렉트 카운트다운:</strong> {redirectCountdown}초 후 {redirectTo}로 이동
|
||||
</div>
|
||||
)}
|
||||
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
|
||||
fallback || (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">인증 확인 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
{fallback || <div>인증이 필요합니다.</div>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (requireAdmin && !isAdmin) {
|
||||
console.log("AuthGuard: 관리자 권한 없음 - fallback 표시");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 rounded bg-orange-100 p-4">
|
||||
<h3 className="font-bold">관리자 권한 없음</h3>
|
||||
{redirectCountdown !== null && (
|
||||
<div className="mb-2 text-destructive">
|
||||
<strong>리다이렉트 카운트다운:</strong> {redirectCountdown}초 후 {redirectTo}로 이동
|
||||
</div>
|
||||
)}
|
||||
<pre className="text-xs">{JSON.stringify(authDebugInfo, null, 2)}</pre>
|
||||
fallback || (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">관리자 권한이 필요합니다.</p>
|
||||
</div>
|
||||
{fallback || <div>관리자 권한이 필요합니다.</div>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
console.log("AuthGuard: 인증 성공 - 자식 컴포넌트 렌더링");
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -777,7 +903,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
for (const row of filteredData) {
|
||||
for (let rowIdx = 0; rowIdx < filteredData.length; rowIdx++) {
|
||||
const row = filteredData[rowIdx];
|
||||
try {
|
||||
let dataToSave = { ...row };
|
||||
let shouldSkip = false;
|
||||
|
|
@ -799,15 +926,16 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
|
||||
if (existingDataMap.has(key)) {
|
||||
existingRow = existingDataMap.get(key);
|
||||
// 중복 발견 - 전역 설정에 따라 처리
|
||||
if (duplicateAction === "skip") {
|
||||
shouldSkip = true;
|
||||
skipCount++;
|
||||
console.log(`⏭️ 중복으로 건너뛰기: ${key}`);
|
||||
console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`);
|
||||
} else {
|
||||
shouldUpdate = true;
|
||||
console.log(`🔄 중복으로 덮어쓰기: ${key}`);
|
||||
console.log(`🔄 [행 ${rowIdx + 1}] 중복으로 덮어쓰기: ${key}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`✅ [행 ${rowIdx + 1}] 중복 아님 (신규 데이터): ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -816,17 +944,22 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
continue;
|
||||
}
|
||||
|
||||
// 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만)
|
||||
if (hasNumbering && uploadMode === "insert" && !shouldUpdate) {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
const numberingResponse = await apiClient.post(`/numbering-rules/${numberingRuleId}/allocate`);
|
||||
const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code;
|
||||
if (numberingResponse.data?.success && generatedCode) {
|
||||
dataToSave[numberingTargetColumn] = generatedCode;
|
||||
// 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용
|
||||
if (hasNumbering && numberingInfo && (uploadMode === "insert" || uploadMode === "upsert") && !shouldUpdate) {
|
||||
const existingValue = dataToSave[numberingInfo.columnName];
|
||||
const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== "";
|
||||
|
||||
if (!hasExcelValue) {
|
||||
try {
|
||||
const { apiClient } = await import("@/lib/api/client");
|
||||
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[numberingInfo.columnName] = generatedCode;
|
||||
}
|
||||
} catch (numError) {
|
||||
console.error("채번 오류:", numError);
|
||||
}
|
||||
} catch (numError) {
|
||||
console.error("채번 오류:", numError);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -837,24 +970,34 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
tableName,
|
||||
data: dataToSave,
|
||||
};
|
||||
console.log(`📝 [행 ${rowIdx + 1}] 덮어쓰기 시도: id=${existingRow.id}`, dataToSave);
|
||||
const result = await DynamicFormApi.updateFormData(existingRow.id, formData);
|
||||
if (result.success) {
|
||||
overwriteCount++;
|
||||
successCount++;
|
||||
} else {
|
||||
console.error(`❌ [행 ${rowIdx + 1}] 덮어쓰기 실패:`, result.message);
|
||||
failCount++;
|
||||
}
|
||||
} else if (uploadMode === "insert") {
|
||||
// 신규 등록
|
||||
} else if (uploadMode === "insert" || uploadMode === "upsert") {
|
||||
// 신규 등록 (insert, upsert 모드)
|
||||
const formData = { screenId: 0, tableName, data: dataToSave };
|
||||
console.log(`📝 [행 ${rowIdx + 1}] 신규 등록 시도 (mode: ${uploadMode}):`, dataToSave);
|
||||
const result = await DynamicFormApi.saveFormData(formData);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
console.log(`✅ [행 ${rowIdx + 1}] 신규 등록 성공`);
|
||||
} else {
|
||||
console.error(`❌ [행 ${rowIdx + 1}] 신규 등록 실패:`, result.message);
|
||||
failCount++;
|
||||
}
|
||||
} else if (uploadMode === "update") {
|
||||
// update 모드에서 기존 데이터가 없는 행은 건너뛰기
|
||||
console.log(`⏭️ [행 ${rowIdx + 1}] update 모드: 기존 데이터 없음, 건너뛰기`);
|
||||
skipCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error(`❌ [행 ${rowIdx + 1}] 업로드 처리 오류:`, error?.response?.data || error?.message || error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
|
@ -877,8 +1020,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
console.log(`📊 엑셀 업로드 결과 요약: 성공=${successCount}, 건너뛰기=${skipCount}, 덮어쓰기=${overwriteCount}, 실패=${failCount}`);
|
||||
|
||||
if (successCount > 0 || skipCount > 0) {
|
||||
// 상세 결과 메시지 생성
|
||||
let message = "";
|
||||
if (successCount > 0) {
|
||||
message += `${successCount}개 행 업로드`;
|
||||
|
|
@ -891,15 +1035,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
message += `중복 건너뛰기 ${skipCount}개`;
|
||||
}
|
||||
if (failCount > 0) {
|
||||
message += ` (실패: ${failCount}개)`;
|
||||
message += `, 실패 ${failCount}개`;
|
||||
}
|
||||
|
||||
toast.success(message);
|
||||
if (failCount > 0 && successCount === 0) {
|
||||
toast.warning(message);
|
||||
} else {
|
||||
toast.success(message);
|
||||
}
|
||||
|
||||
// 매핑 템플릿 저장
|
||||
await saveMappingTemplateInternal();
|
||||
|
||||
onSuccess?.();
|
||||
if (successCount > 0 || overwriteCount > 0) {
|
||||
onSuccess?.();
|
||||
}
|
||||
} else if (failCount > 0) {
|
||||
toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`);
|
||||
} else {
|
||||
toast.error("업로드에 실패했습니다.");
|
||||
}
|
||||
|
|
@ -1341,15 +1493,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 +1527,38 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미매핑 필수(NOT NULL) 컬럼 경고 */}
|
||||
{(() => {
|
||||
const mappedCols = new Set<string>();
|
||||
columnMappings.filter((m) => m.systemColumn).forEach((m) => {
|
||||
const n = m.systemColumn!;
|
||||
mappedCols.add(n);
|
||||
if (n.includes(".")) mappedCols.add(n.split(".")[1]);
|
||||
});
|
||||
const missing = systemColumns.filter((col) => {
|
||||
const rawName = col.name.includes(".") ? col.name.split(".")[1] : col.name;
|
||||
if (AUTO_GENERATED_COLUMNS.includes(rawName.toLowerCase())) return false;
|
||||
if (col.nullable) return false;
|
||||
if (mappedCols.has(col.name) || mappedCols.has(rawName)) return false;
|
||||
if ((col as any).isNumbering) return false;
|
||||
return true;
|
||||
});
|
||||
if (missing.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 text-destructive" />
|
||||
<div className="text-[10px] text-destructive sm:text-xs">
|
||||
<p className="font-medium">필수(NOT NULL) 컬럼이 매핑되지 않았습니다:</p>
|
||||
<p className="mt-1">
|
||||
{missing.map((c) => c.label || c.name).join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 중복 체크 안내 */}
|
||||
{duplicateCheckCount > 0 ? (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||
|
|
@ -14,6 +24,8 @@ 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";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
|
||||
interface ScreenModalState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -61,12 +73,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 +130,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 +140,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), // 여백 없이 컨텐츠 시작점 기준
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -158,12 +179,23 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
splitPanelParentData,
|
||||
selectedData: eventSelectedData,
|
||||
selectedIds,
|
||||
isCreateMode, // 🆕 복사 모드 플래그 (true면 editData가 있어도 originalData 설정 안 함)
|
||||
isCreateMode,
|
||||
fieldMappings,
|
||||
} = event.detail;
|
||||
|
||||
console.log("🟣 [ScreenModal] openScreenModal 이벤트 수신:", {
|
||||
screenId,
|
||||
splitPanelParentData: JSON.stringify(splitPanelParentData),
|
||||
editData: !!editData,
|
||||
isCreateMode,
|
||||
});
|
||||
|
||||
// 🆕 모달 열린 시간 기록
|
||||
modalOpenedAtRef.current = Date.now();
|
||||
|
||||
// 폼 변경 추적 초기화
|
||||
formDataChangedRef.current = false;
|
||||
|
||||
// 🆕 선택된 데이터 저장 (RepeatScreenModal 등에서 사용)
|
||||
if (eventSelectedData && Array.isArray(eventSelectedData)) {
|
||||
setSelectedData(eventSelectedData);
|
||||
|
|
@ -218,10 +250,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 +286,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 +305,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 +332,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;
|
||||
|
|
@ -265,8 +363,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
if (Object.keys(parentData).length > 0) {
|
||||
console.log("🔵 [ScreenModal] ADD모드 formData 설정:", JSON.stringify(parentData));
|
||||
setFormData(parentData);
|
||||
} else {
|
||||
console.log("🔵 [ScreenModal] ADD모드 formData 비어있음");
|
||||
setFormData({});
|
||||
}
|
||||
setOriginalData(null); // 신규 등록 모드
|
||||
|
|
@ -317,6 +417,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
if (isContinuousMode) {
|
||||
// 연속 모드: 폼만 초기화하고 모달은 유지
|
||||
formDataChangedRef.current = false;
|
||||
setFormData({});
|
||||
setResetKey((prev) => prev + 1);
|
||||
|
||||
|
|
@ -463,6 +564,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 화면 관리에서 설정한 해상도 사용 (우선순위)
|
||||
const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution;
|
||||
|
||||
console.log("🔍 [ScreenModal] 해상도 디버그:", {
|
||||
screenId,
|
||||
v2ScreenResolution: v2LayoutData?.screenResolution,
|
||||
layoutScreenResolution: (layoutData as any).screenResolution,
|
||||
screenInfoResolution: (screenInfo as any).screenResolution,
|
||||
finalScreenResolution: screenResolution,
|
||||
hasWidth: screenResolution?.width,
|
||||
hasHeight: screenResolution?.height,
|
||||
});
|
||||
|
||||
let dimensions;
|
||||
if (screenResolution && screenResolution.width && screenResolution.height) {
|
||||
// 화면 관리에서 설정한 해상도 사용
|
||||
|
|
@ -472,9 +583,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
console.log("✅ [ScreenModal] 화면관리 해상도 적용:", dimensions);
|
||||
} else {
|
||||
// 해상도 정보가 없으면 자동 계산
|
||||
dimensions = calculateScreenDimensions(components);
|
||||
console.log("⚠️ [ScreenModal] 해상도 없음 - 자동 계산:", dimensions);
|
||||
}
|
||||
|
||||
setScreenDimensions(dimensions);
|
||||
|
|
@ -483,6 +596,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
components,
|
||||
screenInfo: screenInfo,
|
||||
});
|
||||
|
||||
// 🆕 조건부 레이어/존 로드
|
||||
loadConditionalLayersAndZones(screenId);
|
||||
} else {
|
||||
throw new Error("화면 데이터가 없습니다");
|
||||
}
|
||||
|
|
@ -495,14 +611,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 +878,43 @@ 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) {
|
||||
console.log("⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용");
|
||||
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;
|
||||
const finalWidth = Math.min(screenDimensions.width, window.innerWidth * 0.98);
|
||||
console.log("✅ [ScreenModal] getModalStyle: 해상도 적용됨", {
|
||||
screenDimensions,
|
||||
finalWidth: `${finalWidth}px`,
|
||||
viewportWidth: window.innerWidth,
|
||||
});
|
||||
|
||||
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: `${finalWidth}px`,
|
||||
// CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한
|
||||
maxHeight: "calc(100dvh - 8px)",
|
||||
maxWidth: "98vw",
|
||||
padding: 0,
|
||||
gap: 0,
|
||||
},
|
||||
needsScroll,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -615,10 +980,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 +1016,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">
|
||||
|
|
@ -643,14 +1026,33 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</div>
|
||||
) : screenData ? (
|
||||
<ScreenContextProvider
|
||||
screenId={modalState.screenId || undefined}
|
||||
tableName={screenData.screenInfo?.tableName}
|
||||
>
|
||||
<ActiveTabProvider>
|
||||
<TableOptionsProvider>
|
||||
<div
|
||||
data-screen-runtime="true"
|
||||
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,11 +1188,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
formData={formData}
|
||||
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
formDataChangedRef.current = true;
|
||||
setFormData((prev) => {
|
||||
const newFormData = {
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
};
|
||||
console.log("🟡 [ScreenModal] onFormDataChange:", fieldName, "→", value, "| formData keys:", Object.keys(newFormData), "| process_code:", newFormData.process_code);
|
||||
return newFormData;
|
||||
});
|
||||
}}
|
||||
|
|
@ -810,9 +1214,52 @@ 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>
|
||||
</ScreenContextProvider>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
||||
|
|
@ -838,6 +1285,36 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* 모달 닫기 확인 다이얼로그 */}
|
||||
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
|
||||
<AlertDialogContent className="max-w-[95vw] sm:max-w-[400px]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-base sm:text-lg">
|
||||
화면을 닫으시겠습니까?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-xs sm:text-sm">
|
||||
지금 나가시면 진행 중인 데이터가 저장되지 않습니다.
|
||||
<br />
|
||||
계속 작업하시려면 '계속 작업' 버튼을 눌러주세요.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="gap-2 sm:gap-0">
|
||||
<AlertDialogCancel
|
||||
onClick={handleCancelClose}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
계속 작업
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmClose}
|
||||
className="h-8 flex-1 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
나가기
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* 플로우 에디터 상단 툴바
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Save, Undo2, Redo2, ZoomIn, ZoomOut, Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -42,6 +42,27 @@ export function FlowToolbar({ validations = [], onSaveComplete }: FlowToolbarPro
|
|||
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// Ctrl+S 단축키: 플로우 저장
|
||||
const handleSaveRef = useRef<() => void>();
|
||||
|
||||
useEffect(() => {
|
||||
handleSaveRef.current = handleSave;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
if (!isSaving) {
|
||||
handleSaveRef.current?.();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isSaving]);
|
||||
|
||||
const handleSave = async () => {
|
||||
// 검증 수행
|
||||
const currentValidations = validations.length > 0 ? validations : validateFlow(nodes, edges);
|
||||
|
|
|
|||
|
|
@ -251,6 +251,14 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
||||
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
|
||||
|
||||
// 타겟 조회 설정 (DB 기존값 비교용)
|
||||
const [targetLookup, setTargetLookup] = useState<{
|
||||
tableName: string;
|
||||
tableLabel?: string;
|
||||
lookupKeys: Array<{ sourceField: string; targetField: string; sourceFieldLabel?: string }>;
|
||||
} | undefined>(data.targetLookup);
|
||||
const [targetLookupColumns, setTargetLookupColumns] = useState<ColumnInfo[]>([]);
|
||||
|
||||
// EXISTS 연산자용 상태
|
||||
const [allTables, setAllTables] = useState<TableInfo[]>([]);
|
||||
const [tableColumnsCache, setTableColumnsCache] = useState<Record<string, ColumnInfo[]>>({});
|
||||
|
|
@ -262,8 +270,20 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
setDisplayName(data.displayName || "조건 분기");
|
||||
setConditions(data.conditions || []);
|
||||
setLogic(data.logic || "AND");
|
||||
setTargetLookup(data.targetLookup);
|
||||
}, [data]);
|
||||
|
||||
// targetLookup 테이블 변경 시 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (targetLookup?.tableName) {
|
||||
loadTableColumns(targetLookup.tableName).then((cols) => {
|
||||
setTargetLookupColumns(cols);
|
||||
});
|
||||
} else {
|
||||
setTargetLookupColumns([]);
|
||||
}
|
||||
}, [targetLookup?.tableName]);
|
||||
|
||||
// 전체 테이블 목록 로드 (EXISTS 연산자용)
|
||||
useEffect(() => {
|
||||
const loadAllTables = async () => {
|
||||
|
|
@ -559,6 +579,47 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
});
|
||||
};
|
||||
|
||||
// 타겟 조회 테이블 변경
|
||||
const handleTargetLookupTableChange = async (tableName: string) => {
|
||||
await ensureTablesLoaded();
|
||||
const tableInfo = allTables.find((t) => t.tableName === tableName);
|
||||
const newLookup = {
|
||||
tableName,
|
||||
tableLabel: tableInfo?.tableLabel || tableName,
|
||||
lookupKeys: targetLookup?.lookupKeys || [],
|
||||
};
|
||||
setTargetLookup(newLookup);
|
||||
updateNode(nodeId, { targetLookup: newLookup });
|
||||
|
||||
// 컬럼 로드
|
||||
const cols = await loadTableColumns(tableName);
|
||||
setTargetLookupColumns(cols);
|
||||
};
|
||||
|
||||
// 타겟 조회 키 필드 변경
|
||||
const handleTargetLookupKeyChange = (sourceField: string, targetField: string) => {
|
||||
if (!targetLookup) return;
|
||||
const sourceFieldInfo = availableFields.find((f) => f.name === sourceField);
|
||||
const newLookup = {
|
||||
...targetLookup,
|
||||
lookupKeys: [{ sourceField, targetField, sourceFieldLabel: sourceFieldInfo?.label || sourceField }],
|
||||
};
|
||||
setTargetLookup(newLookup);
|
||||
updateNode(nodeId, { targetLookup: newLookup });
|
||||
};
|
||||
|
||||
// 타겟 조회 제거
|
||||
const handleRemoveTargetLookup = () => {
|
||||
setTargetLookup(undefined);
|
||||
updateNode(nodeId, { targetLookup: undefined });
|
||||
// target 타입 조건들을 field로 변경
|
||||
const newConditions = conditions.map((c) =>
|
||||
(c as any).valueType === "target" ? { ...c, valueType: "field" } : c
|
||||
);
|
||||
setConditions(newConditions);
|
||||
updateNode(nodeId, { conditions: newConditions });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-4 p-4 pb-8">
|
||||
|
|
@ -597,6 +658,119 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타겟 조회 (DB 기존값 비교) */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">
|
||||
<Database className="mr-1 inline h-3.5 w-3.5" />
|
||||
타겟 조회 (DB 기존값)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{!targetLookup ? (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded border border-dashed p-3 text-center text-xs text-gray-400">
|
||||
DB의 기존값과 비교하려면 타겟 테이블을 설정하세요.
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 w-full text-xs"
|
||||
onClick={async () => {
|
||||
await ensureTablesLoaded();
|
||||
setTargetLookup({ tableName: "", lookupKeys: [] });
|
||||
}}
|
||||
>
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
타겟 조회 설정
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 rounded border bg-orange-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-orange-700">타겟 테이블</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleRemoveTargetLookup}
|
||||
className="h-5 px-1 text-xs text-orange-500 hover:text-orange-700"
|
||||
>
|
||||
제거
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{allTables.length > 0 ? (
|
||||
<TableCombobox
|
||||
tables={allTables}
|
||||
value={targetLookup.tableName}
|
||||
onSelect={handleTargetLookupTableChange}
|
||||
placeholder="비교할 테이블 검색..."
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
테이블 로딩 중...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 키 필드 매핑 */}
|
||||
{targetLookup.tableName && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-orange-600">조회 키 (소스 → 타겟)</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select
|
||||
value={targetLookup.lookupKeys?.[0]?.sourceField || ""}
|
||||
onValueChange={(val) => {
|
||||
const targetField = targetLookup.lookupKeys?.[0]?.targetField || "";
|
||||
handleTargetLookupKeyChange(val, targetField);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue placeholder="소스 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((f) => (
|
||||
<SelectItem key={f.name} value={f.name} className="text-xs">
|
||||
{f.label || f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-gray-400">=</span>
|
||||
{targetLookupColumns.length > 0 ? (
|
||||
<Select
|
||||
value={targetLookup.lookupKeys?.[0]?.targetField || ""}
|
||||
onValueChange={(val) => {
|
||||
const sourceField = targetLookup.lookupKeys?.[0]?.sourceField || "";
|
||||
handleTargetLookupKeyChange(sourceField, val);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue placeholder="타겟 필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetLookupColumns.map((c) => (
|
||||
<SelectItem key={c.columnName} value={c.columnName} className="text-xs">
|
||||
{c.columnLabel || c.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex-1 rounded border border-dashed bg-gray-50 p-1 text-center text-[10px] text-gray-400">
|
||||
컬럼 로딩 중...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded bg-orange-100 p-1.5 text-[10px] text-orange-600">
|
||||
비교 값 타입에서 "타겟 필드 (DB 기존값)"을 선택하면 이 테이블의 기존값과 비교합니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 조건식 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
|
|
@ -738,15 +912,46 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
|||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="field">필드 참조</SelectItem>
|
||||
{targetLookup?.tableName && (
|
||||
<SelectItem value="target">타겟 필드 (DB 기존값)</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">
|
||||
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
|
||||
{(condition as any).valueType === "target"
|
||||
? "타겟 필드 (DB 기존값)"
|
||||
: (condition as any).valueType === "field"
|
||||
? "비교 필드"
|
||||
: "비교 값"}
|
||||
</Label>
|
||||
{(condition as any).valueType === "field" ? (
|
||||
{(condition as any).valueType === "target" ? (
|
||||
// 타겟 필드 (DB 기존값): 타겟 테이블 컬럼에서 선택
|
||||
targetLookupColumns.length > 0 ? (
|
||||
<Select
|
||||
value={condition.value as string}
|
||||
onValueChange={(value) => handleConditionChange(index, "value", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="DB 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetLookupColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
<span className="ml-2 text-xs text-gray-400">({col.dataType})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
타겟 조회를 먼저 설정하세요
|
||||
</div>
|
||||
)
|
||||
) : (condition as any).valueType === "field" ? (
|
||||
// 필드 참조: 드롭다운으로 선택
|
||||
availableFields.length > 0 ? (
|
||||
<Select
|
||||
|
|
|
|||
|
|
@ -449,6 +449,15 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
);
|
||||
};
|
||||
|
||||
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시 (인증 대기 없이 즉시 렌더링)
|
||||
if (isPreviewMode) {
|
||||
return (
|
||||
<div className="h-screen w-full overflow-auto bg-white p-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 사용자 정보가 없으면 로딩 표시
|
||||
if (!user) {
|
||||
return (
|
||||
|
|
@ -461,15 +470,6 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
|||
);
|
||||
}
|
||||
|
||||
// 프리뷰 모드: 사이드바/헤더 없이 화면만 표시
|
||||
if (isPreviewMode) {
|
||||
return (
|
||||
<div className="h-screen w-full overflow-auto bg-white p-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// UI 변환된 메뉴 데이터
|
||||
const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo);
|
||||
|
||||
|
|
|
|||
|
|
@ -685,6 +685,7 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
|
|||
|
||||
return {
|
||||
valueId: selectedId,
|
||||
valueCode: node.valueCode, // valueCode 추가 (V2Select 호환)
|
||||
valueLabel: node.valueLabel,
|
||||
valuePath: pathParts.join(" > "),
|
||||
};
|
||||
|
|
@ -698,6 +699,7 @@ const CategoryConfigPanel: React.FC<CategoryConfigPanelProps> = ({
|
|||
|
||||
const newMapping: CategoryFormatMapping = {
|
||||
categoryValueId: selectedInfo.valueId,
|
||||
categoryValueCode: selectedInfo.valueCode, // V2Select에서 valueCode를 value로 사용하므로 매칭용 저장
|
||||
categoryValueLabel: selectedInfo.valueLabel,
|
||||
categoryValuePath: selectedInfo.valuePath,
|
||||
format: newFormat.trim(),
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const NumberingRuleCard: React.FC<NumberingRuleCardProps> = ({
|
|||
isPreview = false,
|
||||
}) => {
|
||||
return (
|
||||
<Card className="border-border bg-card">
|
||||
<Card className="border-border bg-card flex-1">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="outline" className="text-xs sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -62,9 +62,9 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
const [editingLeftTitle, setEditingLeftTitle] = useState(false);
|
||||
const [editingRightTitle, setEditingRightTitle] = useState(false);
|
||||
|
||||
// 구분자 관련 상태
|
||||
const [separatorType, setSeparatorType] = useState<SeparatorType>("-");
|
||||
const [customSeparator, setCustomSeparator] = useState("");
|
||||
// 구분자 관련 상태 (개별 파트 사이 구분자)
|
||||
const [separatorTypes, setSeparatorTypes] = useState<Record<number, SeparatorType>>({});
|
||||
const [customSeparators, setCustomSeparators] = useState<Record<number, string>>({});
|
||||
|
||||
// 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회
|
||||
interface CategoryOption {
|
||||
|
|
@ -192,48 +192,68 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
}
|
||||
}, [currentRule, onChange]);
|
||||
|
||||
// currentRule이 변경될 때 구분자 상태 동기화
|
||||
// currentRule이 변경될 때 파트별 구분자 상태 동기화
|
||||
useEffect(() => {
|
||||
if (currentRule) {
|
||||
const sep = currentRule.separator ?? "-";
|
||||
// 빈 문자열이면 "none"
|
||||
if (sep === "") {
|
||||
setSeparatorType("none");
|
||||
setCustomSeparator("");
|
||||
return;
|
||||
}
|
||||
// 미리 정의된 구분자인지 확인 (none, custom 제외)
|
||||
const predefinedOption = SEPARATOR_OPTIONS.find(
|
||||
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
|
||||
);
|
||||
if (predefinedOption) {
|
||||
setSeparatorType(predefinedOption.value);
|
||||
setCustomSeparator("");
|
||||
} else {
|
||||
// 직접 입력된 구분자
|
||||
setSeparatorType("custom");
|
||||
setCustomSeparator(sep);
|
||||
}
|
||||
if (currentRule && currentRule.parts.length > 0) {
|
||||
const newSepTypes: Record<number, SeparatorType> = {};
|
||||
const newCustomSeps: Record<number, string> = {};
|
||||
|
||||
currentRule.parts.forEach((part) => {
|
||||
const sep = part.separatorAfter ?? currentRule.separator ?? "-";
|
||||
if (sep === "") {
|
||||
newSepTypes[part.order] = "none";
|
||||
newCustomSeps[part.order] = "";
|
||||
} else {
|
||||
const predefinedOption = SEPARATOR_OPTIONS.find(
|
||||
opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep
|
||||
);
|
||||
if (predefinedOption) {
|
||||
newSepTypes[part.order] = predefinedOption.value;
|
||||
newCustomSeps[part.order] = "";
|
||||
} else {
|
||||
newSepTypes[part.order] = "custom";
|
||||
newCustomSeps[part.order] = sep;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setSeparatorTypes(newSepTypes);
|
||||
setCustomSeparators(newCustomSeps);
|
||||
}
|
||||
}, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시)
|
||||
}, [currentRule?.ruleId]);
|
||||
|
||||
// 구분자 변경 핸들러
|
||||
const handleSeparatorChange = useCallback((type: SeparatorType) => {
|
||||
setSeparatorType(type);
|
||||
// 개별 파트 구분자 변경 핸들러
|
||||
const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => {
|
||||
setSeparatorTypes(prev => ({ ...prev, [partOrder]: type }));
|
||||
if (type !== "custom") {
|
||||
const option = SEPARATOR_OPTIONS.find(opt => opt.value === type);
|
||||
const newSeparator = option?.displayValue ?? "";
|
||||
setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null);
|
||||
setCustomSeparator("");
|
||||
setCustomSeparators(prev => ({ ...prev, [partOrder]: "" }));
|
||||
setCurrentRule((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
parts: prev.parts.map((part) =>
|
||||
part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 직접 입력 구분자 변경 핸들러
|
||||
const handleCustomSeparatorChange = useCallback((value: string) => {
|
||||
// 최대 2자 제한
|
||||
// 개별 파트 직접 입력 구분자 변경 핸들러
|
||||
const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => {
|
||||
const trimmedValue = value.slice(0, 2);
|
||||
setCustomSeparator(trimmedValue);
|
||||
setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null);
|
||||
setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue }));
|
||||
setCurrentRule((prev) => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
parts: prev.parts.map((part) =>
|
||||
part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part
|
||||
),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleAddPart = useCallback(() => {
|
||||
|
|
@ -250,6 +270,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
partType: "text",
|
||||
generationMethod: "auto",
|
||||
autoConfig: { textValue: "CODE" },
|
||||
separatorAfter: "-",
|
||||
};
|
||||
|
||||
setCurrentRule((prev) => {
|
||||
|
|
@ -257,6 +278,10 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
return { ...prev, parts: [...prev.parts, newPart] };
|
||||
});
|
||||
|
||||
// 새 파트의 구분자 상태 초기화
|
||||
setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" }));
|
||||
setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" }));
|
||||
|
||||
toast.success(`규칙 ${newPart.order}가 추가되었습니다`);
|
||||
}, [currentRule, maxRules]);
|
||||
|
||||
|
|
@ -573,42 +598,6 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 두 번째 줄: 구분자 설정 */}
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="w-48 space-y-2">
|
||||
<Label className="text-sm font-medium">구분자</Label>
|
||||
<Select
|
||||
value={separatorType}
|
||||
onValueChange={(value) => handleSeparatorChange(value as SeparatorType)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="구분자 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEPARATOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{separatorType === "custom" && (
|
||||
<div className="w-32 space-y-2">
|
||||
<Label className="text-sm font-medium">직접 입력</Label>
|
||||
<Input
|
||||
value={customSeparator}
|
||||
onChange={(e) => handleCustomSeparatorChange(e.target.value)}
|
||||
className="h-9"
|
||||
placeholder="최대 2자"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-muted-foreground pb-2 text-xs">
|
||||
규칙 사이에 들어갈 문자입니다
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
|
@ -625,15 +614,48 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
<p className="text-muted-foreground text-xs sm:text-sm">규칙을 추가하여 코드를 구성하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
<div className="flex flex-wrap items-stretch gap-3">
|
||||
{currentRule.parts.map((part, index) => (
|
||||
<NumberingRuleCard
|
||||
key={`part-${part.order}-${index}`}
|
||||
part={part}
|
||||
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
||||
onDelete={() => handleDeletePart(part.order)}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
<React.Fragment key={`part-${part.order}-${index}`}>
|
||||
<div className="flex w-[200px] flex-col">
|
||||
<NumberingRuleCard
|
||||
part={part}
|
||||
onUpdate={(updates) => handleUpdatePart(part.order, updates)}
|
||||
onDelete={() => handleDeletePart(part.order)}
|
||||
isPreview={isPreview}
|
||||
/>
|
||||
{/* 카드 하단에 구분자 설정 (마지막 파트 제외) */}
|
||||
{index < currentRule.parts.length - 1 && (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<span className="text-muted-foreground text-[10px] whitespace-nowrap">뒤 구분자</span>
|
||||
<Select
|
||||
value={separatorTypes[part.order] || "-"}
|
||||
onValueChange={(value) => handlePartSeparatorChange(part.order, value as SeparatorType)}
|
||||
>
|
||||
<SelectTrigger className="h-6 flex-1 text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEPARATOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{separatorTypes[part.order] === "custom" && (
|
||||
<Input
|
||||
value={customSeparators[part.order] || ""}
|
||||
onChange={(e) => handlePartCustomSeparatorChange(part.order, e.target.value)}
|
||||
className="h-6 w-14 text-center text-[10px]"
|
||||
placeholder="2자"
|
||||
maxLength={2}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -17,75 +17,71 @@ export const NumberingRulePreview: React.FC<NumberingRulePreviewProps> = ({
|
|||
return "규칙을 추가해주세요";
|
||||
}
|
||||
|
||||
const parts = config.parts
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((part) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return part.manualConfig?.value || "XXX";
|
||||
const sortedParts = config.parts.sort((a, b) => a.order - b.order);
|
||||
|
||||
const partValues = sortedParts.map((part) => {
|
||||
if (part.generationMethod === "manual") {
|
||||
return part.manualConfig?.value || "XXX";
|
||||
}
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
||||
switch (part.partType) {
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
return String(startFrom).padStart(length, "0");
|
||||
}
|
||||
|
||||
const autoConfig = part.autoConfig || {};
|
||||
|
||||
switch (part.partType) {
|
||||
// 1. 순번 (자동 증가)
|
||||
case "sequence": {
|
||||
const length = autoConfig.sequenceLength || 3;
|
||||
const startFrom = autoConfig.startFrom || 1;
|
||||
return String(startFrom).padStart(length, "0");
|
||||
}
|
||||
|
||||
// 2. 숫자 (고정 자릿수)
|
||||
case "number": {
|
||||
const length = autoConfig.numberLength || 4;
|
||||
const value = autoConfig.numberValue || 0;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
|
||||
// 3. 날짜
|
||||
case "date": {
|
||||
const format = autoConfig.dateFormat || "YYYYMMDD";
|
||||
|
||||
// 컬럼 기준 생성인 경우 placeholder 표시
|
||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
|
||||
// 형식에 맞는 placeholder 반환
|
||||
switch (format) {
|
||||
case "YYYY": return "[YYYY]";
|
||||
case "YY": return "[YY]";
|
||||
case "YYYYMM": return "[YYYYMM]";
|
||||
case "YYMM": return "[YYMM]";
|
||||
case "YYYYMMDD": return "[YYYYMMDD]";
|
||||
case "YYMMDD": return "[YYMMDD]";
|
||||
default: return "[DATE]";
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 날짜 기준 생성
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
|
||||
case "number": {
|
||||
const length = autoConfig.numberLength || 4;
|
||||
const value = autoConfig.numberValue || 0;
|
||||
return String(value).padStart(length, "0");
|
||||
}
|
||||
case "date": {
|
||||
const format = autoConfig.dateFormat || "YYYYMMDD";
|
||||
if (autoConfig.useColumnValue && autoConfig.sourceColumnName) {
|
||||
switch (format) {
|
||||
case "YYYY": return String(year);
|
||||
case "YY": return String(year).slice(-2);
|
||||
case "YYYYMM": return `${year}${month}`;
|
||||
case "YYMM": return `${String(year).slice(-2)}${month}`;
|
||||
case "YYYYMMDD": return `${year}${month}${day}`;
|
||||
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
|
||||
default: return `${year}${month}${day}`;
|
||||
case "YYYY": return "[YYYY]";
|
||||
case "YY": return "[YY]";
|
||||
case "YYYYMM": return "[YYYYMM]";
|
||||
case "YYMM": return "[YYMM]";
|
||||
case "YYYYMMDD": return "[YYYYMMDD]";
|
||||
case "YYMMDD": return "[YYMMDD]";
|
||||
default: return "[DATE]";
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 문자
|
||||
case "text":
|
||||
return autoConfig.textValue || "TEXT";
|
||||
|
||||
default:
|
||||
return "XXX";
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
switch (format) {
|
||||
case "YYYY": return String(year);
|
||||
case "YY": return String(year).slice(-2);
|
||||
case "YYYYMM": return `${year}${month}`;
|
||||
case "YYMM": return `${String(year).slice(-2)}${month}`;
|
||||
case "YYYYMMDD": return `${year}${month}${day}`;
|
||||
case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`;
|
||||
default: return `${year}${month}${day}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
case "text":
|
||||
return autoConfig.textValue || "TEXT";
|
||||
default:
|
||||
return "XXX";
|
||||
}
|
||||
});
|
||||
|
||||
return parts.join(config.separator || "");
|
||||
// 파트별 개별 구분자로 결합
|
||||
const globalSep = config.separator ?? "-";
|
||||
let result = "";
|
||||
partValues.forEach((val, idx) => {
|
||||
result += val;
|
||||
if (idx < partValues.length - 1) {
|
||||
const sep = sortedParts[idx].separatorAfter ?? globalSep;
|
||||
result += sep;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [config]);
|
||||
|
||||
if (compact) {
|
||||
|
|
|
|||
|
|
@ -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,9 @@ 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";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
|
||||
interface EditModalState {
|
||||
isOpen: boolean;
|
||||
|
|
@ -111,11 +114,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) {
|
||||
|
|
@ -264,14 +275,39 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
});
|
||||
|
||||
// 편집 데이터로 폼 데이터 초기화
|
||||
setFormData(editData || {});
|
||||
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드)
|
||||
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨
|
||||
setOriginalData(isCreateMode ? {} : editData || {});
|
||||
|
||||
if (isCreateMode) {
|
||||
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData);
|
||||
// entity join 필드(xxx_yyy)를 dot notation(table.column)으로도 매핑
|
||||
const enriched = { ...(editData || {}) };
|
||||
if (editData) {
|
||||
Object.keys(editData).forEach((key) => {
|
||||
// item_id_item_name → item_info.item_name 패턴 변환
|
||||
const match = key.match(/^(.+?)_([a-z_]+)$/);
|
||||
if (match && editData[key] != null) {
|
||||
const [, fkCol, fieldName] = match;
|
||||
// FK가 _id로 끝나면 참조 테이블명 추론 (item_id → item_info)
|
||||
if (fkCol.endsWith("_id")) {
|
||||
const refTable = fkCol.replace(/_id$/, "_info");
|
||||
const dotKey = `${refTable}.${fieldName}`;
|
||||
if (!(dotKey in enriched)) {
|
||||
enriched[dotKey] = editData[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
setFormData(enriched);
|
||||
// originalData: changedData 계산(PATCH)에만 사용
|
||||
// INSERT/UPDATE 판단에는 사용하지 않음
|
||||
setOriginalData(isCreateMode ? {} : editData || {});
|
||||
// INSERT/UPDATE 판단: 이벤트의 isCreateMode 플래그를 직접 저장
|
||||
// isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE
|
||||
setIsCreateModeFlag(!!isCreateMode);
|
||||
|
||||
console.log("[EditModal] 모달 열림:", {
|
||||
mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)",
|
||||
hasEditData: !!editData,
|
||||
editDataId: editData?.id,
|
||||
isCreateMode,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseEditModal = () => {
|
||||
|
|
@ -360,15 +396,44 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
try {
|
||||
setLoading(true);
|
||||
|
||||
// console.log("화면 데이터 로딩 시작:", screenId);
|
||||
|
||||
// 화면 정보와 레이아웃 데이터 로딩
|
||||
const [screenInfo, layoutData] = await Promise.all([
|
||||
// 화면 정보와 레이아웃 데이터 로딩 (ScreenModal과 동일하게 V2 API 우선)
|
||||
const [screenInfo, v2LayoutData] = await Promise.all([
|
||||
screenApi.getScreen(screenId),
|
||||
screenApi.getLayout(screenId),
|
||||
screenApi.getLayoutV2(screenId),
|
||||
]);
|
||||
|
||||
// console.log("API 응답:", { screenInfo, layoutData });
|
||||
// V2 → Legacy 변환 (ScreenModal과 동일한 패턴)
|
||||
let layoutData: any = null;
|
||||
if (v2LayoutData && isValidV2Layout(v2LayoutData)) {
|
||||
layoutData = convertV2ToLegacy(v2LayoutData);
|
||||
if (layoutData) {
|
||||
layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution;
|
||||
}
|
||||
}
|
||||
|
||||
// V2 없으면 기존 API fallback
|
||||
if (!layoutData) {
|
||||
console.warn("[EditModal] V2 레이아웃 없음, getLayout fallback 시도:", screenId);
|
||||
layoutData = await screenApi.getLayout(screenId);
|
||||
}
|
||||
|
||||
// getLayout도 실패하면 기본 레이어(layer_id=1) 직접 로드
|
||||
if (!layoutData || !layoutData.components || layoutData.components.length === 0) {
|
||||
console.warn("[EditModal] getLayout도 실패, getLayerLayout(1) 최종 fallback:", screenId);
|
||||
try {
|
||||
const baseLayerData = await screenApi.getLayerLayout(screenId, 1);
|
||||
if (baseLayerData && isValidV2Layout(baseLayerData)) {
|
||||
layoutData = convertV2ToLegacy(baseLayerData);
|
||||
if (layoutData) {
|
||||
layoutData.screenResolution = baseLayerData.screenResolution || layoutData.screenResolution;
|
||||
}
|
||||
} else if (baseLayerData?.components) {
|
||||
layoutData = baseLayerData;
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
console.error("[EditModal] getLayerLayout(1) fallback 실패:", fallbackErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (screenInfo && layoutData) {
|
||||
const components = layoutData.components || [];
|
||||
|
|
@ -381,11 +446,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 +466,185 @@ 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]);
|
||||
|
||||
// 활성화된 조건부 레이어의 컴포넌트 가져오기 (Zone 오프셋 적용)
|
||||
const activeConditionalComponents = useMemo(() => {
|
||||
return conditionalLayers
|
||||
.filter((layer) => activeConditionalLayerIds.includes(layer.id))
|
||||
.flatMap((layer) => {
|
||||
const layerWithComps = layer as LayerDefinition & { components: ComponentData[] };
|
||||
const comps = layerWithComps.components || [];
|
||||
|
||||
// Zone 오프셋 적용: 조건부 레이어 컴포넌트는 Zone 내부 상대 좌표로 저장되므로
|
||||
// Zone의 절대 좌표를 더해줘야 EditModal에서 올바른 위치에 렌더링됨
|
||||
const associatedZone = zones.find((z) => z.zone_id === (layer as any).zoneId);
|
||||
if (!associatedZone) return comps;
|
||||
|
||||
const zoneOffsetX = associatedZone.x || 0;
|
||||
const zoneOffsetY = associatedZone.y || 0;
|
||||
|
||||
return comps.map((comp) => ({
|
||||
...comp,
|
||||
position: {
|
||||
...comp.position,
|
||||
x: parseFloat(comp.position?.x?.toString() || "0") + zoneOffsetX,
|
||||
y: parseFloat(comp.position?.y?.toString() || "0") + zoneOffsetY,
|
||||
},
|
||||
}));
|
||||
});
|
||||
}, [conditionalLayers, activeConditionalLayerIds, zones]);
|
||||
|
||||
const handleClose = () => {
|
||||
setModalState({
|
||||
isOpen: false,
|
||||
|
|
@ -412,7 +659,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
});
|
||||
setScreenData(null);
|
||||
setFormData({});
|
||||
setZones([]);
|
||||
setConditionalLayers([]);
|
||||
setOriginalData({});
|
||||
setIsCreateModeFlag(true); // 기본값은 INSERT (안전 방향)
|
||||
setGroupData([]); // 🆕
|
||||
setOriginalGroupData([]); // 🆕
|
||||
};
|
||||
|
|
@ -704,14 +954,31 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
}
|
||||
|
||||
// V2Repeater 저장 이벤트 발생 (디테일 테이블 데이터 저장)
|
||||
const hasRepeaterInstances = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
|
||||
if (hasRepeaterInstances) {
|
||||
const masterRecordId = groupData[0]?.id || formData.id;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
masterRecordId,
|
||||
mainFormData: formData,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
console.log("📋 [EditModal] 그룹 저장 후 repeaterSave 이벤트 발생:", { masterRecordId });
|
||||
}
|
||||
|
||||
// 결과 메시지
|
||||
const messages: string[] = [];
|
||||
if (insertedCount > 0) messages.push(`${insertedCount}개 추가`);
|
||||
if (updatedCount > 0) messages.push(`${updatedCount}개 수정`);
|
||||
if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`);
|
||||
|
||||
if (messages.length > 0) {
|
||||
toast.success(`품목이 저장되었습니다 (${messages.join(", ")})`);
|
||||
if (messages.length > 0 || hasRepeaterInstances) {
|
||||
toast.success(messages.length > 0 ? `품목이 저장되었습니다 (${messages.join(", ")})` : "저장되었습니다.");
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
if (modalState.onSave) {
|
||||
|
|
@ -776,8 +1043,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 모드
|
||||
|
|
@ -903,19 +1193,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
if (response.success) {
|
||||
const masterRecordId = response.data?.id || formData.id;
|
||||
|
||||
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
masterRecordId,
|
||||
mainFormData: formData,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
console.log("📋 [EditModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName: screenData.screenInfo.tableName });
|
||||
|
||||
toast.success("데이터가 생성되었습니다.");
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
|
|
@ -963,88 +1240,97 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
|
||||
const hasRepeaterForInsert = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
|
||||
if (hasRepeaterForInsert) {
|
||||
try {
|
||||
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||
const handler = () => {
|
||||
clearTimeout(fallbackTimeout);
|
||||
window.removeEventListener("repeaterSaveComplete", handler);
|
||||
resolve();
|
||||
};
|
||||
window.addEventListener("repeaterSaveComplete", handler);
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
mainFormData: formData,
|
||||
masterRecordId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await repeaterSavePromise;
|
||||
} catch (repeaterError) {
|
||||
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
|
||||
}
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} else {
|
||||
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 (전체 업데이트)
|
||||
// VIEW에서 온 데이터의 경우 master_id를 우선 사용 (마스터-디테일 구조)
|
||||
const recordId = formData.master_id || 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("데이터가 수정되었습니다.");
|
||||
|
||||
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
|
||||
if (modalState.onSave) {
|
||||
try {
|
||||
modalState.onSave();
|
||||
} catch (callbackError) {
|
||||
console.error("onSave 콜백 에러:", callbackError);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
|
||||
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
|
||||
try {
|
||||
|
|
@ -1081,6 +1367,41 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||||
}
|
||||
|
||||
// V2Repeater 디테일 데이터 저장 (등록된 인스턴스가 있을 때만)
|
||||
const hasRepeaterForUpdate = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0;
|
||||
if (hasRepeaterForUpdate) {
|
||||
try {
|
||||
const repeaterSavePromise = new Promise<void>((resolve) => {
|
||||
const fallbackTimeout = setTimeout(resolve, 5000);
|
||||
const handler = () => {
|
||||
clearTimeout(fallbackTimeout);
|
||||
window.removeEventListener("repeaterSaveComplete", handler);
|
||||
resolve();
|
||||
};
|
||||
window.addEventListener("repeaterSaveComplete", handler);
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: recordId,
|
||||
tableName: screenData.screenInfo.tableName,
|
||||
mainFormData: formData,
|
||||
masterRecordId: recordId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await repeaterSavePromise;
|
||||
} catch (repeaterError) {
|
||||
console.error("❌ [EditModal] repeaterSave 오류:", repeaterError);
|
||||
}
|
||||
}
|
||||
|
||||
// 리피터 저장 완료 후 메인 테이블 새로고침
|
||||
if (modalState.onSave) {
|
||||
try { modalState.onSave(); } catch {}
|
||||
}
|
||||
handleClose();
|
||||
} else {
|
||||
throw new Error(response.message || "수정에 실패했습니다.");
|
||||
|
|
@ -1138,7 +1459,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
<div className="flex flex-1 justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
|
@ -1147,16 +1468,36 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
</div>
|
||||
</div>
|
||||
) : screenData ? (
|
||||
<ScreenContextProvider
|
||||
screenId={modalState.screenId || undefined}
|
||||
tableName={screenData.screenInfo?.tableName}
|
||||
>
|
||||
<div
|
||||
className="relative bg-white"
|
||||
data-screen-runtime="true"
|
||||
className="relative m-auto 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 +1515,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,20 +1564,76 @@ 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>
|
||||
</ScreenContextProvider>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">화면 데이터가 없습니다.</p>
|
||||
|
|
|
|||
|
|
@ -245,23 +245,29 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
|||
};
|
||||
|
||||
// 라벨 렌더링
|
||||
const labelPos = widget.style?.labelPosition || "top";
|
||||
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
||||
|
||||
const renderLabel = () => {
|
||||
if (hideLabel) return null;
|
||||
|
||||
const labelStyle = widget.style || {};
|
||||
const ls = widget.style || {};
|
||||
const labelElement = (
|
||||
<label
|
||||
className={`mb-2 block text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
|
||||
className={`text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
|
||||
style={{
|
||||
fontSize: labelStyle.labelFontSize || "14px",
|
||||
color: hasError ? "hsl(var(--destructive))" : labelStyle.labelColor || undefined,
|
||||
fontWeight: labelStyle.labelFontWeight || "500",
|
||||
fontFamily: labelStyle.labelFontFamily,
|
||||
textAlign: labelStyle.labelTextAlign || "left",
|
||||
backgroundColor: labelStyle.labelBackgroundColor,
|
||||
padding: labelStyle.labelPadding,
|
||||
borderRadius: labelStyle.labelBorderRadius,
|
||||
marginBottom: labelStyle.labelMarginBottom || "8px",
|
||||
fontSize: ls.labelFontSize || "14px",
|
||||
color: hasError ? "hsl(var(--destructive))" : ls.labelColor || undefined,
|
||||
fontWeight: ls.labelFontWeight || "500",
|
||||
fontFamily: ls.labelFontFamily,
|
||||
textAlign: ls.labelTextAlign || "left",
|
||||
backgroundColor: ls.labelBackgroundColor,
|
||||
padding: ls.labelPadding,
|
||||
borderRadius: ls.labelBorderRadius,
|
||||
...(isHorizLabel
|
||||
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
|
||||
: { marginBottom: labelPos === "top" ? (ls.labelMarginBottom || "8px") : undefined,
|
||||
marginTop: labelPos === "bottom" ? (ls.labelMarginBottom || "8px") : undefined }),
|
||||
}}
|
||||
>
|
||||
{widget.label}
|
||||
|
|
@ -332,11 +338,28 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
|
|||
}
|
||||
};
|
||||
|
||||
const labelElement = renderLabel();
|
||||
const widgetElement = renderByWebType();
|
||||
const validationElement = renderFieldValidation();
|
||||
|
||||
if (isHorizLabel && labelElement) {
|
||||
return (
|
||||
<div key={comp.id}>
|
||||
<div style={{ display: "flex", flexDirection: labelPos === "left" ? "row" : "row-reverse", alignItems: "center", gap: widget.style?.labelGap || "8px" }}>
|
||||
{labelElement}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>{widgetElement}</div>
|
||||
</div>
|
||||
{validationElement}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={comp.id} className="space-y-2">
|
||||
{renderLabel()}
|
||||
{renderByWebType()}
|
||||
{renderFieldValidation()}
|
||||
<div key={comp.id}>
|
||||
{labelPos === "top" && labelElement}
|
||||
{widgetElement}
|
||||
{labelPos === "bottom" && labelElement}
|
||||
{validationElement}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -284,6 +284,39 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
});
|
||||
}, [finalFormData, layers, allComponents, handleLayerAction]);
|
||||
|
||||
// 🆕 Zone 기반 Y 오프셋 계산 (단순화)
|
||||
// Zone 단위로 활성 여부만 판단 → merge 로직 불필요
|
||||
const calculateYOffset = useCallback((componentY: number): number => {
|
||||
// layers에서 Zone 정보 추출 (displayRegion이 있는 레이어들을 zone 단위로 그룹핑)
|
||||
const zoneMap = new Map<number, { y: number; height: number; hasActive: boolean }>();
|
||||
|
||||
for (const layer of layers) {
|
||||
if (layer.type !== "conditional" || !layer.zoneId || !layer.displayRegion) continue;
|
||||
const zid = layer.zoneId;
|
||||
if (!zoneMap.has(zid)) {
|
||||
zoneMap.set(zid, {
|
||||
y: layer.displayRegion.y,
|
||||
height: layer.displayRegion.height,
|
||||
hasActive: false,
|
||||
});
|
||||
}
|
||||
if (activeLayerIds.includes(layer.id)) {
|
||||
zoneMap.get(zid)!.hasActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
let totalOffset = 0;
|
||||
for (const [, zone] of zoneMap) {
|
||||
const zoneBottom = zone.y + zone.height;
|
||||
// 컴포넌트가 Zone 하단보다 아래에 있고, Zone에 활성 레이어가 없으면 접힘
|
||||
if (componentY >= zoneBottom && !zone.hasActive) {
|
||||
totalOffset += zone.height;
|
||||
}
|
||||
}
|
||||
|
||||
return totalOffset;
|
||||
}, [layers, activeLayerIds]);
|
||||
|
||||
// 개선된 검증 시스템 (선택적 활성화)
|
||||
const enhancedValidation = enableEnhancedValidation && screenInfo && tableColumns.length > 0
|
||||
? useFormValidation(
|
||||
|
|
@ -2158,10 +2191,11 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
|
||||
// 라벨 표시 여부 계산
|
||||
const shouldShowLabel =
|
||||
!hideLabel && // hideLabel이 true면 라벨 숨김
|
||||
(component.style?.labelDisplay ?? true) &&
|
||||
!hideLabel &&
|
||||
(component.style?.labelDisplay ?? true) !== false &&
|
||||
component.style?.labelDisplay !== "false" &&
|
||||
(component.label || component.style?.labelText) &&
|
||||
!templateTypes.includes(component.type); // 템플릿 컴포넌트는 라벨 표시 안함
|
||||
!templateTypes.includes(component.type);
|
||||
|
||||
const labelText = component.style?.labelText || component.label || "";
|
||||
|
||||
|
|
@ -2175,15 +2209,21 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
});
|
||||
}
|
||||
|
||||
// 라벨 스타일 적용
|
||||
const labelStyle = {
|
||||
// 라벨 위치 및 스타일
|
||||
const labelPosition = component.style?.labelPosition || "top";
|
||||
const isHorizontalLabel = labelPosition === "left" || labelPosition === "right";
|
||||
const labelGap = component.style?.labelGap || "8px";
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#212121",
|
||||
fontWeight: component.style?.labelFontWeight || "500",
|
||||
backgroundColor: component.style?.labelBackgroundColor || "transparent",
|
||||
padding: component.style?.labelPadding || "0",
|
||||
borderRadius: component.style?.labelBorderRadius || "0",
|
||||
marginBottom: component.style?.labelMarginBottom || "4px",
|
||||
...(isHorizontalLabel
|
||||
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
|
||||
: { marginBottom: component.style?.labelMarginBottom || "4px" }),
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -2193,11 +2233,34 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
...component,
|
||||
style: {
|
||||
...component.style,
|
||||
labelDisplay: false, // 상위에서 라벨을 표시했으므로 컴포넌트 내부에서는 숨김
|
||||
labelDisplay: false,
|
||||
labelPosition: "top" as const,
|
||||
...(isHorizontalLabel ? { width: "100%", height: "100%" } : {}),
|
||||
},
|
||||
...(isHorizontalLabel ? {
|
||||
size: {
|
||||
...component.size,
|
||||
width: undefined as unknown as number,
|
||||
height: undefined as unknown as number,
|
||||
},
|
||||
} : {}),
|
||||
}
|
||||
: component;
|
||||
|
||||
// 모든 레이어의 컴포넌트 통합 (조건 평가용 - 트리거 컴포넌트 검색에 필요)
|
||||
const allLayerComponents = useMemo(() => {
|
||||
return layers.flatMap((layer) => layer.components);
|
||||
}, [layers]);
|
||||
|
||||
// 🔧 활성 레이어 컴포넌트만 통합 (저장/데이터 수집용)
|
||||
// 기본 레이어(base) + 현재 활성화된 조건부 레이어만 포함
|
||||
// 비활성 레이어의 중복 columnName 컴포넌트가 저장 데이터를 오염시키는 문제 해결
|
||||
const visibleLayerComponents = useMemo(() => {
|
||||
return layers
|
||||
.filter((layer) => layer.type === "base" || activeLayerIds.includes(layer.id))
|
||||
.flatMap((layer) => layer.components);
|
||||
}, [layers, activeLayerIds]);
|
||||
|
||||
// 🆕 레이어별 컴포넌트 렌더링 함수
|
||||
const renderLayerComponents = useCallback((layer: LayerDefinition) => {
|
||||
// 활성화되지 않은 레이어는 렌더링하지 않음
|
||||
|
|
@ -2234,7 +2297,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
>
|
||||
<InteractiveScreenViewer
|
||||
component={comp}
|
||||
allComponents={layer.components}
|
||||
allComponents={visibleLayerComponents}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
|
|
@ -2306,7 +2369,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
>
|
||||
<InteractiveScreenViewer
|
||||
component={comp}
|
||||
allComponents={layer.components}
|
||||
allComponents={visibleLayerComponents}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
|
|
@ -2319,37 +2382,83 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
);
|
||||
}
|
||||
|
||||
// 일반/조건부 레이어 (base, conditional)
|
||||
// 조건부 레이어: Zone 기반 영역 내에 컴포넌트 렌더링
|
||||
if (layer.type === "conditional" && layer.displayRegion) {
|
||||
const region = layer.displayRegion;
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${region.x}px`,
|
||||
top: `${region.y}px`,
|
||||
width: `${region.width}px`,
|
||||
height: `${region.height}px`,
|
||||
zIndex: layer.zIndex,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{layer.components.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="pointer-events-auto absolute"
|
||||
style={{
|
||||
left: `${comp.position.x}px`,
|
||||
top: `${comp.position.y}px`,
|
||||
width: comp.style?.width || `${comp.size.width}px`,
|
||||
height: comp.style?.height || `${comp.size.height}px`,
|
||||
zIndex: comp.position.z || 1,
|
||||
}}
|
||||
>
|
||||
<InteractiveScreenViewer
|
||||
component={comp}
|
||||
allComponents={visibleLayerComponents}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본/기타 레이어 (base)
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{ zIndex: layer.zIndex }}
|
||||
>
|
||||
{layer.components.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="pointer-events-auto absolute"
|
||||
style={{
|
||||
left: `${comp.position.x}px`,
|
||||
top: `${comp.position.y}px`,
|
||||
width: comp.style?.width || `${comp.size.width}px`,
|
||||
height: comp.style?.height || `${comp.size.height}px`,
|
||||
zIndex: comp.position.z || 1,
|
||||
}}
|
||||
>
|
||||
<InteractiveScreenViewer
|
||||
component={comp}
|
||||
allComponents={layer.components}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{layer.components.map((comp) => {
|
||||
const yOffset = layer.type === "base" ? calculateYOffset(comp.position.y) : 0;
|
||||
const adjustedY = comp.position.y - yOffset;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="pointer-events-auto absolute"
|
||||
style={{
|
||||
left: `${comp.position.x}px`,
|
||||
top: `${adjustedY}px`,
|
||||
width: comp.style?.width || `${comp.size.width}px`,
|
||||
height: comp.style?.height || `${comp.size.height}px`,
|
||||
zIndex: comp.position.z || 1,
|
||||
}}
|
||||
>
|
||||
<InteractiveScreenViewer
|
||||
component={comp}
|
||||
allComponents={visibleLayerComponents}
|
||||
formData={externalFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenInfo={screenInfo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo]);
|
||||
}, [activeLayerIds, handleLayerAction, externalFormData, onFormDataChange, screenInfo, calculateYOffset, visibleLayerComponents, layers]);
|
||||
|
||||
return (
|
||||
<SplitPanelProvider>
|
||||
|
|
@ -2359,18 +2468,45 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
{/* 테이블 옵션 툴바 */}
|
||||
<TableOptionsToolbar />
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<div className="h-full flex-1" style={{ width: '100%' }}>
|
||||
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
|
||||
{shouldShowLabel && (
|
||||
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{/* 메인 컨텐츠 - 라벨 위치에 따라 flex 방향 변경 */}
|
||||
<div
|
||||
className="h-full flex-1"
|
||||
style={{
|
||||
width: '100%',
|
||||
...(shouldShowLabel && isHorizontalLabel
|
||||
? { display: 'flex', flexDirection: labelPosition === 'left' ? 'row' : 'row-reverse', alignItems: 'center', gap: labelGap }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{/* 라벨: top 또는 left일 때 위젯보다 먼저 렌더링 */}
|
||||
{shouldShowLabel && (labelPosition === "top" || labelPosition === "left") && (
|
||||
<label
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
style={labelStyle}
|
||||
>
|
||||
{labelText}
|
||||
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
|
||||
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
|
||||
{/* 실제 위젯 */}
|
||||
<div className="h-full" style={{ width: '100%', height: '100%', ...(isHorizontalLabel ? { flex: 1, minWidth: 0 } : {}) }}>
|
||||
{renderInteractiveWidget(componentForRendering)}
|
||||
</div>
|
||||
|
||||
{/* 라벨: bottom 또는 right일 때 위젯 뒤에 렌더링 */}
|
||||
{shouldShowLabel && (labelPosition === "bottom" || labelPosition === "right") && (
|
||||
<label
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
style={{
|
||||
...labelStyle,
|
||||
...(labelPosition === "bottom" ? { marginBottom: 0, marginTop: component.style?.labelMarginBottom || "4px" } : {}),
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -2401,7 +2537,13 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
setPopupScreen(null);
|
||||
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
|
||||
}}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
|
||||
<DialogContent
|
||||
className="max-w-none w-auto max-h-[90vh] overflow-hidden p-0"
|
||||
style={popupScreenResolution ? {
|
||||
width: `${Math.min(popupScreenResolution.width + 48, window.innerWidth * 0.98)}px`,
|
||||
maxWidth: "98vw",
|
||||
} : { maxWidth: "56rem" }}
|
||||
>
|
||||
<DialogHeader className="px-6 pt-4 pb-2">
|
||||
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import React, { useState, useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -20,6 +20,12 @@ import { findAllButtonGroups } from "@/lib/utils/flowButtonGroupUtils";
|
|||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { evaluateConditional } from "@/lib/utils/conditionalEvaluator";
|
||||
import {
|
||||
subscribe as canvasSplitSubscribe,
|
||||
getSnapshot as canvasSplitGetSnapshot,
|
||||
getServerSnapshot as canvasSplitGetServerSnapshot,
|
||||
subscribeDom as canvasSplitSubscribeDom,
|
||||
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
|
||||
|
||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||
import "@/lib/registry/components/ButtonRenderer";
|
||||
|
|
@ -82,9 +88,14 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
parentTabId,
|
||||
parentTabsComponentId,
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const { isPreviewMode } = useScreenPreview();
|
||||
const { userName: authUserName, user: authUser } = useAuth();
|
||||
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
|
||||
// 캔버스 분할선 글로벌 스토어 구독
|
||||
const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot);
|
||||
const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null);
|
||||
const myScopeIdRef = React.useRef<string | null>(null);
|
||||
|
||||
// 외부에서 전달받은 사용자 정보가 있으면 우선 사용 (ScreenModal 등에서)
|
||||
const userName = externalUserName || authUserName;
|
||||
|
|
@ -560,8 +571,38 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
return;
|
||||
}
|
||||
|
||||
// 리피터가 화면과 동일 테이블을 사용하는지 감지 (useCustomTable 미설정 = 동일 테이블)
|
||||
const hasRepeaterOnSameTable = allComponents.some((c: any) => {
|
||||
const compType = c.componentType || c.overrides?.type;
|
||||
if (compType !== "v2-repeater") return false;
|
||||
const compConfig = c.componentConfig || c.overrides || {};
|
||||
return !compConfig.useCustomTable;
|
||||
});
|
||||
|
||||
if (hasRepeaterOnSameTable) {
|
||||
// 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장
|
||||
// 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리
|
||||
try {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: null,
|
||||
masterRecordId: null,
|
||||
mainFormData: formData,
|
||||
tableName: screenInfo.tableName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
toast.success("데이터가 성공적으로 저장되었습니다.");
|
||||
} catch (error) {
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
|
||||
// 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
|
||||
// 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함
|
||||
const masterFormData: Record<string, any> = {};
|
||||
|
||||
|
|
@ -580,11 +621,8 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
if (!Array.isArray(value)) {
|
||||
// 배열이 아닌 값은 그대로 저장
|
||||
masterFormData[key] = value;
|
||||
} else if (mediaColumnNames.has(key)) {
|
||||
// v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응)
|
||||
// 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용
|
||||
masterFormData[key] = value.length > 0 ? value[0] : null;
|
||||
console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`);
|
||||
} else {
|
||||
|
|
@ -597,7 +635,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
data: masterFormData,
|
||||
};
|
||||
|
||||
// console.log("💾 저장 액션 실행:", saveData);
|
||||
const response = await dynamicFormApi.saveData(saveData);
|
||||
|
||||
if (response.success) {
|
||||
|
|
@ -608,7 +645,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
new CustomEvent("repeaterSave", {
|
||||
detail: {
|
||||
parentId: masterRecordId,
|
||||
masterRecordId, // 🆕 마스터 레코드 ID (FK 자동 연결용)
|
||||
masterRecordId,
|
||||
mainFormData: formData,
|
||||
tableName: screenInfo.tableName,
|
||||
},
|
||||
|
|
@ -620,7 +657,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
toast.error(response.message || "저장에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("저장 오류:", error);
|
||||
toast.error("저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
|
@ -1067,36 +1103,279 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
// TableSearchWidget의 경우 높이를 자동으로 설정
|
||||
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||
|
||||
// 🆕 라벨 표시 여부 확인 (V2 입력 컴포넌트)
|
||||
// labelDisplay가 false가 아니고, labelText 또는 label이 있으면 라벨 표시
|
||||
const isV2InputComponent = type === "v2-input" || type === "v2-select" || type === "v2-date";
|
||||
// 라벨 표시 여부 확인 (V2 입력 컴포넌트)
|
||||
const compType = (component as any).componentType || "";
|
||||
const isV2InputComponent =
|
||||
type === "v2-input" || type === "v2-select" || type === "v2-date" ||
|
||||
compType === "v2-input" || compType === "v2-select" || compType === "v2-date";
|
||||
const hasVisibleLabel = isV2InputComponent &&
|
||||
style?.labelDisplay !== false &&
|
||||
style?.labelDisplay !== false && style?.labelDisplay !== "false" &&
|
||||
(style?.labelText || (component as any).label);
|
||||
|
||||
// 라벨이 있는 경우 상단 여백 계산 (라벨 폰트크기 + 여백)
|
||||
// 라벨 위치에 따라 오프셋 계산 (좌/우 배치 시 세로 오프셋 불필요)
|
||||
const labelPos = style?.labelPosition || "top";
|
||||
const isVerticalLabel = labelPos === "top" || labelPos === "bottom";
|
||||
const labelFontSize = style?.labelFontSize ? parseInt(String(style.labelFontSize)) : 14;
|
||||
const labelMarginBottom = style?.labelMarginBottom ? parseInt(String(style.labelMarginBottom)) : 4;
|
||||
const labelOffset = hasVisibleLabel ? (labelFontSize + labelMarginBottom + 2) : 0;
|
||||
const labelOffset = (hasVisibleLabel && isVerticalLabel) ? (labelFontSize + labelMarginBottom + 2) : 0;
|
||||
|
||||
// 수평 라벨 관련 (componentStyle 계산보다 먼저 선언)
|
||||
const needsExternalLabel = hasVisibleLabel && labelPos !== "top";
|
||||
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
||||
const labelText = style?.labelText || (component as any).label || "";
|
||||
const labelGapValue = style?.labelGap || "8px";
|
||||
|
||||
const calculateCanvasSplitX = (): { x: number; w: number } => {
|
||||
const compType = (component as any).componentType || "";
|
||||
const isSplitLine = type === "component" && compType === "v2-split-line";
|
||||
const origX = position?.x || 0;
|
||||
const defaultW = size?.width || 200;
|
||||
|
||||
if (isSplitLine) return { x: origX, w: defaultW };
|
||||
|
||||
if (!canvasSplit.active || canvasSplit.canvasWidth <= 0 || !canvasSplit.scopeId) {
|
||||
return { x: origX, w: defaultW };
|
||||
}
|
||||
|
||||
if (myScopeIdRef.current === null) {
|
||||
const el = document.getElementById(`interactive-${component.id}`);
|
||||
const container = el?.closest("[data-screen-runtime]");
|
||||
myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__";
|
||||
}
|
||||
if (myScopeIdRef.current !== canvasSplit.scopeId) {
|
||||
return { x: origX, w: defaultW };
|
||||
}
|
||||
|
||||
const { initialDividerX, currentDividerX, canvasWidth } = canvasSplit;
|
||||
const delta = currentDividerX - initialDividerX;
|
||||
if (Math.abs(delta) < 1) return { x: origX, w: defaultW };
|
||||
|
||||
const origW = defaultW;
|
||||
if (canvasSplitSideRef.current === null) {
|
||||
const componentCenterX = origX + (origW / 2);
|
||||
canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right";
|
||||
}
|
||||
|
||||
// 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음
|
||||
let newX: number;
|
||||
let newW: number;
|
||||
const GAP = 4; // 스플릿선과의 최소 간격
|
||||
|
||||
if (canvasSplitSideRef.current === "left") {
|
||||
// 왼쪽 영역: [0, currentDividerX - GAP]
|
||||
const initialZoneWidth = initialDividerX;
|
||||
const currentZoneWidth = Math.max(20, currentDividerX - GAP);
|
||||
const scale = initialZoneWidth > 0 ? currentZoneWidth / initialZoneWidth : 1;
|
||||
newX = origX * scale;
|
||||
newW = origW * scale;
|
||||
// 안전 클램핑: 왼쪽 영역을 절대 넘지 않음
|
||||
if (newX + newW > currentDividerX - GAP) {
|
||||
newW = currentDividerX - GAP - newX;
|
||||
}
|
||||
} else {
|
||||
// 오른쪽 영역: [currentDividerX + GAP, canvasWidth]
|
||||
const initialRightWidth = canvasWidth - initialDividerX;
|
||||
const currentRightWidth = Math.max(20, canvasWidth - currentDividerX - GAP);
|
||||
const scale = initialRightWidth > 0 ? currentRightWidth / initialRightWidth : 1;
|
||||
const rightOffset = origX - initialDividerX;
|
||||
newX = currentDividerX + GAP + rightOffset * scale;
|
||||
newW = origW * scale;
|
||||
// 안전 클램핑: 오른쪽 영역을 절대 넘지 않음
|
||||
if (newX < currentDividerX + GAP) newX = currentDividerX + GAP;
|
||||
if (newX + newW > canvasWidth) newW = canvasWidth - newX;
|
||||
}
|
||||
|
||||
newX = Math.max(0, newX);
|
||||
newW = Math.max(20, newW);
|
||||
|
||||
return { x: newX, w: newW };
|
||||
};
|
||||
|
||||
const splitResult = calculateCanvasSplitX();
|
||||
const adjustedX = splitResult.x;
|
||||
const adjustedW = splitResult.w;
|
||||
const origW = size?.width || 200;
|
||||
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
|
||||
|
||||
// styleWithoutSize에서 left/top 제거 (캔버스 분할 조정값 덮어쓰기 방지)
|
||||
const { left: _styleLeft, top: _styleTop, ...safeStyleWithoutSize } = styleWithoutSize as any;
|
||||
|
||||
// 수평 라벨 컴포넌트: position wrapper에서 border 제거 (내부 V2 컴포넌트가 기본 border 사용)
|
||||
const cleanedStyle = (isHorizLabel && needsExternalLabel)
|
||||
? (() => {
|
||||
const { borderWidth: _bw, borderColor: _bc, borderStyle: _bs, border: _b, borderRadius: _br, ...rest } = safeStyleWithoutSize;
|
||||
return rest;
|
||||
})()
|
||||
: safeStyleWithoutSize;
|
||||
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
left: position?.x || 0,
|
||||
top: position?.y || 0, // 원래 위치 유지 (음수로 가면 overflow-hidden에 잘림)
|
||||
...cleanedStyle,
|
||||
// left/top은 반드시 마지막에 (styleWithoutSize가 덮어쓰지 못하게)
|
||||
left: adjustedX,
|
||||
top: position?.y || 0,
|
||||
zIndex: position?.z || 1,
|
||||
...styleWithoutSize, // width/height 제외한 스타일만 먼저 적용
|
||||
width: size?.width || 200, // size의 픽셀 값이 최종 우선순위
|
||||
width: isSplitActive ? adjustedW : (size?.width || 200),
|
||||
height: isTableSearchWidget ? "auto" : size?.height || 10,
|
||||
minHeight: isTableSearchWidget ? "48px" : undefined,
|
||||
// 🆕 라벨이 있으면 overflow visible로 설정하여 라벨이 잘리지 않게 함
|
||||
overflow: labelOffset > 0 ? "visible" : undefined,
|
||||
overflow: (isSplitActive && adjustedW < origW) ? "hidden" : (labelOffset > 0 ? "visible" : undefined),
|
||||
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
|
||||
transition: isSplitActive
|
||||
? (canvasSplit.isDragging ? "none" : "left 0.15s ease-out, width 0.15s ease-out")
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// 스플릿 조정된 컴포넌트 객체 캐싱 (드래그 끝난 후 최종 렌더링용)
|
||||
const splitAdjustedComponent = React.useMemo(() => {
|
||||
if (isSplitActive && adjustedW !== origW) {
|
||||
return { ...component, size: { ...(component as any).size, width: Math.round(adjustedW) } };
|
||||
}
|
||||
return component;
|
||||
}, [component, isSplitActive, adjustedW, origW]);
|
||||
|
||||
// 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트)
|
||||
const elRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useEffect(() => {
|
||||
const compType = (component as any).componentType || "";
|
||||
if (type === "component" && compType === "v2-split-line") return;
|
||||
|
||||
const unsubscribe = canvasSplitSubscribeDom((snap) => {
|
||||
if (!snap.isDragging || !snap.active || !snap.scopeId) return;
|
||||
if (myScopeIdRef.current !== snap.scopeId) return;
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const origX = position?.x || 0;
|
||||
const oW = size?.width || 200;
|
||||
const { initialDividerX, currentDividerX, canvasWidth } = snap;
|
||||
const delta = currentDividerX - initialDividerX;
|
||||
if (Math.abs(delta) < 1) return;
|
||||
|
||||
if (canvasSplitSideRef.current === null) {
|
||||
canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right";
|
||||
}
|
||||
|
||||
const GAP = 4;
|
||||
let nx: number, nw: number;
|
||||
if (canvasSplitSideRef.current === "left") {
|
||||
const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1;
|
||||
nx = origX * scale;
|
||||
nw = oW * scale;
|
||||
if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx;
|
||||
} else {
|
||||
const irw = canvasWidth - initialDividerX;
|
||||
const crw = Math.max(20, canvasWidth - currentDividerX - GAP);
|
||||
const scale = irw > 0 ? crw / irw : 1;
|
||||
nx = currentDividerX + GAP + (origX - initialDividerX) * scale;
|
||||
nw = oW * scale;
|
||||
if (nx < currentDividerX + GAP) nx = currentDividerX + GAP;
|
||||
if (nx + nw > canvasWidth) nw = canvasWidth - nx;
|
||||
}
|
||||
nx = Math.max(0, nx);
|
||||
nw = Math.max(20, nw);
|
||||
|
||||
el.style.left = `${nx}px`;
|
||||
el.style.width = `${Math.round(nw)}px`;
|
||||
el.style.overflow = nw < oW ? "hidden" : "";
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [component.id, position?.x, size?.width, type]);
|
||||
|
||||
// needsExternalLabel, isHorizLabel, labelText, labelGapValue는 위에서 선언됨
|
||||
|
||||
const externalLabelComponent = needsExternalLabel ? (
|
||||
<label
|
||||
className="text-sm font-medium leading-none"
|
||||
style={{
|
||||
fontSize: style?.labelFontSize || "14px",
|
||||
color: style?.labelColor || "#212121",
|
||||
fontWeight: style?.labelFontWeight || "500",
|
||||
...(isHorizLabel ? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" } : {}),
|
||||
...(labelPos === "bottom" ? { marginTop: style?.labelMarginBottom || "4px" } : {}),
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
{((component as any).required || (component as any).componentConfig?.required) && (
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
)}
|
||||
</label>
|
||||
) : null;
|
||||
|
||||
const componentToRender = needsExternalLabel
|
||||
? {
|
||||
...splitAdjustedComponent,
|
||||
style: {
|
||||
...splitAdjustedComponent.style,
|
||||
labelDisplay: false,
|
||||
labelPosition: "top" as const,
|
||||
...(isHorizLabel ? {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderWidth: undefined,
|
||||
borderColor: undefined,
|
||||
borderStyle: undefined,
|
||||
border: undefined,
|
||||
borderRadius: undefined,
|
||||
} : {}),
|
||||
},
|
||||
...(isHorizLabel ? {
|
||||
size: {
|
||||
...splitAdjustedComponent.size,
|
||||
width: undefined as unknown as number,
|
||||
height: undefined as unknown as number,
|
||||
},
|
||||
} : {}),
|
||||
}
|
||||
: splitAdjustedComponent;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute" style={componentStyle}>
|
||||
{/* 위젯 렌더링 (라벨은 V2Input 내부에서 absolute로 표시됨) */}
|
||||
{renderInteractiveWidget(component)}
|
||||
<div ref={elRef} id={`interactive-${component.id}`} className="absolute" style={componentStyle}>
|
||||
{needsExternalLabel ? (
|
||||
isHorizLabel ? (
|
||||
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
||||
<label
|
||||
className="text-sm font-medium leading-none"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
...(labelPos === "left"
|
||||
? { right: "100%", marginRight: labelGapValue }
|
||||
: { left: "100%", marginLeft: labelGapValue }),
|
||||
fontSize: style?.labelFontSize || "14px",
|
||||
color: style?.labelColor || "#212121",
|
||||
fontWeight: style?.labelFontWeight || "500",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
{((component as any).required || (component as any).componentConfig?.required) && (
|
||||
<span className="ml-1 text-destructive">*</span>
|
||||
)}
|
||||
</label>
|
||||
<div style={{ width: "100%", height: "100%" }}>
|
||||
{renderInteractiveWidget(componentToRender)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column-reverse",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{externalLabelComponent}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{renderInteractiveWidget(componentToRender)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
renderInteractiveWidget(componentToRender)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 팝업 화면 렌더링 */}
|
||||
|
|
|
|||
|
|
@ -10,15 +10,28 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2, AlertCircle, Check, X } from "lucide-react";
|
||||
import { Loader2, AlertCircle, Check, X, Database, Code2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentData, LayerCondition, LayerDefinition } from "@/types/screen-management";
|
||||
import { ComponentData, LayerCondition, LayerDefinition, DisplayRegion } from "@/types/screen-management";
|
||||
import { getCodesByCategory, CodeItem } from "@/lib/api/codeManagement";
|
||||
import { EntityReferenceAPI } from "@/lib/api/entityReference";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
// 통합 옵션 타입 (코드/엔티티/카테고리 모두 사용)
|
||||
interface ConditionOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// 컴포넌트의 데이터 소스 타입
|
||||
type DataSourceType = "code" | "entity" | "category" | "static" | "none";
|
||||
|
||||
interface LayerConditionPanelProps {
|
||||
layer: LayerDefinition;
|
||||
components: ComponentData[]; // 화면의 모든 컴포넌트
|
||||
baseLayerComponents?: ComponentData[]; // 기본 레이어 컴포넌트 (트리거 우선 대상)
|
||||
onUpdateCondition: (condition: LayerCondition | undefined) => void;
|
||||
onUpdateDisplayRegion: (region: DisplayRegion | undefined) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +47,9 @@ type OperatorType = "eq" | "neq" | "in";
|
|||
export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
||||
layer,
|
||||
components,
|
||||
baseLayerComponents,
|
||||
onUpdateCondition,
|
||||
onUpdateDisplayRegion,
|
||||
onClose,
|
||||
}) => {
|
||||
// 조건 설정 상태
|
||||
|
|
@ -51,75 +66,289 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
Array.isArray(layer.condition?.value) ? layer.condition.value : []
|
||||
);
|
||||
|
||||
// 코드 목록 로딩 상태
|
||||
const [codeOptions, setCodeOptions] = useState<CodeItem[]>([]);
|
||||
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
|
||||
const [codeLoadError, setCodeLoadError] = useState<string | null>(null);
|
||||
// 옵션 목록 로딩 상태 (코드/엔티티 통합)
|
||||
const [options, setOptions] = useState<ConditionOption[]>([]);
|
||||
const [isLoadingOptions, setIsLoadingOptions] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// 트리거 가능한 컴포넌트 필터링 (셀렉트, 라디오, 코드 타입 등)
|
||||
// 트리거 가능한 컴포넌트 필터링 (기본 레이어 우선, 셀렉트/라디오/코드 타입 등)
|
||||
const triggerableComponents = useMemo(() => {
|
||||
return components.filter((comp) => {
|
||||
// 기본 레이어 컴포넌트가 전달된 경우 우선 사용, 없으면 전체 컴포넌트 사용
|
||||
const sourceComponents = baseLayerComponents && baseLayerComponents.length > 0
|
||||
? baseLayerComponents
|
||||
: components;
|
||||
|
||||
const isTriggerComponent = (comp: ComponentData): boolean => {
|
||||
const componentType = (comp.componentType || "").toLowerCase();
|
||||
const widgetType = ((comp as any).widgetType || "").toLowerCase();
|
||||
const webType = ((comp as any).webType || "").toLowerCase();
|
||||
const inputType = ((comp as any).componentConfig?.inputType || "").toLowerCase();
|
||||
const webType = ((comp as any).webType || comp.componentConfig?.webType || "").toLowerCase();
|
||||
const inputType = ((comp as any).inputType || comp.componentConfig?.inputType || "").toLowerCase();
|
||||
const source = ((comp as any).source || comp.componentConfig?.source || "").toLowerCase();
|
||||
|
||||
// 셀렉트, 라디오, 코드 타입 컴포넌트만 허용
|
||||
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle"];
|
||||
const isTriggerType = triggerTypes.some((type) =>
|
||||
// 셀렉트, 라디오, 코드, 카테고리, 엔티티 타입 컴포넌트 허용
|
||||
const triggerTypes = ["select", "radio", "code", "checkbox", "toggle", "entity", "category"];
|
||||
return triggerTypes.some((type) =>
|
||||
componentType.includes(type) ||
|
||||
widgetType.includes(type) ||
|
||||
webType.includes(type) ||
|
||||
inputType.includes(type)
|
||||
inputType.includes(type) ||
|
||||
source.includes(type)
|
||||
);
|
||||
|
||||
return isTriggerType;
|
||||
});
|
||||
}, [components]);
|
||||
};
|
||||
|
||||
// 기본 레이어 컴포넌트 ID Set (그룹 구분용)
|
||||
const baseLayerIds = new Set(
|
||||
(baseLayerComponents || []).map((c) => c.id)
|
||||
);
|
||||
|
||||
// 기본 레이어 트리거 컴포넌트
|
||||
const baseLayerTriggers = sourceComponents.filter(isTriggerComponent);
|
||||
|
||||
// 기본 레이어가 아닌 다른 레이어의 트리거 컴포넌트도 포함 (하단에 표시)
|
||||
// 단, baseLayerComponents가 별도로 전달된 경우에만 나머지 컴포넌트 추가
|
||||
const otherLayerTriggers = baseLayerComponents && baseLayerComponents.length > 0
|
||||
? components.filter((comp) => !baseLayerIds.has(comp.id) && isTriggerComponent(comp))
|
||||
: [];
|
||||
|
||||
return { baseLayerTriggers, otherLayerTriggers };
|
||||
}, [components, baseLayerComponents]);
|
||||
|
||||
// 선택된 컴포넌트 정보
|
||||
const selectedComponent = useMemo(() => {
|
||||
return components.find((c) => c.id === targetComponentId);
|
||||
}, [components, targetComponentId]);
|
||||
// 기본 레이어 + 현재 레이어 통합 컴포넌트 목록 (트리거 컴포넌트 검색용)
|
||||
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 codeCategory = useMemo(() => {
|
||||
if (!selectedComponent) return null;
|
||||
const selectedComponent = useMemo(() => {
|
||||
return allAvailableComponents.find((c) => c.id === targetComponentId);
|
||||
}, [allAvailableComponents, targetComponentId]);
|
||||
|
||||
// 선택된 컴포넌트의 데이터 소스 정보 추출
|
||||
const dataSourceInfo = useMemo<{
|
||||
type: DataSourceType;
|
||||
codeCategory?: string;
|
||||
// 엔티티: 원본 테이블.컬럼 (entity-reference API용)
|
||||
originTable?: string;
|
||||
originColumn?: string;
|
||||
// 엔티티: 참조 대상 정보 (직접 조회용 폴백)
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
categoryTable?: string;
|
||||
categoryColumn?: string;
|
||||
staticOptions?: any[];
|
||||
}>(() => {
|
||||
if (!selectedComponent) return { type: "none" };
|
||||
|
||||
// codeCategory 확인 (다양한 위치에 있을 수 있음)
|
||||
const category =
|
||||
(selectedComponent as any).codeCategory ||
|
||||
(selectedComponent as any).componentConfig?.codeCategory ||
|
||||
(selectedComponent as any).webTypeConfig?.codeCategory;
|
||||
const comp = selectedComponent as any;
|
||||
const config = comp.componentConfig || comp.webTypeConfig || {};
|
||||
const detailSettings = comp.detailSettings || {};
|
||||
|
||||
return category || null;
|
||||
// V2 컴포넌트: source 확인 (componentConfig, 상위 레벨, inputType 모두 체크)
|
||||
const source = config.source || comp.source;
|
||||
const inputType = config.inputType || comp.inputType;
|
||||
const webType = config.webType || comp.webType;
|
||||
|
||||
// inputType/webType이 category면 카테고리로 판단
|
||||
if (inputType === "category" || webType === "category") {
|
||||
const categoryTable = config.categoryTable || comp.tableName || config.tableName;
|
||||
const categoryColumn = config.categoryColumn || comp.columnName || config.columnName;
|
||||
return { type: "category", categoryTable, categoryColumn };
|
||||
}
|
||||
|
||||
// 1. 카테고리 소스 (V2: source === "category", category_values 테이블)
|
||||
if (source === "category") {
|
||||
const categoryTable = config.categoryTable || comp.tableName;
|
||||
const categoryColumn = config.categoryColumn || comp.columnName;
|
||||
return { type: "category", categoryTable, categoryColumn };
|
||||
}
|
||||
|
||||
// 2. 코드 카테고리 확인 (V2: source === "code" + codeGroup, 기존: codeCategory)
|
||||
const codeCategory =
|
||||
config.codeGroup || // V2 컴포넌트
|
||||
config.codeCategory ||
|
||||
comp.codeCategory ||
|
||||
detailSettings.codeCategory;
|
||||
|
||||
if (source === "code" || codeCategory) {
|
||||
return { type: "code", codeCategory };
|
||||
}
|
||||
|
||||
// 3. 엔티티 참조 확인 (V2: source === "entity")
|
||||
// entity-reference API는 원본 테이블.컬럼으로 호출해야 함
|
||||
// (백엔드에서 table_type_columns를 조회하여 참조 테이블/컬럼을 자동 매핑)
|
||||
const originTable = comp.tableName || config.tableName;
|
||||
const originColumn = comp.columnName || config.columnName;
|
||||
|
||||
const referenceTable =
|
||||
config.entityTable ||
|
||||
config.referenceTable ||
|
||||
comp.referenceTable ||
|
||||
detailSettings.referenceTable;
|
||||
|
||||
const referenceColumn =
|
||||
config.entityValueColumn ||
|
||||
config.referenceColumn ||
|
||||
comp.referenceColumn ||
|
||||
detailSettings.referenceColumn;
|
||||
|
||||
if (source === "entity" || referenceTable) {
|
||||
return { type: "entity", originTable, originColumn, referenceTable, referenceColumn };
|
||||
}
|
||||
|
||||
// 4. 정적 옵션 확인 (V2: source === "static" 또는 config.options 존재)
|
||||
const staticOptions = config.options;
|
||||
if (source === "static" || (staticOptions && Array.isArray(staticOptions) && staticOptions.length > 0)) {
|
||||
return { type: "static", staticOptions };
|
||||
}
|
||||
|
||||
return { type: "none" };
|
||||
}, [selectedComponent]);
|
||||
|
||||
// 컴포넌트 선택 시 코드 목록 로드
|
||||
// 의존성 안정화를 위한 직렬화 키
|
||||
const dataSourceKey = useMemo(() => {
|
||||
const { type, categoryTable, categoryColumn, codeCategory, originTable, originColumn, referenceTable, referenceColumn } = dataSourceInfo;
|
||||
return `${type}|${categoryTable || ""}|${categoryColumn || ""}|${codeCategory || ""}|${originTable || ""}|${originColumn || ""}|${referenceTable || ""}|${referenceColumn || ""}`;
|
||||
}, [dataSourceInfo]);
|
||||
|
||||
// 컴포넌트 선택 시 옵션 목록 로드 (카테고리, 코드, 엔티티, 정적)
|
||||
useEffect(() => {
|
||||
if (!codeCategory) {
|
||||
setCodeOptions([]);
|
||||
// race condition 방지
|
||||
let cancelled = false;
|
||||
|
||||
if (dataSourceInfo.type === "none") {
|
||||
setOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCodes = async () => {
|
||||
setIsLoadingCodes(true);
|
||||
setCodeLoadError(null);
|
||||
// 정적 옵션은 즉시 설정
|
||||
if (dataSourceInfo.type === "static") {
|
||||
const staticOpts = dataSourceInfo.staticOptions || [];
|
||||
setOptions(staticOpts.map((opt: any) => ({
|
||||
value: opt.value || "",
|
||||
label: opt.label || opt.value || "",
|
||||
})));
|
||||
return;
|
||||
}
|
||||
|
||||
const loadOptions = async () => {
|
||||
setIsLoadingOptions(true);
|
||||
setLoadError(null);
|
||||
|
||||
try {
|
||||
const codes = await getCodesByCategory(codeCategory);
|
||||
setCodeOptions(codes);
|
||||
if (dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && dataSourceInfo.categoryColumn) {
|
||||
// 카테고리 값에서 옵션 로드 (category_values 테이블)
|
||||
console.log("[LayerCondition] 카테고리 옵션 로드:", dataSourceInfo.categoryTable, dataSourceInfo.categoryColumn);
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${dataSourceInfo.categoryTable}/${dataSourceInfo.categoryColumn}/values`
|
||||
);
|
||||
if (cancelled) return;
|
||||
const data = response.data;
|
||||
console.log("[LayerCondition] 카테고리 API 응답:", data?.success, "항목수:", Array.isArray(data?.data) ? data.data.length : 0);
|
||||
if (data.success && data.data) {
|
||||
// 트리 구조를 평탄화
|
||||
const flattenTree = (items: any[], depth = 0): ConditionOption[] => {
|
||||
const result: ConditionOption[] = [];
|
||||
for (const item of items) {
|
||||
const prefix = depth > 0 ? " ".repeat(depth) : "";
|
||||
result.push({
|
||||
value: item.valueCode || item.valueLabel,
|
||||
label: `${prefix}${item.valueLabel}`,
|
||||
});
|
||||
if (item.children && item.children.length > 0) {
|
||||
result.push(...flattenTree(item.children, depth + 1));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const loadedOptions = flattenTree(Array.isArray(data.data) ? data.data : []);
|
||||
console.log("[LayerCondition] 카테고리 옵션 설정:", loadedOptions.length, "개");
|
||||
setOptions(loadedOptions);
|
||||
} else {
|
||||
setOptions([]);
|
||||
}
|
||||
} else if (dataSourceInfo.type === "code" && dataSourceInfo.codeCategory) {
|
||||
// 코드 카테고리에서 옵션 로드
|
||||
const codes = await getCodesByCategory(dataSourceInfo.codeCategory);
|
||||
if (cancelled) return;
|
||||
setOptions(codes.map((code) => ({
|
||||
value: code.code,
|
||||
label: code.name,
|
||||
})));
|
||||
} else if (dataSourceInfo.type === "entity") {
|
||||
// 엔티티 참조에서 옵션 로드
|
||||
let entityLoaded = false;
|
||||
|
||||
if (dataSourceInfo.originTable && dataSourceInfo.originColumn) {
|
||||
try {
|
||||
const entityData = await EntityReferenceAPI.getEntityReferenceData(
|
||||
dataSourceInfo.originTable,
|
||||
dataSourceInfo.originColumn,
|
||||
{ limit: 100 }
|
||||
);
|
||||
if (cancelled) return;
|
||||
setOptions(entityData.options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})));
|
||||
entityLoaded = true;
|
||||
} catch {
|
||||
console.warn("원본 테이블.컬럼으로 엔티티 조회 실패, 직접 참조로 폴백");
|
||||
}
|
||||
}
|
||||
|
||||
// 폴백: 참조 테이블에서 직접 조회
|
||||
if (!entityLoaded && dataSourceInfo.referenceTable) {
|
||||
try {
|
||||
const refColumn = dataSourceInfo.referenceColumn || "id";
|
||||
const entityData = await EntityReferenceAPI.getEntityReferenceData(
|
||||
dataSourceInfo.referenceTable,
|
||||
refColumn,
|
||||
{ limit: 100 }
|
||||
);
|
||||
if (cancelled) return;
|
||||
setOptions(entityData.options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
})));
|
||||
entityLoaded = true;
|
||||
} catch {
|
||||
console.warn("직접 참조 테이블로도 엔티티 조회 실패");
|
||||
}
|
||||
}
|
||||
|
||||
if (!entityLoaded && !cancelled) {
|
||||
setOptions([]);
|
||||
}
|
||||
} else {
|
||||
if (!cancelled) setOptions([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("코드 목록 로드 실패:", error);
|
||||
setCodeLoadError(error.message || "코드 목록을 불러올 수 없습니다.");
|
||||
setCodeOptions([]);
|
||||
if (!cancelled) {
|
||||
console.error("옵션 목록 로드 실패:", error);
|
||||
setLoadError(error.message || "옵션 목록을 불러올 수 없습니다.");
|
||||
setOptions([]);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingCodes(false);
|
||||
if (!cancelled) {
|
||||
setIsLoadingOptions(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadCodes();
|
||||
}, [codeCategory]);
|
||||
loadOptions();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dataSourceKey]);
|
||||
|
||||
// 조건 저장
|
||||
const handleSave = useCallback(() => {
|
||||
|
|
@ -180,36 +409,91 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
<SelectValue placeholder="컴포넌트 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{triggerableComponents.length === 0 ? (
|
||||
{triggerableComponents.baseLayerTriggers.length === 0 &&
|
||||
triggerableComponents.otherLayerTriggers.length === 0 ? (
|
||||
<div className="p-2 text-xs text-muted-foreground text-center">
|
||||
조건 설정 가능한 컴포넌트가 없습니다.
|
||||
<br />
|
||||
(셀렉트, 라디오, 코드 타입)
|
||||
</div>
|
||||
) : (
|
||||
triggerableComponents.map((comp) => (
|
||||
<SelectItem key={comp.id} value={comp.id} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{getComponentLabel(comp)}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{comp.componentType || (comp as any).widgetType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
<>
|
||||
{/* 기본 레이어 컴포넌트 (우선 표시) */}
|
||||
{triggerableComponents.baseLayerTriggers.length > 0 && (
|
||||
<>
|
||||
{triggerableComponents.otherLayerTriggers.length > 0 && (
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground">
|
||||
기본 레이어
|
||||
</div>
|
||||
)}
|
||||
{triggerableComponents.baseLayerTriggers.map((comp) => (
|
||||
<SelectItem key={comp.id} value={comp.id} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{getComponentLabel(comp)}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{comp.componentType || (comp as any).widgetType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{/* 다른 레이어 컴포넌트 (하단에 구분하여 표시) */}
|
||||
{triggerableComponents.otherLayerTriggers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground border-t mt-1 pt-1">
|
||||
다른 레이어
|
||||
</div>
|
||||
{triggerableComponents.otherLayerTriggers.map((comp) => (
|
||||
<SelectItem key={comp.id} value={comp.id} className="text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{getComponentLabel(comp)}</span>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{comp.componentType || (comp as any).widgetType}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 코드 카테고리 표시 */}
|
||||
{codeCategory && (
|
||||
{/* 데이터 소스 표시 */}
|
||||
{dataSourceInfo.type === "code" && dataSourceInfo.codeCategory && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Code2 className="h-3 w-3" />
|
||||
<span>코드:</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{dataSourceInfo.codeCategory}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{dataSourceInfo.type === "entity" && (dataSourceInfo.referenceTable || dataSourceInfo.originTable) && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Database className="h-3 w-3" />
|
||||
<span>엔티티:</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{dataSourceInfo.referenceTable || `${dataSourceInfo.originTable}.${dataSourceInfo.originColumn}`}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{dataSourceInfo.type === "category" && dataSourceInfo.categoryTable && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Database className="h-3 w-3" />
|
||||
<span>카테고리:</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{codeCategory}
|
||||
{dataSourceInfo.categoryTable}.{dataSourceInfo.categoryColumn}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
{dataSourceInfo.type === "static" && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>정적 옵션</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
|
|
@ -241,42 +525,41 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
{operator === "in" ? "값 선택 (복수)" : "값"}
|
||||
</Label>
|
||||
|
||||
{isLoadingCodes ? (
|
||||
{isLoadingOptions ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground p-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
코드 목록 로딩 중...
|
||||
옵션 목록 로딩 중...
|
||||
</div>
|
||||
) : codeLoadError ? (
|
||||
) : loadError ? (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive p-2">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{codeLoadError}
|
||||
{loadError}
|
||||
</div>
|
||||
) : codeOptions.length > 0 ? (
|
||||
// 코드 카테고리가 있는 경우 - 선택 UI
|
||||
) : options.length > 0 ? (
|
||||
// 옵션이 있는 경우 - 선택 UI
|
||||
operator === "in" ? (
|
||||
// 다중 선택 (in 연산자)
|
||||
<div className="space-y-1 max-h-40 overflow-y-auto border rounded-md p-2">
|
||||
{codeOptions.map((code) => (
|
||||
{options.map((opt) => (
|
||||
<div
|
||||
key={code.codeValue}
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-1.5 rounded cursor-pointer text-xs hover:bg-accent",
|
||||
multiValues.includes(code.codeValue) && "bg-primary/10"
|
||||
multiValues.includes(opt.value) && "bg-primary/10"
|
||||
)}
|
||||
onClick={() => toggleMultiValue(code.codeValue)}
|
||||
onClick={() => toggleMultiValue(opt.value)}
|
||||
>
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center",
|
||||
multiValues.includes(code.codeValue)
|
||||
multiValues.includes(opt.value)
|
||||
? "bg-primary border-primary"
|
||||
: "border-input"
|
||||
)}>
|
||||
{multiValues.includes(code.codeValue) && (
|
||||
{multiValues.includes(opt.value) && (
|
||||
<Check className="h-3 w-3 text-primary-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<span>{code.codeName}</span>
|
||||
<span className="text-muted-foreground">({code.codeValue})</span>
|
||||
<span>{opt.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -287,20 +570,20 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
<SelectValue placeholder="값 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{codeOptions.map((code) => (
|
||||
{options.map((opt) => (
|
||||
<SelectItem
|
||||
key={code.codeValue}
|
||||
value={code.codeValue}
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="text-xs"
|
||||
>
|
||||
{code.codeName} ({code.codeValue})
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
) : (
|
||||
// 코드 카테고리가 없는 경우 - 직접 입력
|
||||
// 옵션이 없는 경우 - 직접 입력
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
|
|
@ -313,14 +596,14 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
{operator === "in" && multiValues.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{multiValues.map((val) => {
|
||||
const code = codeOptions.find((c) => c.codeValue === val);
|
||||
const opt = options.find((o) => o.value === val);
|
||||
return (
|
||||
<Badge
|
||||
key={val}
|
||||
variant="secondary"
|
||||
className="text-[10px] gap-1"
|
||||
>
|
||||
{code?.codeName || val}
|
||||
{opt?.label || val}
|
||||
<X
|
||||
className="h-2.5 w-2.5 cursor-pointer hover:text-destructive"
|
||||
onClick={() => toggleMultiValue(val)}
|
||||
|
|
@ -334,19 +617,65 @@ export const LayerConditionPanel: React.FC<LayerConditionPanelProps> = ({
|
|||
)}
|
||||
|
||||
{/* 현재 조건 요약 */}
|
||||
{targetComponentId && (value || multiValues.length > 0) && (
|
||||
{targetComponentId && selectedComponent && (value || multiValues.length > 0) && (
|
||||
<div className="p-2 bg-muted rounded-md text-xs">
|
||||
<span className="font-medium">요약: </span>
|
||||
<span className="text-muted-foreground">
|
||||
"{getComponentLabel(selectedComponent!)}" 값이{" "}
|
||||
{operator === "eq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 같으면`}
|
||||
{operator === "neq" && `"${codeOptions.find(c => c.codeValue === value)?.codeName || value}"와 다르면`}
|
||||
{operator === "in" && `[${multiValues.map(v => codeOptions.find(c => c.codeValue === v)?.codeName || v).join(", ")}] 중 하나이면`}
|
||||
"{getComponentLabel(selectedComponent)}" 값이{" "}
|
||||
{operator === "eq" && `"${options.find(o => o.value === value)?.label || value}"와 같으면`}
|
||||
{operator === "neq" && `"${options.find(o => o.value === value)?.label || value}"와 다르면`}
|
||||
{operator === "in" && `[${multiValues.map(v => options.find(o => o.value === v)?.label || v).join(", ")}] 중 하나이면`}
|
||||
{" "}이 레이어 표시
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 표시 영역 설정 */}
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<Label className="text-xs font-semibold">표시 영역</Label>
|
||||
|
||||
{layer.displayRegion ? (
|
||||
<>
|
||||
{/* 현재 영역 정보 표시 */}
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||
<div className="flex-1 text-[10px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{layer.displayRegion.width} x {layer.displayRegion.height}
|
||||
</span>
|
||||
<span className="ml-1">
|
||||
({layer.displayRegion.x}, {layer.displayRegion.y})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-[10px] text-destructive hover:text-destructive"
|
||||
onClick={() => onUpdateDisplayRegion(undefined)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
캔버스에서 점선 영역을 드래그하여 이동/리사이즈할 수 있습니다.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
좌측의 레이어 항목을 캔버스로
|
||||
</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
드래그&드롭하여 영역을 배치하세요
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
영역을 추가하면 조건 미충족 시 해당 영역이 사라지고 아래 컴포넌트가 위로 이동합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -561,9 +561,8 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
zIndex: position?.z || 1,
|
||||
// right 속성 강제 제거
|
||||
right: undefined,
|
||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
|
||||
transition:
|
||||
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
||||
// 모든 컴포넌트에서 transition 완전 제거 (위치 변경 시 애니메이션 방지)
|
||||
transition: "none",
|
||||
};
|
||||
|
||||
// 선택된 컴포넌트 스타일
|
||||
|
|
@ -594,7 +593,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
return (
|
||||
<div
|
||||
id={`component-${id}`}
|
||||
className="absolute cursor-pointer"
|
||||
className="absolute cursor-pointer !transition-none"
|
||||
style={{ ...componentStyle, ...selectionStyle }}
|
||||
onClick={handleClick}
|
||||
draggable
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import React, { useMemo, useSyncExternalStore } from "react";
|
||||
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import {
|
||||
|
|
@ -17,6 +17,12 @@ import {
|
|||
File,
|
||||
} from "lucide-react";
|
||||
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
|
||||
import {
|
||||
subscribe as canvasSplitSubscribe,
|
||||
getSnapshot as canvasSplitGetSnapshot,
|
||||
getServerSnapshot as canvasSplitGetServerSnapshot,
|
||||
subscribeDom as canvasSplitSubscribeDom,
|
||||
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
|
||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
|
||||
// 컴포넌트 렌더러들 자동 등록
|
||||
|
|
@ -388,10 +394,12 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
}
|
||||
: component;
|
||||
|
||||
// 🆕 분할 패널 리사이즈 Context
|
||||
// 기존 분할 패널 리사이즈 Context (레거시 split-panel-layout용)
|
||||
const splitPanelContext = useSplitPanel();
|
||||
|
||||
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
|
||||
// 캔버스 분할선 글로벌 스토어 (useSyncExternalStore로 직접 구독)
|
||||
const canvasSplit = useSyncExternalStore(canvasSplitSubscribe, canvasSplitGetSnapshot, canvasSplitGetServerSnapshot);
|
||||
|
||||
const componentType = (component as any).componentType || "";
|
||||
const componentId = (component as any).componentId || "";
|
||||
const widgetType = (component as any).widgetType || "";
|
||||
|
|
@ -402,137 +410,170 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
(["button-primary", "button-secondary"].includes(componentType) ||
|
||||
["button-primary", "button-secondary"].includes(componentId)));
|
||||
|
||||
// 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점)
|
||||
// 레거시 분할 패널용 refs
|
||||
const initialPanelRatioRef = React.useRef<number | null>(null);
|
||||
const initialPanelIdRef = React.useRef<string | null>(null);
|
||||
// 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지)
|
||||
const isInLeftPanelRef = React.useRef<boolean | null>(null);
|
||||
|
||||
// 🆕 분할 패널 위 버튼 위치 자동 조정
|
||||
const calculateButtonPosition = () => {
|
||||
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
|
||||
// 캔버스 분할선 좌/우 판정 (한 번만)
|
||||
const canvasSplitSideRef = React.useRef<"left" | "right" | null>(null);
|
||||
// 스코프 체크 캐시 (DOM 쿼리 최소화)
|
||||
const myScopeIdRef = React.useRef<string | null>(null);
|
||||
|
||||
const calculateSplitAdjustedPosition = () => {
|
||||
const isSplitLineComponent =
|
||||
type === "component" && componentType === "v2-split-line";
|
||||
|
||||
if (isSplitLineComponent) {
|
||||
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
// === 1. 캔버스 분할선 (글로벌 스토어) ===
|
||||
if (canvasSplit.active && canvasSplit.canvasWidth > 0 && canvasSplit.scopeId) {
|
||||
if (myScopeIdRef.current === null) {
|
||||
const el = document.getElementById(`component-${id}`);
|
||||
const container = el?.closest("[data-screen-runtime]");
|
||||
myScopeIdRef.current = container?.getAttribute("data-split-scope") || "__none__";
|
||||
}
|
||||
if (myScopeIdRef.current !== canvasSplit.scopeId) {
|
||||
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
const { initialDividerX, currentDividerX, canvasWidth, isDragging: splitDragging } = canvasSplit;
|
||||
const delta = currentDividerX - initialDividerX;
|
||||
|
||||
if (canvasSplitSideRef.current === null) {
|
||||
const origW = size?.width || 100;
|
||||
const componentCenterX = position.x + (origW / 2);
|
||||
canvasSplitSideRef.current = componentCenterX < initialDividerX ? "left" : "right";
|
||||
}
|
||||
|
||||
if (Math.abs(delta) < 1) {
|
||||
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging };
|
||||
}
|
||||
|
||||
// 영역별 비례 스케일링: 스플릿선이 벽 역할 → 절대 넘어가지 않음
|
||||
const origW = size?.width || 100;
|
||||
const GAP = 4;
|
||||
let adjustedX: number;
|
||||
let adjustedW: number;
|
||||
|
||||
if (canvasSplitSideRef.current === "left") {
|
||||
const initialZoneWidth = initialDividerX;
|
||||
const currentZoneWidth = Math.max(20, currentDividerX - GAP);
|
||||
const scale = initialZoneWidth > 0 ? currentZoneWidth / initialZoneWidth : 1;
|
||||
adjustedX = position.x * scale;
|
||||
adjustedW = origW * scale;
|
||||
if (adjustedX + adjustedW > currentDividerX - GAP) {
|
||||
adjustedW = currentDividerX - GAP - adjustedX;
|
||||
}
|
||||
} else {
|
||||
const initialRightWidth = canvasWidth - initialDividerX;
|
||||
const currentRightWidth = Math.max(20, canvasWidth - currentDividerX - GAP);
|
||||
const scale = initialRightWidth > 0 ? currentRightWidth / initialRightWidth : 1;
|
||||
const rightOffset = position.x - initialDividerX;
|
||||
adjustedX = currentDividerX + GAP + rightOffset * scale;
|
||||
adjustedW = origW * scale;
|
||||
if (adjustedX < currentDividerX + GAP) adjustedX = currentDividerX + GAP;
|
||||
if (adjustedX + adjustedW > canvasWidth) adjustedW = canvasWidth - adjustedX;
|
||||
}
|
||||
|
||||
adjustedX = Math.max(0, adjustedX);
|
||||
adjustedW = Math.max(20, adjustedW);
|
||||
|
||||
return { adjustedPositionX: adjustedX, adjustedWidth: adjustedW, isOnSplitPanel: true, isDraggingSplitPanel: splitDragging };
|
||||
}
|
||||
|
||||
// === 2. 레거시 분할 패널 (Context) - 버튼 전용 ===
|
||||
const isSplitPanelComponent =
|
||||
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
|
||||
|
||||
if (!isButtonComponent || isSplitPanelComponent) {
|
||||
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
if (isSplitPanelComponent) {
|
||||
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
if (!isButtonComponent) {
|
||||
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
const componentWidth = size?.width || 100;
|
||||
const componentHeight = size?.height || 40;
|
||||
|
||||
// 분할 패널 위에 있는지 확인 (원래 위치 기준)
|
||||
const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight);
|
||||
|
||||
// 분할 패널 위에 없으면 기준점 초기화
|
||||
if (!overlap) {
|
||||
if (initialPanelIdRef.current !== null) {
|
||||
initialPanelRatioRef.current = null;
|
||||
initialPanelIdRef.current = null;
|
||||
isInLeftPanelRef.current = null;
|
||||
}
|
||||
return {
|
||||
adjustedPositionX: position.x,
|
||||
isOnSplitPanel: false,
|
||||
isDraggingSplitPanel: false,
|
||||
};
|
||||
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: false, isDraggingSplitPanel: false };
|
||||
}
|
||||
|
||||
const { panel } = overlap;
|
||||
|
||||
// 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만)
|
||||
if (initialPanelIdRef.current !== overlap.panelId) {
|
||||
initialPanelRatioRef.current = panel.leftWidthPercent;
|
||||
initialPanelRatioRef.current = panel.initialLeftWidthPercent;
|
||||
initialPanelIdRef.current = overlap.panelId;
|
||||
|
||||
// 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산)
|
||||
// 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단
|
||||
const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
|
||||
const initialDividerX = panel.x + (panel.width * panel.initialLeftWidthPercent) / 100;
|
||||
const componentCenterX = position.x + componentWidth / 2;
|
||||
const relativeX = componentCenterX - panel.x;
|
||||
const wasInLeftPanel = relativeX < initialLeftPanelWidth;
|
||||
|
||||
isInLeftPanelRef.current = wasInLeftPanel;
|
||||
console.log("📌 [버튼 기준점 설정]:", {
|
||||
componentId: component.id,
|
||||
panelId: overlap.panelId,
|
||||
initialRatio: panel.leftWidthPercent,
|
||||
isInLeftPanel: wasInLeftPanel,
|
||||
buttonCenterX: componentCenterX,
|
||||
leftPanelWidth: initialLeftPanelWidth,
|
||||
});
|
||||
isInLeftPanelRef.current = componentCenterX < initialDividerX;
|
||||
}
|
||||
|
||||
// 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준)
|
||||
if (!isInLeftPanelRef.current) {
|
||||
return {
|
||||
adjustedPositionX: position.x,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: panel.isDragging,
|
||||
};
|
||||
}
|
||||
const baseRatio = initialPanelRatioRef.current ?? panel.initialLeftWidthPercent;
|
||||
const initialDividerX = panel.x + (panel.width * baseRatio) / 100;
|
||||
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100;
|
||||
const dividerDelta = currentDividerX - initialDividerX;
|
||||
|
||||
// 초기 기준 비율 (버튼이 처음 배치될 때의 비율)
|
||||
const baseRatio = initialPanelRatioRef.current ?? panel.leftWidthPercent;
|
||||
|
||||
// 기준 비율 대비 현재 비율로 분할선 위치 계산
|
||||
const baseDividerX = panel.x + (panel.width * baseRatio) / 100; // 초기 분할선 위치
|
||||
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; // 현재 분할선 위치
|
||||
|
||||
// 분할선 이동량 (px)
|
||||
const dividerDelta = currentDividerX - baseDividerX;
|
||||
|
||||
// 변화가 없으면 원래 위치 반환
|
||||
if (Math.abs(dividerDelta) < 1) {
|
||||
return {
|
||||
adjustedPositionX: position.x,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: panel.isDragging,
|
||||
};
|
||||
return { adjustedPositionX: position.x, adjustedWidth: null, isOnSplitPanel: true, isDraggingSplitPanel: panel.isDragging };
|
||||
}
|
||||
|
||||
// 🆕 버튼도 분할선과 같은 양만큼 이동
|
||||
// 분할선이 왼쪽으로 100px 이동하면, 버튼도 왼쪽으로 100px 이동
|
||||
const adjustedX = position.x + dividerDelta;
|
||||
|
||||
console.log("📍 [버튼 위치 조정]:", {
|
||||
componentId: component.id,
|
||||
originalX: position.x,
|
||||
adjustedX,
|
||||
dividerDelta,
|
||||
baseRatio,
|
||||
currentRatio: panel.leftWidthPercent,
|
||||
baseDividerX,
|
||||
currentDividerX,
|
||||
isDragging: panel.isDragging,
|
||||
});
|
||||
const adjustedX = isInLeftPanelRef.current ? position.x + dividerDelta : position.x;
|
||||
|
||||
return {
|
||||
adjustedPositionX: adjustedX,
|
||||
adjustedWidth: null,
|
||||
isOnSplitPanel: true,
|
||||
isDraggingSplitPanel: panel.isDragging,
|
||||
};
|
||||
};
|
||||
|
||||
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateButtonPosition();
|
||||
const { adjustedPositionX, adjustedWidth: splitAdjustedWidth, isOnSplitPanel, isDraggingSplitPanel } = calculateSplitAdjustedPosition();
|
||||
|
||||
// 🆕 리사이즈 크기가 있으면 우선 사용
|
||||
// (size가 업데이트되면 위 useEffect에서 resizeSize를 null로 설정)
|
||||
const displayWidth = resizeSize ? `${resizeSize.width}px` : getWidth();
|
||||
const displayHeight = resizeSize ? `${resizeSize.height}px` : getHeight();
|
||||
|
||||
const isSplitActive = canvasSplit.active && canvasSplit.scopeId && myScopeIdRef.current === canvasSplit.scopeId;
|
||||
|
||||
const origWidth = size?.width || 100;
|
||||
const isSplitShrunk = splitAdjustedWidth !== null && splitAdjustedWidth < origWidth;
|
||||
|
||||
// v2 수평 라벨 컴포넌트: position wrapper에서 border 제거 (DynamicComponentRenderer가 내부에서 처리)
|
||||
const isV2HorizLabel = !!(
|
||||
componentStyle &&
|
||||
(componentStyle.labelDisplay === true || componentStyle.labelDisplay === "true") &&
|
||||
(componentStyle.labelPosition === "left" || componentStyle.labelPosition === "right")
|
||||
);
|
||||
const safeComponentStyle = isV2HorizLabel
|
||||
? (() => {
|
||||
const { borderWidth, borderColor, borderStyle, border, borderRadius, ...rest } = componentStyle as any;
|
||||
return rest;
|
||||
})()
|
||||
: componentStyle;
|
||||
|
||||
const baseStyle = {
|
||||
left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용
|
||||
left: `${adjustedPositionX}px`,
|
||||
top: `${position.y}px`,
|
||||
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
|
||||
width: displayWidth, // 🆕 리사이즈 중이면 resizeSize 사용
|
||||
height: displayHeight, // 🆕 리사이즈 중이면 resizeSize 사용
|
||||
...safeComponentStyle,
|
||||
width: splitAdjustedWidth !== null ? `${splitAdjustedWidth}px` : displayWidth,
|
||||
height: displayHeight,
|
||||
zIndex: component.type === "layout" ? 1 : position.z || 2,
|
||||
right: undefined,
|
||||
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동, 리사이즈 중에도 트랜지션 없음
|
||||
overflow: isSplitShrunk ? "hidden" as const : undefined,
|
||||
willChange: canvasSplit.isDragging && isSplitActive ? "left, width" as const : undefined,
|
||||
transition:
|
||||
isResizing ? "none" :
|
||||
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
|
||||
isOnSplitPanel ? (isDraggingSplitPanel ? "none" : "left 0.15s ease-out, width 0.15s ease-out") : undefined,
|
||||
};
|
||||
|
||||
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)
|
||||
|
|
@ -576,6 +617,60 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
onDragEnd?.();
|
||||
};
|
||||
|
||||
const splitAdjustedComp = React.useMemo(() => {
|
||||
if (isSplitShrunk && splitAdjustedWidth !== null) {
|
||||
return { ...enhancedComponent, size: { ...(enhancedComponent as any).size, width: Math.round(splitAdjustedWidth) } };
|
||||
}
|
||||
return enhancedComponent;
|
||||
}, [enhancedComponent, isSplitShrunk, splitAdjustedWidth]);
|
||||
|
||||
// 드래그 중 DOM 직접 조작 (React 리렌더 없이 매 프레임 업데이트)
|
||||
React.useEffect(() => {
|
||||
const isSplitLine = type === "component" && componentType === "v2-split-line";
|
||||
if (isSplitLine) return;
|
||||
|
||||
const unsubscribe = canvasSplitSubscribeDom((snap) => {
|
||||
if (!snap.isDragging || !snap.active || !snap.scopeId) return;
|
||||
if (myScopeIdRef.current !== snap.scopeId) return;
|
||||
const el = outerDivRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const origX = position.x;
|
||||
const oW = size?.width || 100;
|
||||
const { initialDividerX, currentDividerX, canvasWidth } = snap;
|
||||
const delta = currentDividerX - initialDividerX;
|
||||
if (Math.abs(delta) < 1) return;
|
||||
|
||||
if (canvasSplitSideRef.current === null) {
|
||||
canvasSplitSideRef.current = (origX + oW / 2) < initialDividerX ? "left" : "right";
|
||||
}
|
||||
|
||||
const GAP = 4;
|
||||
let nx: number, nw: number;
|
||||
if (canvasSplitSideRef.current === "left") {
|
||||
const scale = initialDividerX > 0 ? Math.max(20, currentDividerX - GAP) / initialDividerX : 1;
|
||||
nx = origX * scale;
|
||||
nw = oW * scale;
|
||||
if (nx + nw > currentDividerX - GAP) nw = currentDividerX - GAP - nx;
|
||||
} else {
|
||||
const irw = canvasWidth - initialDividerX;
|
||||
const crw = Math.max(20, canvasWidth - currentDividerX - GAP);
|
||||
const scale = irw > 0 ? crw / irw : 1;
|
||||
nx = currentDividerX + GAP + (origX - initialDividerX) * scale;
|
||||
nw = oW * scale;
|
||||
if (nx < currentDividerX + GAP) nx = currentDividerX + GAP;
|
||||
if (nx + nw > canvasWidth) nw = canvasWidth - nx;
|
||||
}
|
||||
nx = Math.max(0, nx);
|
||||
nw = Math.max(20, nw);
|
||||
|
||||
el.style.left = `${nx}px`;
|
||||
el.style.width = `${Math.round(nw)}px`;
|
||||
el.style.overflow = nw < oW ? "hidden" : "";
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [id, position.x, size?.width, type, componentType]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outerDivRef}
|
||||
|
|
@ -602,7 +697,7 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
|||
style={{ width: "100%", maxWidth: "100%" }}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={enhancedComponent}
|
||||
component={splitAdjustedComp}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
isInteractive={!isDesignMode} // 편집 모드가 아닐 때만 인터랙티브
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Database, Cog } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -511,25 +512,114 @@ export default function ScreenDesigner({
|
|||
return lines;
|
||||
}, [layout.gridSettings?.showGrid, screenResolution.width, screenResolution.height]);
|
||||
|
||||
// 🆕 레이어 활성 상태 관리 (LayerProvider 외부에서 관리)
|
||||
const [activeLayerId, setActiveLayerIdLocal] = useState<string | null>("default-layer");
|
||||
// 🆕 현재 편집 중인 레이어 ID (DB의 layer_id, 1 = 기본 레이어)
|
||||
const [activeLayerId, setActiveLayerIdLocal] = useState<number>(1);
|
||||
const activeLayerIdRef = useRef<number>(1);
|
||||
const setActiveLayerIdWithRef = useCallback((id: number) => {
|
||||
setActiveLayerIdLocal(id);
|
||||
activeLayerIdRef.current = id;
|
||||
}, []);
|
||||
|
||||
// 캔버스에 렌더링할 컴포넌트 필터링 (레이어 기반)
|
||||
// 활성 레이어가 있으면 해당 레이어의 컴포넌트만 표시
|
||||
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
|
||||
const visibleComponents = useMemo(() => {
|
||||
// 레이어 시스템이 활성화되지 않았거나 활성 레이어가 없으면 모든 컴포넌트 표시
|
||||
if (!activeLayerId) {
|
||||
return layout.components;
|
||||
// 🆕 좌측 패널 탭 상태 관리
|
||||
const [leftPanelTab, setLeftPanelTab] = useState<string>("components");
|
||||
|
||||
// 🆕 조건부 영역(Zone) 목록 (DB screen_conditional_zones 기반)
|
||||
const [zones, setZones] = useState<import("@/types/screen-management").ConditionalZone[]>([]);
|
||||
|
||||
// 🆕 조건부 영역 드래그 상태 (캔버스에서 드래그로 영역 설정)
|
||||
const [regionDrag, setRegionDrag] = useState<{
|
||||
isDrawing: boolean; // 새 영역 그리기 모드
|
||||
isDragging: boolean; // 기존 영역 이동 모드
|
||||
isResizing: boolean; // 기존 영역 리사이즈 모드
|
||||
targetLayerId: string | null; // 대상 Zone ID (문자열)
|
||||
startX: number;
|
||||
startY: number;
|
||||
currentX: number;
|
||||
currentY: number;
|
||||
resizeHandle: string | null; // 리사이즈 핸들 위치
|
||||
originalRegion: { x: number; y: number; width: number; height: number } | null;
|
||||
}>({
|
||||
isDrawing: false,
|
||||
isDragging: false,
|
||||
isResizing: false,
|
||||
targetLayerId: null,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
currentX: 0,
|
||||
currentY: 0,
|
||||
resizeHandle: null,
|
||||
originalRegion: null,
|
||||
});
|
||||
|
||||
// 현재 활성 레이어의 Zone 정보 (캔버스 크기 결정용)
|
||||
const [activeLayerZone, setActiveLayerZone] = useState<import("@/types/screen-management").ConditionalZone | null>(null);
|
||||
|
||||
// 다른 레이어의 컴포넌트 메타 정보 캐시 (데이터 전달 타겟 선택용)
|
||||
const [otherLayerComponents, setOtherLayerComponents] = useState<ComponentData[]>([]);
|
||||
|
||||
// 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기
|
||||
useEffect(() => {
|
||||
if (activeLayerId <= 1 || !selectedScreen?.screenId) {
|
||||
setActiveLayerZone(null);
|
||||
return;
|
||||
}
|
||||
// 레이어의 condition_config에서 zone_id를 가져와서 zones에서 찾기
|
||||
const findZone = async () => {
|
||||
try {
|
||||
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, activeLayerId);
|
||||
const zoneId = layerData?.conditionConfig?.zone_id;
|
||||
if (zoneId) {
|
||||
const zone = zones.find(z => z.zone_id === zoneId);
|
||||
setActiveLayerZone(zone || null);
|
||||
} else {
|
||||
setActiveLayerZone(null);
|
||||
}
|
||||
} catch {
|
||||
setActiveLayerZone(null);
|
||||
}
|
||||
};
|
||||
findZone();
|
||||
}, [activeLayerId, selectedScreen?.screenId, zones]);
|
||||
|
||||
// 활성 레이어에 속한 컴포넌트만 필터링
|
||||
return layout.components.filter((comp) => {
|
||||
// layerId가 없는 컴포넌트는 기본 레이어("default-layer")에 속한 것으로 처리
|
||||
const compLayerId = comp.layerId || "default-layer";
|
||||
return compLayerId === activeLayerId;
|
||||
});
|
||||
}, [layout.components, activeLayerId]);
|
||||
// 다른 레이어의 컴포넌트 메타 정보 로드 (데이터 전달 타겟 선택용)
|
||||
useEffect(() => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
const loadOtherLayerComponents = async () => {
|
||||
try {
|
||||
const allLayers = await screenApi.getScreenLayers(selectedScreen.screenId);
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
const otherLayers = allLayers.filter((l: any) => l.layer_id !== currentLayerId && l.layer_id > 0);
|
||||
|
||||
const components: ComponentData[] = [];
|
||||
for (const layerInfo of otherLayers) {
|
||||
try {
|
||||
const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerInfo.layer_id);
|
||||
const rawComps = layerData?.components;
|
||||
if (rawComps && Array.isArray(rawComps)) {
|
||||
for (const comp of rawComps) {
|
||||
components.push({
|
||||
...comp,
|
||||
_layerName: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`,
|
||||
_layerId: String(layerInfo.layer_id),
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 개별 레이어 로드 실패 무시
|
||||
}
|
||||
}
|
||||
setOtherLayerComponents(components);
|
||||
} catch {
|
||||
setOtherLayerComponents([]);
|
||||
}
|
||||
};
|
||||
loadOtherLayerComponents();
|
||||
}, [selectedScreen?.screenId, activeLayerId]);
|
||||
|
||||
// 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시)
|
||||
const visibleComponents = useMemo(() => {
|
||||
return layout.components;
|
||||
}, [layout.components]);
|
||||
|
||||
// 이미 배치된 컬럼 목록 계산
|
||||
const placedColumns = useMemo(() => {
|
||||
|
|
@ -1549,6 +1639,12 @@ export default function ScreenDesigner({
|
|||
|
||||
// 파일 컴포넌트 데이터 복원 (비동기)
|
||||
restoreFileComponentsData(layoutWithDefaultGrid.components);
|
||||
|
||||
// 🆕 조건부 영역(Zone) 로드
|
||||
try {
|
||||
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
|
||||
setZones(loadedZones);
|
||||
} catch { /* Zone 로드 실패 무시 */ }
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error("레이아웃 로드 실패:", error);
|
||||
|
|
@ -1888,17 +1984,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(
|
||||
|
|
@ -1970,30 +2082,12 @@ export default function ScreenDesigner({
|
|||
// 현재 선택된 테이블을 화면의 기본 테이블로 저장
|
||||
const currentMainTableName = tables.length > 0 ? tables[0].tableName : null;
|
||||
|
||||
// 🆕 레이어 정보도 함께 저장 (레이어가 있으면 레이어의 컴포넌트로 업데이트)
|
||||
const updatedLayers = layout.layers?.map((layer) => ({
|
||||
...layer,
|
||||
components: layer.components.map((comp) => {
|
||||
// 분할 패널 업데이트 로직 적용
|
||||
const updatedComp = updatedComponents.find((uc) => uc.id === comp.id);
|
||||
return updatedComp || comp;
|
||||
}),
|
||||
}));
|
||||
|
||||
const layoutWithResolution = {
|
||||
...layout,
|
||||
components: updatedComponents,
|
||||
layers: updatedLayers, // 🆕 레이어 정보 포함
|
||||
screenResolution: screenResolution,
|
||||
mainTableName: currentMainTableName, // 화면의 기본 테이블
|
||||
};
|
||||
// 🔍 버튼 컴포넌트들의 action.type 확인
|
||||
const buttonComponents = layoutWithResolution.components.filter(
|
||||
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
|
||||
);
|
||||
// 💾 저장 로그 (디버그 완료 - 간소화)
|
||||
// console.log("💾 저장 시작:", { screenId: selectedScreen.screenId, componentsCount: layoutWithResolution.components.length });
|
||||
// 분할 패널 디버그 로그 (주석 처리)
|
||||
|
||||
// V2/POP API 사용 여부에 따라 분기
|
||||
const v2Layout = convertLegacyToV2(layoutWithResolution);
|
||||
|
|
@ -2001,9 +2095,13 @@ export default function ScreenDesigner({
|
|||
// POP 모드: screen_layouts_pop 테이블에 저장
|
||||
await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout);
|
||||
} else if (USE_V2_API) {
|
||||
// 데스크톱 V2 모드: screen_layouts_v2 테이블에 저장
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, v2Layout);
|
||||
// console.log("📦 V2 레이아웃 저장:", v2Layout.components.length, "개 컴포넌트");
|
||||
// 레이어 기반 저장: 현재 활성 레이어의 layout만 저장
|
||||
const currentLayerId = activeLayerIdRef.current || 1;
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, {
|
||||
...v2Layout,
|
||||
layerId: currentLayerId,
|
||||
mainTableName: currentMainTableName, // 화면의 기본 테이블 (DB 업데이트용)
|
||||
});
|
||||
} else {
|
||||
await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution);
|
||||
}
|
||||
|
|
@ -2120,7 +2218,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);
|
||||
}
|
||||
|
|
@ -2539,10 +2642,10 @@ export default function ScreenDesigner({
|
|||
}
|
||||
});
|
||||
|
||||
// 🆕 현재 활성 레이어에 컴포넌트 추가
|
||||
// 🆕 현재 활성 레이어에 컴포넌트 추가 (ref 사용으로 클로저 문제 방지)
|
||||
const componentsWithLayerId = newComponents.map((comp) => ({
|
||||
...comp,
|
||||
layerId: activeLayerId || "default-layer",
|
||||
layerId: activeLayerIdRef.current || 1,
|
||||
}));
|
||||
|
||||
// 레이아웃에 새 컴포넌트들 추가
|
||||
|
|
@ -2561,7 +2664,7 @@ export default function ScreenDesigner({
|
|||
|
||||
toast.success(`${template.name} 템플릿이 추가되었습니다.`);
|
||||
},
|
||||
[layout, selectedScreen, saveToHistory, activeLayerId],
|
||||
[layout, selectedScreen, saveToHistory],
|
||||
);
|
||||
|
||||
// 레이아웃 드래그 처리
|
||||
|
|
@ -2615,7 +2718,7 @@ export default function ScreenDesigner({
|
|||
label: layoutData.label,
|
||||
allowedComponentTypes: layoutData.allowedComponentTypes,
|
||||
dropZoneConfig: layoutData.dropZoneConfig,
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
} as ComponentData;
|
||||
|
||||
// 레이아웃에 새 컴포넌트 추가
|
||||
|
|
@ -2632,7 +2735,7 @@ export default function ScreenDesigner({
|
|||
|
||||
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||
},
|
||||
[layout, screenResolution, saveToHistory, zoomLevel, activeLayerId],
|
||||
[layout, screenResolution, saveToHistory, zoomLevel],
|
||||
);
|
||||
|
||||
// handleZoneComponentDrop은 handleComponentDrop으로 대체됨
|
||||
|
|
@ -3024,9 +3127,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 =
|
||||
|
|
@ -3223,7 +3330,7 @@ export default function ScreenDesigner({
|
|||
position: snappedPosition,
|
||||
size: componentSize,
|
||||
gridColumns: gridColumns, // 컴포넌트별 그리드 컬럼 수 적용
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
componentConfig: {
|
||||
type: component.id, // 새 컴포넌트 시스템의 ID 사용
|
||||
webType: component.webType, // 웹타입 정보 추가
|
||||
|
|
@ -3257,7 +3364,7 @@ export default function ScreenDesigner({
|
|||
|
||||
toast.success(`${component.name} 컴포넌트가 추가되었습니다.`);
|
||||
},
|
||||
[layout, selectedScreen, saveToHistory, activeLayerId],
|
||||
[layout, selectedScreen, saveToHistory],
|
||||
);
|
||||
|
||||
// 드래그 앤 드롭 처리
|
||||
|
|
@ -3266,7 +3373,7 @@ export default function ScreenDesigner({
|
|||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const dragData = e.dataTransfer.getData("application/json");
|
||||
|
|
@ -3298,6 +3405,31 @@ export default function ScreenDesigner({
|
|||
return;
|
||||
}
|
||||
|
||||
// 🆕 조건부 영역(Zone) 생성 드래그인 경우 → DB screen_conditional_zones에 저장
|
||||
if (parsedData.type === "create-zone" && selectedScreen?.screenId) {
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!canvasRect) return;
|
||||
const dropX = Math.round((e.clientX - canvasRect.left) / zoomLevel);
|
||||
const dropY = Math.round((e.clientY - canvasRect.top) / zoomLevel);
|
||||
try {
|
||||
await screenApi.createZone(selectedScreen.screenId, {
|
||||
zone_name: "조건부 영역",
|
||||
x: Math.max(0, dropX - 400),
|
||||
y: Math.max(0, dropY),
|
||||
width: Math.min(800, screenResolution.width),
|
||||
height: 200,
|
||||
});
|
||||
// Zone 목록 새로고침
|
||||
const loadedZones = await screenApi.getScreenZones(selectedScreen.screenId);
|
||||
setZones(loadedZones);
|
||||
toast.success("조건부 영역이 생성되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("Zone 생성 실패:", error);
|
||||
toast.error("조건부 영역 생성에 실패했습니다.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 테이블/컬럼 드래그 처리
|
||||
const { type, table, column } = parsedData;
|
||||
|
||||
|
|
@ -3629,7 +3761,7 @@ export default function ScreenDesigner({
|
|||
tableName: table.tableName,
|
||||
position: { x, y, z: 1 } as Position,
|
||||
size: { width: 300, height: 200 },
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
style: {
|
||||
labelDisplay: true,
|
||||
labelFontSize: "14px",
|
||||
|
|
@ -3874,13 +4006,13 @@ export default function ScreenDesigner({
|
|||
label: column.columnLabel || column.columnName,
|
||||
tableName: table.tableName,
|
||||
columnName: column.columnName,
|
||||
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
|
||||
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
|
||||
parentId: formContainerId, // 폼 컨테이너의 자식으로 설정
|
||||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
required: isEntityJoinColumn ? false : column.required,
|
||||
readonly: false,
|
||||
parentId: formContainerId,
|
||||
componentType: v2Mapping.componentType,
|
||||
position: { x: relativeX, y: relativeY, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
|
|
@ -3901,12 +4033,11 @@ export default function ScreenDesigner({
|
|||
},
|
||||
componentConfig: {
|
||||
type: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정
|
||||
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
|
||||
...v2Mapping.componentConfig,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용
|
||||
|
|
@ -3942,12 +4073,12 @@ export default function ScreenDesigner({
|
|||
label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명
|
||||
tableName: table.tableName,
|
||||
columnName: column.columnName,
|
||||
required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님
|
||||
readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용
|
||||
componentType: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
required: isEntityJoinColumn ? false : column.required,
|
||||
readonly: false,
|
||||
componentType: v2Mapping.componentType,
|
||||
position: { x, y, z: 1 } as Position,
|
||||
size: { width: componentWidth, height: getDefaultHeight(column.widgetType) },
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 추가
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용)
|
||||
// 코드 타입인 경우 코드 카테고리 정보 추가
|
||||
...(column.widgetType === "code" &&
|
||||
column.codeCategory && {
|
||||
|
|
@ -3968,8 +4099,7 @@ export default function ScreenDesigner({
|
|||
},
|
||||
componentConfig: {
|
||||
type: v2Mapping.componentType, // v2-input, v2-select 등
|
||||
...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정
|
||||
...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화
|
||||
...v2Mapping.componentConfig,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -4205,9 +4335,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,
|
||||
};
|
||||
|
||||
|
|
@ -4770,7 +4906,7 @@ export default function ScreenDesigner({
|
|||
z: clipComponent.position.z || 1,
|
||||
} as Position,
|
||||
parentId: undefined, // 붙여넣기 시 부모 관계 해제
|
||||
layerId: activeLayerId || "default-layer", // 🆕 현재 활성 레이어에 붙여넣기
|
||||
layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 붙여넣기 (ref 사용)
|
||||
};
|
||||
newComponents.push(newComponent);
|
||||
});
|
||||
|
|
@ -4791,7 +4927,7 @@ export default function ScreenDesigner({
|
|||
|
||||
// console.log("컴포넌트 붙여넣기 완료:", newComponents.length, "개");
|
||||
toast.success(`${newComponents.length}개 컴포넌트가 붙여넣어졌습니다.`);
|
||||
}, [clipboard, layout, saveToHistory, activeLayerId]);
|
||||
}, [clipboard, layout, saveToHistory]);
|
||||
|
||||
// 🆕 플로우 버튼 그룹 생성 (다중 선택된 버튼들을 한 번에 그룹으로)
|
||||
// 🆕 플로우 버튼 그룹 다이얼로그 상태
|
||||
|
|
@ -5456,8 +5592,12 @@ export default function ScreenDesigner({
|
|||
return false;
|
||||
}
|
||||
|
||||
// 6. 삭제 (단일/다중 선택 지원)
|
||||
if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) {
|
||||
// 6. 삭제 (단일/다중 선택 지원) - Delete 또는 Backspace(Mac)
|
||||
const isInputFocused = document.activeElement instanceof HTMLInputElement ||
|
||||
document.activeElement instanceof HTMLTextAreaElement ||
|
||||
document.activeElement instanceof HTMLSelectElement ||
|
||||
(document.activeElement as HTMLElement)?.isContentEditable;
|
||||
if ((e.key === "Delete" || (e.key === "Backspace" && !isInputFocused)) && (selectedComponent || groupState.selectedComponents.length > 0)) {
|
||||
// console.log("🗑️ 컴포넌트 삭제 (단축키)");
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
|
@ -5500,7 +5640,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);
|
||||
}
|
||||
|
|
@ -5693,21 +5838,124 @@ export default function ScreenDesigner({
|
|||
};
|
||||
}, [layout, selectedComponent]);
|
||||
|
||||
// 🆕 조건부 영역 드래그 핸들러 (이동/리사이즈, DB 기반)
|
||||
const handleRegionMouseDown = useCallback((
|
||||
e: React.MouseEvent,
|
||||
layerId: string,
|
||||
mode: "move" | "resize",
|
||||
handle?: string,
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const zoneId = Number(layerId); // layerId는 실제로 zoneId
|
||||
const zone = zones.find(z => z.zone_id === zoneId);
|
||||
if (!zone) return;
|
||||
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!canvasRect) return;
|
||||
|
||||
const x = (e.clientX - canvasRect.left) / zoomLevel;
|
||||
const y = (e.clientY - canvasRect.top) / zoomLevel;
|
||||
|
||||
setRegionDrag({
|
||||
isDrawing: false,
|
||||
isDragging: mode === "move",
|
||||
isResizing: mode === "resize",
|
||||
targetLayerId: String(zoneId),
|
||||
startX: x,
|
||||
startY: y,
|
||||
currentX: x,
|
||||
currentY: y,
|
||||
resizeHandle: handle || null,
|
||||
originalRegion: { x: zone.x, y: zone.y, width: zone.width, height: zone.height },
|
||||
});
|
||||
}, [zones, zoomLevel]);
|
||||
|
||||
// 🆕 캔버스 마우스 이벤트 (영역 이동/리사이즈)
|
||||
const handleRegionCanvasMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!regionDrag.isDragging && !regionDrag.isResizing) return;
|
||||
if (!regionDrag.targetLayerId) return;
|
||||
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!canvasRect) return;
|
||||
|
||||
const x = (e.clientX - canvasRect.left) / zoomLevel;
|
||||
const y = (e.clientY - canvasRect.top) / zoomLevel;
|
||||
|
||||
if (regionDrag.isDragging && regionDrag.originalRegion) {
|
||||
const dx = x - regionDrag.startX;
|
||||
const dy = y - regionDrag.startY;
|
||||
const newRegion = {
|
||||
x: Math.max(0, Math.round(regionDrag.originalRegion.x + dx)),
|
||||
y: Math.max(0, Math.round(regionDrag.originalRegion.y + dy)),
|
||||
width: regionDrag.originalRegion.width,
|
||||
height: regionDrag.originalRegion.height,
|
||||
};
|
||||
const zoneId = Number(regionDrag.targetLayerId);
|
||||
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
|
||||
} else if (regionDrag.isResizing && regionDrag.originalRegion) {
|
||||
const dx = x - regionDrag.startX;
|
||||
const dy = y - regionDrag.startY;
|
||||
const orig = regionDrag.originalRegion;
|
||||
const newRegion = { ...orig };
|
||||
|
||||
const handle = regionDrag.resizeHandle;
|
||||
if (handle?.includes("e")) newRegion.width = Math.max(50, Math.round(orig.width + dx));
|
||||
if (handle?.includes("s")) newRegion.height = Math.max(30, Math.round(orig.height + dy));
|
||||
if (handle?.includes("w")) {
|
||||
newRegion.x = Math.max(0, Math.round(orig.x + dx));
|
||||
newRegion.width = Math.max(50, Math.round(orig.width - dx));
|
||||
}
|
||||
if (handle?.includes("n")) {
|
||||
newRegion.y = Math.max(0, Math.round(orig.y + dy));
|
||||
newRegion.height = Math.max(30, Math.round(orig.height - dy));
|
||||
}
|
||||
|
||||
const zoneId = Number(regionDrag.targetLayerId);
|
||||
setZones((prev) => prev.map(z => z.zone_id === zoneId ? { ...z, ...newRegion } : z));
|
||||
}
|
||||
}, [regionDrag, zoomLevel]);
|
||||
|
||||
const handleRegionCanvasMouseUp = useCallback(async () => {
|
||||
// 드래그 완료 시 DB에 Zone 저장
|
||||
if ((regionDrag.isDragging || regionDrag.isResizing) && regionDrag.targetLayerId) {
|
||||
const zoneId = Number(regionDrag.targetLayerId);
|
||||
const zone = zones.find(z => z.zone_id === zoneId);
|
||||
if (zone) {
|
||||
try {
|
||||
await screenApi.updateZone(zoneId, {
|
||||
x: zone.x, y: zone.y, width: zone.width, height: zone.height,
|
||||
});
|
||||
} catch {
|
||||
console.error("Zone 저장 실패");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 드래그 상태 초기화
|
||||
setRegionDrag({
|
||||
isDrawing: false,
|
||||
isDragging: false,
|
||||
isResizing: false,
|
||||
targetLayerId: null,
|
||||
startX: 0, startY: 0, currentX: 0, currentY: 0,
|
||||
resizeHandle: null,
|
||||
originalRegion: null,
|
||||
});
|
||||
}, [regionDrag, zones]);
|
||||
|
||||
// 🆕 레이어 변경 핸들러 - 레이어 컨텍스트에서 레이어가 변경되면 layout에도 반영
|
||||
// 주의: layout.components는 layerId 속성으로 레이어를 구분하므로, 여기서 덮어쓰지 않음
|
||||
// Zone 기반이므로 displayRegion 보존 불필요
|
||||
const handleLayersChange = useCallback((newLayers: LayerDefinition[]) => {
|
||||
setLayout((prevLayout) => ({
|
||||
...prevLayout,
|
||||
layers: newLayers,
|
||||
// components는 그대로 유지 - layerId 속성으로 레이어 구분
|
||||
// components: prevLayout.components (기본값으로 유지됨)
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 🆕 활성 레이어 변경 핸들러
|
||||
const handleActiveLayerChange = useCallback((newActiveLayerId: string | null) => {
|
||||
setActiveLayerIdLocal(newActiveLayerId);
|
||||
}, []);
|
||||
const handleActiveLayerChange = useCallback((newActiveLayerId: number) => {
|
||||
setActiveLayerIdWithRef(newActiveLayerId);
|
||||
}, [setActiveLayerIdWithRef]);
|
||||
|
||||
// 🆕 초기 레이어 계산 - layout에서 layers가 있으면 사용, 없으면 기본 레이어 생성
|
||||
// 주의: components는 layout.components에 layerId 속성으로 저장되므로, layer.components는 비워둠
|
||||
|
|
@ -5788,7 +6036,7 @@ export default function ScreenDesigner({
|
|||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<Tabs defaultValue="components" className="flex min-h-0 flex-1 flex-col">
|
||||
<Tabs value={leftPanelTab} onValueChange={setLeftPanelTab} className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList className="mx-4 mt-2 grid h-8 w-auto grid-cols-3 gap-1">
|
||||
<TabsTrigger value="components" className="text-xs">
|
||||
컴포넌트
|
||||
|
|
@ -5821,9 +6069,43 @@ export default function ScreenDesigner({
|
|||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* 🆕 레이어 관리 탭 */}
|
||||
{/* 🆕 레이어 관리 탭 (DB 기반) */}
|
||||
<TabsContent value="layers" className="mt-0 flex-1 overflow-hidden">
|
||||
<LayerManagerPanel components={layout.components} />
|
||||
<LayerManagerPanel
|
||||
screenId={selectedScreen?.screenId || null}
|
||||
activeLayerId={Number(activeLayerIdRef.current) || 1}
|
||||
onLayerChange={async (layerId) => {
|
||||
if (!selectedScreen?.screenId) return;
|
||||
try {
|
||||
// 1. 현재 레이어 저장
|
||||
const curId = Number(activeLayerIdRef.current) || 1;
|
||||
const v2Layout = convertLegacyToV2({ ...layout, screenResolution });
|
||||
await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: curId });
|
||||
|
||||
// 2. 새 레이어 로드
|
||||
const data = await screenApi.getLayerLayout(selectedScreen.screenId, layerId);
|
||||
if (data && data.components) {
|
||||
const legacy = convertV2ToLegacy(data);
|
||||
if (legacy) {
|
||||
setLayout((prev) => ({ ...prev, components: legacy.components }));
|
||||
} else {
|
||||
setLayout((prev) => ({ ...prev, components: [] }));
|
||||
}
|
||||
} else {
|
||||
setLayout((prev) => ({ ...prev, components: [] }));
|
||||
}
|
||||
|
||||
setActiveLayerIdWithRef(layerId);
|
||||
setSelectedComponent(null);
|
||||
} catch (error) {
|
||||
console.error("레이어 전환 실패:", error);
|
||||
toast.error("레이어 전환에 실패했습니다.");
|
||||
}
|
||||
}}
|
||||
components={layout.components}
|
||||
zones={zones}
|
||||
onZonesChange={setZones}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="properties" className="mt-0 flex-1 overflow-hidden">
|
||||
|
|
@ -6272,8 +6554,8 @@ export default function ScreenDesigner({
|
|||
updateComponentProperty(selectedComponent.id, "style", style);
|
||||
}
|
||||
}}
|
||||
allComponents={layout.components} // 🆕 플로우 위젯 감지용
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||
allComponents={[...layout.components, ...otherLayerComponents]}
|
||||
menuObjid={menuObjid}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
|
@ -6396,24 +6678,54 @@ export default function ScreenDesigner({
|
|||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* 🆕 활성 레이어 인디케이터 (기본 레이어가 아닌 경우 표시) */}
|
||||
{activeLayerId > 1 && (
|
||||
<div className="sticky top-0 z-30 flex items-center justify-center gap-2 border-b bg-amber-50 px-4 py-1.5 backdrop-blur-sm dark:bg-amber-950/30">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-xs font-medium">
|
||||
레이어 {activeLayerId} 편집 중
|
||||
{activeLayerZone && (
|
||||
<span className="ml-2 text-amber-600">
|
||||
(캔버스: {activeLayerZone.width} x {activeLayerZone.height}px - {activeLayerZone.zone_name})
|
||||
</span>
|
||||
)}
|
||||
{!activeLayerZone && (
|
||||
<span className="ml-2 text-red-500">
|
||||
(조건부 영역 미설정 - 기본 레이어에서 Zone을 먼저 생성하세요)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
|
||||
{(() => {
|
||||
// 🆕 조건부 레이어 편집 시 캔버스 크기를 Zone에 맞춤
|
||||
const activeRegion = activeLayerId > 1 ? activeLayerZone : null;
|
||||
const canvasW = activeRegion ? activeRegion.width : screenResolution.width;
|
||||
const canvasH = activeRegion ? activeRegion.height : screenResolution.height;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex justify-center"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: screenResolution.height * zoomLevel,
|
||||
minHeight: canvasH * zoomLevel,
|
||||
contain: "layout style", // 레이아웃 재계산 범위 제한
|
||||
}}
|
||||
>
|
||||
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
|
||||
{/* 실제 작업 캔버스 (해상도 크기 또는 조건부 레이어 영역 크기) */}
|
||||
<div
|
||||
className="bg-background border-border border shadow-lg"
|
||||
className={cn(
|
||||
"bg-background border shadow-lg",
|
||||
activeRegion ? "border-amber-400 border-2" : "border-border"
|
||||
)}
|
||||
style={{
|
||||
width: `${screenResolution.width}px`,
|
||||
height: `${screenResolution.height}px`,
|
||||
minWidth: `${screenResolution.width}px`,
|
||||
maxWidth: `${screenResolution.width}px`,
|
||||
minHeight: `${screenResolution.height}px`,
|
||||
width: `${canvasW}px`,
|
||||
height: `${canvasH}px`,
|
||||
minWidth: `${canvasW}px`,
|
||||
maxWidth: `${canvasW}px`,
|
||||
minHeight: `${canvasH}px`,
|
||||
flexShrink: 0,
|
||||
transform: `scale3d(${zoomLevel}, ${zoomLevel}, 1)`,
|
||||
transformOrigin: "top center", // 중앙 기준으로 스케일
|
||||
|
|
@ -6436,6 +6748,22 @@ export default function ScreenDesigner({
|
|||
startSelectionDrag(e);
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
// 영역 이동/리사이즈 처리
|
||||
if (regionDrag.isDragging || regionDrag.isResizing) {
|
||||
handleRegionCanvasMouseMove(e);
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (regionDrag.isDragging || regionDrag.isResizing) {
|
||||
handleRegionCanvasMouseUp();
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (regionDrag.isDragging || regionDrag.isResizing) {
|
||||
handleRegionCanvasMouseUp();
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
|
|
@ -6504,6 +6832,106 @@ export default function ScreenDesigner({
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* 조건부 영역(Zone) (기본 레이어에서만 표시, DB 기반) */}
|
||||
{/* 내부는 pointerEvents: none으로 아래 컴포넌트 클릭/드래그 통과 */}
|
||||
{activeLayerId === 1 && zones.map((zone) => {
|
||||
const layerId = zone.zone_id; // 렌더링용 ID
|
||||
const region = zone;
|
||||
const resizeHandles = ["nw", "ne", "sw", "se", "n", "s", "e", "w"];
|
||||
const handleCursors: Record<string, string> = {
|
||||
nw: "nwse-resize", ne: "nesw-resize", sw: "nesw-resize", se: "nwse-resize",
|
||||
n: "ns-resize", s: "ns-resize", e: "ew-resize", w: "ew-resize",
|
||||
};
|
||||
const handlePositions: Record<string, React.CSSProperties> = {
|
||||
nw: { top: -4, left: -4 }, ne: { top: -4, right: -4 },
|
||||
sw: { bottom: -4, left: -4 }, se: { bottom: -4, right: -4 },
|
||||
n: { top: -4, left: "50%", transform: "translateX(-50%)" },
|
||||
s: { bottom: -4, left: "50%", transform: "translateX(-50%)" },
|
||||
e: { top: "50%", right: -4, transform: "translateY(-50%)" },
|
||||
w: { top: "50%", left: -4, transform: "translateY(-50%)" },
|
||||
};
|
||||
// 테두리 두께 (이동 핸들 영역)
|
||||
const borderWidth = 6;
|
||||
return (
|
||||
<div
|
||||
key={`region-${layerId}`}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: `${region.x}px`,
|
||||
top: `${region.y}px`,
|
||||
width: `${region.width}px`,
|
||||
height: `${region.height}px`,
|
||||
border: "2px dashed hsl(var(--primary))",
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "hsl(var(--primary) / 0.05)",
|
||||
zIndex: 50,
|
||||
pointerEvents: "none", // 내부 클릭은 아래 컴포넌트로 통과
|
||||
}}
|
||||
>
|
||||
{/* 테두리 이동 핸들: 상/하/좌/우 얇은 영역만 pointerEvents 활성 */}
|
||||
{/* 상단 */}
|
||||
<div
|
||||
className="absolute left-0 right-0 top-0"
|
||||
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
/>
|
||||
{/* 하단 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0"
|
||||
style={{ height: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
/>
|
||||
{/* 좌측 */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 top-0"
|
||||
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
/>
|
||||
{/* 우측 */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 top-0"
|
||||
style={{ width: borderWidth, cursor: "move", pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
/>
|
||||
{/* 라벨 */}
|
||||
<span
|
||||
className="absolute left-2 top-1 select-none text-[10px] font-medium text-primary"
|
||||
style={{ pointerEvents: "auto", cursor: "move" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "move")}
|
||||
>
|
||||
Zone {zone.zone_id} - {zone.zone_name}
|
||||
</span>
|
||||
{/* 리사이즈 핸들 */}
|
||||
{resizeHandles.map((handle) => (
|
||||
<div
|
||||
key={handle}
|
||||
className="absolute z-10 h-2 w-2 rounded-sm border border-primary bg-background"
|
||||
style={{ ...handlePositions[handle], cursor: handleCursors[handle], pointerEvents: "auto" }}
|
||||
onMouseDown={(e) => handleRegionMouseDown(e, String(layerId), "resize", handle)}
|
||||
/>
|
||||
))}
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
className="absolute -right-1 -top-3 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[8px] text-destructive-foreground hover:bg-destructive/80"
|
||||
style={{ pointerEvents: "auto" }}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!selectedScreen?.screenId) return;
|
||||
try {
|
||||
await screenApi.deleteZone(zone.zone_id);
|
||||
setZones((prev) => prev.filter(z => z.zone_id !== zone.zone_id));
|
||||
toast.success("조건부 영역이 삭제되었습니다.");
|
||||
} catch { toast.error("Zone 삭제 실패"); }
|
||||
}}
|
||||
title="영역 삭제"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
{/* 일반 컴포넌트들 */}
|
||||
{regularComponents.map((component) => {
|
||||
const children =
|
||||
|
|
@ -7031,7 +7459,7 @@ export default function ScreenDesigner({
|
|||
</p>
|
||||
<p>
|
||||
<span className="font-medium">편집:</span> Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장),
|
||||
Ctrl+Z(실행취소), Delete(삭제)
|
||||
Ctrl+Z(실행취소), Delete/Backspace(삭제)
|
||||
</p>
|
||||
<p className="text-warning flex items-center justify-center gap-2">
|
||||
<span>⚠️</span>
|
||||
|
|
@ -7043,8 +7471,9 @@ export default function ScreenDesigner({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>{" "}
|
||||
{/* 🔥 줌 래퍼 닫기 */}
|
||||
</div>
|
||||
); /* 🔥 줌 래퍼 닫기 */
|
||||
})()}
|
||||
</div>
|
||||
</div>{" "}
|
||||
{/* 메인 컨테이너 닫기 */}
|
||||
|
|
@ -7128,4 +7557,4 @@ export default function ScreenDesigner({
|
|||
</LayerProvider>
|
||||
</ScreenPreviewProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1872,6 +1872,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
id: screenToPreview!.screenId,
|
||||
tableName: screenToPreview?.tableName,
|
||||
}}
|
||||
layers={previewLayout.layers || []}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ interface ScreenSettingModalProps {
|
|||
fieldMappings?: FieldMappingInfo[];
|
||||
componentCount?: number;
|
||||
onSaveSuccess?: () => void;
|
||||
isPop?: boolean; // POP 화면 여부
|
||||
}
|
||||
|
||||
// 검색 가능한 Select 컴포넌트
|
||||
|
|
@ -240,7 +239,6 @@ export function ScreenSettingModal({
|
|||
fieldMappings = [],
|
||||
componentCount = 0,
|
||||
onSaveSuccess,
|
||||
isPop = false,
|
||||
}: ScreenSettingModalProps) {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -521,7 +519,6 @@ export function ScreenSettingModal({
|
|||
iframeKey={iframeKey}
|
||||
canvasWidth={canvasSize.width}
|
||||
canvasHeight={canvasSize.height}
|
||||
isPop={isPop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4634,10 +4631,9 @@ interface PreviewTabProps {
|
|||
iframeKey?: number; // iframe 새로고침용 키
|
||||
canvasWidth?: number; // 화면 캔버스 너비
|
||||
canvasHeight?: number; // 화면 캔버스 높이
|
||||
isPop?: boolean; // POP 화면 여부
|
||||
}
|
||||
|
||||
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight, isPop = false }: PreviewTabProps) {
|
||||
function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWidth, canvasHeight }: PreviewTabProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -4691,18 +4687,12 @@ function PreviewTab({ screenId, screenName, companyCode, iframeKey = 0, canvasWi
|
|||
if (companyCode) {
|
||||
params.set("company_code", companyCode);
|
||||
}
|
||||
// POP 화면일 경우 디바이스 타입 추가
|
||||
if (isPop) {
|
||||
params.set("device", "tablet");
|
||||
}
|
||||
// POP 화면과 데스크톱 화면 경로 분기
|
||||
const screenPath = isPop ? `/pop/screens/${screenId}` : `/screens/${screenId}`;
|
||||
if (typeof window !== "undefined") {
|
||||
const baseUrl = window.location.origin;
|
||||
return `${baseUrl}${screenPath}?${params.toString()}`;
|
||||
return `${baseUrl}/screens/${screenId}?${params.toString()}`;
|
||||
}
|
||||
return `${screenPath}?${params.toString()}`;
|
||||
}, [screenId, companyCode, isPop]);
|
||||
return `/screens/${screenId}?${params.toString()}`;
|
||||
}, [screenId, companyCode]);
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
setLoading(false);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,15 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
onStyleChange(newStyle);
|
||||
};
|
||||
|
||||
// 숫자만 입력했을 때 자동으로 px 붙여주는 핸들러
|
||||
const autoPxProperties: (keyof ComponentStyle)[] = ["fontSize", "borderWidth", "borderRadius"];
|
||||
const handlePxBlur = (property: keyof ComponentStyle) => {
|
||||
const val = localStyle[property];
|
||||
if (val && /^\d+(\.\d+)?$/.test(String(val))) {
|
||||
handleStyleChange(property, `${val}px`);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setOpenSections((prev) => ({
|
||||
...prev,
|
||||
|
|
@ -66,6 +75,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
placeholder="1px"
|
||||
value={localStyle.borderWidth || ""}
|
||||
onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
|
||||
onBlur={() => handlePxBlur("borderWidth")}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -121,6 +131,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
placeholder="5px"
|
||||
value={localStyle.borderRadius || ""}
|
||||
onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
|
||||
onBlur={() => handlePxBlur("borderRadius")}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -209,6 +220,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
|
|||
placeholder="14px"
|
||||
value={localStyle.fontSize || ""}
|
||||
onChange={(e) => handleStyleChange("fontSize", e.target.value)}
|
||||
onBlur={() => handlePxBlur("fontSize")}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,344 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isToday,
|
||||
} from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FormDatePickerProps {
|
||||
id?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
includeTime?: boolean;
|
||||
}
|
||||
|
||||
export const FormDatePicker: React.FC<FormDatePickerProps> = ({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
readOnly = false,
|
||||
includeTime = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
const [timeValue, setTimeValue] = useState("00:00");
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [typingValue, setTypingValue] = useState("");
|
||||
|
||||
const parseDate = (val: string): Date | undefined => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
const date = new Date(val);
|
||||
if (isNaN(date.getTime())) return undefined;
|
||||
return date;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedDate = parseDate(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setViewMode("calendar");
|
||||
if (selectedDate) {
|
||||
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12);
|
||||
if (includeTime) {
|
||||
const hours = String(selectedDate.getHours()).padStart(2, "0");
|
||||
const minutes = String(selectedDate.getMinutes()).padStart(2, "0");
|
||||
setTimeValue(`${hours}:${minutes}`);
|
||||
}
|
||||
} else {
|
||||
setCurrentMonth(new Date());
|
||||
setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
setTimeValue("00:00");
|
||||
}
|
||||
} else {
|
||||
setIsTyping(false);
|
||||
setTypingValue("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const formatDisplayValue = (): string => {
|
||||
if (!selectedDate) return "";
|
||||
if (includeTime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko });
|
||||
return format(selectedDate, "yyyy-MM-dd", { locale: ko });
|
||||
};
|
||||
|
||||
const buildDateStr = (date: Date, time?: string) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(date.getDate()).padStart(2, "0");
|
||||
if (includeTime) return `${y}-${m}-${d}T${time || timeValue}`;
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
onChange(buildDateStr(date));
|
||||
if (!includeTime) setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleTimeChange = (newTime: string) => {
|
||||
setTimeValue(newTime);
|
||||
if (selectedDate) onChange(buildDateStr(selectedDate, newTime));
|
||||
};
|
||||
|
||||
const handleSetToday = () => {
|
||||
const today = new Date();
|
||||
if (includeTime) {
|
||||
const t = `${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`;
|
||||
onChange(buildDateStr(today, t));
|
||||
} else {
|
||||
onChange(buildDateStr(today));
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange("");
|
||||
setIsTyping(false);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleTriggerInput = (raw: string) => {
|
||||
setIsTyping(true);
|
||||
setTypingValue(raw);
|
||||
if (!isOpen) setIsOpen(true);
|
||||
const digitsOnly = raw.replace(/\D/g, "");
|
||||
if (digitsOnly.length === 8) {
|
||||
const y = parseInt(digitsOnly.slice(0, 4), 10);
|
||||
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
|
||||
const d = parseInt(digitsOnly.slice(6, 8), 10);
|
||||
const date = new Date(y, m, d);
|
||||
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
|
||||
onChange(buildDateStr(date));
|
||||
setCurrentMonth(new Date(y, m, 1));
|
||||
if (!includeTime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400);
|
||||
else setIsTyping(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
const dayOfWeek = monthStart.getDay();
|
||||
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={(open) => { if (!open) { setIsOpen(false); setIsTyping(false); } }}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
id={id}
|
||||
className={cn(
|
||||
"border-input bg-background flex h-10 w-full cursor-pointer items-center rounded-md border px-3",
|
||||
(disabled || readOnly) && "cursor-not-allowed opacity-50",
|
||||
!selectedDate && !isTyping && "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => { if (!disabled && !readOnly) setIsOpen(true); }}
|
||||
>
|
||||
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={isTyping ? typingValue : (formatDisplayValue() || "")}
|
||||
placeholder={placeholder || "날짜를 선택하세요"}
|
||||
disabled={disabled || readOnly}
|
||||
onChange={(e) => handleTriggerInput(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={() => { if (!disabled && !readOnly && !isOpen) setIsOpen(true); }}
|
||||
onBlur={() => { if (!isOpen) setIsTyping(false); }}
|
||||
className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
/>
|
||||
{selectedDate && !disabled && !readOnly && !isTyping && (
|
||||
<X
|
||||
className="text-muted-foreground hover:text-foreground ml-auto h-3.5 w-3.5 shrink-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSetToday}>
|
||||
오늘
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">{yearRangeStart} - {yearRangeStart + 11}</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||
setViewMode("month");
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||
setViewMode("calendar");
|
||||
}}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) return <div key={index} className="p-2" />;
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{includeTime && viewMode === "calendar" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">시간:</span>
|
||||
<input
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className="border-input h-8 rounded-md border px-2 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isToday,
|
||||
} from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface InlineCellDatePickerProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
inputRef?: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export const InlineCellDatePicker: React.FC<InlineCellDatePickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
onSave,
|
||||
onKeyDown,
|
||||
inputRef,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
const localInputRef = useRef<HTMLInputElement>(null);
|
||||
const actualInputRef = inputRef || localInputRef;
|
||||
|
||||
const parseDate = (val: string): Date | undefined => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
const date = new Date(val);
|
||||
if (isNaN(date.getTime())) return undefined;
|
||||
return date;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedDate = parseDate(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDate) {
|
||||
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
onChange(dateStr);
|
||||
setIsOpen(false);
|
||||
setTimeout(() => onSave(), 50);
|
||||
};
|
||||
|
||||
const handleSetToday = () => {
|
||||
const today = new Date();
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
onChange(dateStr);
|
||||
setIsOpen(false);
|
||||
setTimeout(() => onSave(), 50);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange("");
|
||||
setIsOpen(false);
|
||||
setTimeout(() => onSave(), 50);
|
||||
};
|
||||
|
||||
const handleInputChange = (raw: string) => {
|
||||
onChange(raw);
|
||||
const digitsOnly = raw.replace(/\D/g, "");
|
||||
if (digitsOnly.length === 8) {
|
||||
const y = parseInt(digitsOnly.slice(0, 4), 10);
|
||||
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
|
||||
const d = parseInt(digitsOnly.slice(6, 8), 10);
|
||||
const date = new Date(y, m, d);
|
||||
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
|
||||
const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
onChange(dateStr);
|
||||
setIsOpen(false);
|
||||
setTimeout(() => onSave(), 50);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePopoverClose = (open: boolean) => {
|
||||
if (!open) {
|
||||
setIsOpen(false);
|
||||
onSave();
|
||||
}
|
||||
};
|
||||
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
|
||||
const startDate = new Date(monthStart);
|
||||
const dayOfWeek = startDate.getDay();
|
||||
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={handlePopoverClose}>
|
||||
<PopoverTrigger asChild>
|
||||
<input
|
||||
ref={actualInputRef as any}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onClick={() => setIsOpen(true)}
|
||||
placeholder="YYYYMMDD"
|
||||
className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleSetToday}>
|
||||
오늘
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div className="text-xs font-medium">
|
||||
{yearRangeStart} - {yearRangeStart + 11}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||
setViewMode("month");
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-0.5 text-xs font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||
setViewMode("calendar");
|
||||
}}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-0.5 text-xs font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 grid grid-cols-7 gap-0.5">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-1 text-center text-[10px] font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-0.5">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) return <div key={index} className="p-1" />;
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-7 w-7 p-0 text-[11px]",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
|
@ -34,6 +34,8 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
|
||||
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
|
||||
// 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장)
|
||||
const [tempValue, setTempValue] = useState<DateRangeValue>(value || {});
|
||||
|
|
@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
if (isOpen) {
|
||||
setTempValue(value || {});
|
||||
setSelectingType("from");
|
||||
setViewMode("calendar");
|
||||
}
|
||||
}, [isOpen, value]);
|
||||
|
||||
|
|
@ -234,60 +237,150 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 월 네비게이션 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">{format(currentMonth, "yyyy년 MM월", { locale: ko })}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요일 헤더 */}
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 날짜 그리드 */}
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) {
|
||||
return <div key={index} className="p-2" />;
|
||||
}
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = isRangeStart(date) || isRangeEnd(date);
|
||||
const isInRangeDate = isInRange(date);
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isInRangeDate && !isSelected && "bg-muted",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
selectingType === "from" && "hover:bg-primary/20",
|
||||
selectingType === "to" && "hover:bg-secondary/20",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
{/* 년도 선택 뷰 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm font-medium">
|
||||
{yearRangeStart} - {yearRangeStart + 11}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||
setViewMode("month");
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
{/* 월 선택 뷰 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||
setViewMode("calendar");
|
||||
}}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 월 네비게이션 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요일 헤더 */}
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 날짜 그리드 */}
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) {
|
||||
return <div key={index} className="p-2" />;
|
||||
}
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = isRangeStart(date) || isRangeEnd(date);
|
||||
const isInRangeDate = isInRange(date);
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isInRangeDate && !isSelected && "bg-muted",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
selectingType === "from" && "hover:bg-primary/20",
|
||||
selectingType === "to" && "hover:bg-secondary/20",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 선택된 범위 표시 */}
|
||||
{(tempValue.from || tempValue.to) && (
|
||||
|
|
|
|||
|
|
@ -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,23 @@ export function ComponentsPanel({
|
|||
tags: ["repeater", "table", "modal", "button", "v2", "v2"],
|
||||
defaultSize: { width: 600, height: 300 },
|
||||
},
|
||||
] as ComponentDefinition[],
|
||||
{
|
||||
id: "v2-bom-tree",
|
||||
name: "BOM 트리 뷰",
|
||||
description: "BOM 구성을 계층 트리 형태로 조회",
|
||||
category: "data" as ComponentCategory,
|
||||
tags: ["bom", "tree", "계층", "제조", "v2"],
|
||||
defaultSize: { width: 900, height: 600 },
|
||||
},
|
||||
{
|
||||
id: "v2-bom-item-editor",
|
||||
name: "BOM 하위품목 편집기",
|
||||
description: "BOM 하위 품목을 트리 구조로 추가/편집/삭제",
|
||||
category: "data" as ComponentCategory,
|
||||
tags: ["bom", "tree", "편집", "하위품목", "제조", "v2"],
|
||||
defaultSize: { width: 900, height: 400 },
|
||||
},
|
||||
] as unknown as ComponentDefinition[],
|
||||
[],
|
||||
);
|
||||
|
||||
|
|
@ -98,8 +130,7 @@ export function ComponentsPanel({
|
|||
"image-display", // → v2-media (image)
|
||||
// 공통코드관리로 통합 예정
|
||||
"category-manager", // → 공통코드관리 기능으로 통합 예정
|
||||
// 분할 패널 정리 (split-panel-layout v1 유지)
|
||||
"split-panel-layout2", // → split-panel-layout로 통합
|
||||
// 분할 패널 정리
|
||||
"screen-split-panel", // 화면 임베딩 방식은 사용하지 않음
|
||||
// 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음)
|
||||
"accordion-basic", // 아코디언 컴포넌트
|
||||
|
|
@ -109,8 +140,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 +157,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
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ interface EntityJoinTable {
|
|||
tableName: string;
|
||||
currentDisplayColumn: string;
|
||||
availableColumns: EntityJoinColumn[];
|
||||
// 같은 테이블이 여러 FK로 조인될 수 있으므로 소스 컬럼으로 구분
|
||||
joinConfig?: {
|
||||
sourceColumn: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface TablesPanelProps {
|
||||
|
|
@ -414,7 +419,11 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
</Badge>
|
||||
</div>
|
||||
|
||||
{entityJoinTables.map((joinTable) => {
|
||||
{entityJoinTables.map((joinTable, idx) => {
|
||||
// 같은 테이블이 여러 FK로 조인될 수 있으므로 sourceColumn으로 고유 키 생성
|
||||
const uniqueKey = joinTable.joinConfig?.sourceColumn
|
||||
? `entity-join-${joinTable.tableName}-${joinTable.joinConfig.sourceColumn}`
|
||||
: `entity-join-${joinTable.tableName}-${idx}`;
|
||||
const isExpanded = expandedJoinTables.has(joinTable.tableName);
|
||||
// 검색어로 필터링
|
||||
const filteredColumns = searchTerm
|
||||
|
|
@ -431,8 +440,7 @@ export const TablesPanel: React.FC<TablesPanelProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
// 엔티티 조인 테이블에 고유 접두사 추가 (메인 테이블과 키 중복 방지)
|
||||
<div key={`entity-join-${joinTable.tableName}`} className="space-y-1">
|
||||
<div key={uniqueKey} className="space-y-1">
|
||||
{/* 조인 테이블 헤더 */}
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between rounded-md bg-cyan-50 p-2 hover:bg-cyan-100"
|
||||
|
|
|
|||
|
|
@ -216,6 +216,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
"v2-media": require("@/components/v2/config-panels/V2MediaConfigPanel").V2MediaConfigPanel,
|
||||
"v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel,
|
||||
"v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel,
|
||||
"v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel,
|
||||
"v2-bom-tree": require("@/components/v2/config-panels/V2BomTreeConfigPanel").V2BomTreeConfigPanel,
|
||||
};
|
||||
|
||||
const V2ConfigPanel = v2ConfigPanels[componentId];
|
||||
|
|
@ -235,10 +237,16 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
const extraProps: Record<string, any> = {};
|
||||
if (componentId === "v2-select") {
|
||||
extraProps.inputType = inputType;
|
||||
extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||
extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName;
|
||||
}
|
||||
if (componentId === "v2-list") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
}
|
||||
if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") {
|
||||
extraProps.currentTableName = currentTableName;
|
||||
extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={selectedComponent.id} className="space-y-4">
|
||||
|
|
@ -833,6 +841,44 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">위치</Label>
|
||||
<Select
|
||||
value={selectedComponent.style?.labelPosition || "top"}
|
||||
onValueChange={(value) => handleUpdate("style.labelPosition", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="top">위</SelectItem>
|
||||
<SelectItem value="bottom">아래</SelectItem>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">간격</Label>
|
||||
<Input
|
||||
value={
|
||||
(selectedComponent.style?.labelPosition === "left" || selectedComponent.style?.labelPosition === "right")
|
||||
? (selectedComponent.style?.labelGap || "8px")
|
||||
: (selectedComponent.style?.labelMarginBottom || "4px")
|
||||
}
|
||||
onChange={(e) => {
|
||||
const pos = selectedComponent.style?.labelPosition;
|
||||
if (pos === "left" || pos === "right") {
|
||||
handleUpdate("style.labelGap", e.target.value);
|
||||
} else {
|
||||
handleUpdate("style.labelMarginBottom", e.target.value);
|
||||
}
|
||||
}}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">크기</Label>
|
||||
|
|
@ -854,12 +900,21 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
|||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">여백</Label>
|
||||
<Input
|
||||
value={selectedComponent.style?.labelMarginBottom || "4px"}
|
||||
onChange={(e) => handleUpdate("style.labelMarginBottom", e.target.value)}
|
||||
className="h-6 w-full px-2 py-0 text-xs"
|
||||
/>
|
||||
<Label className="text-xs">굵기</Label>
|
||||
<Select
|
||||
value={selectedComponent.style?.labelFontWeight || "500"}
|
||||
onValueChange={(value) => handleUpdate("style.labelFontWeight", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="400">보통</SelectItem>
|
||||
<SelectItem value="500">중간</SelectItem>
|
||||
<SelectItem value="600">굵게</SelectItem>
|
||||
<SelectItem value="700">매우 굵게</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-5">
|
||||
<Checkbox
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export const GroupingPanel: React.FC<Props> = ({
|
|||
전체 해제
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
|
||||
{selectedColumns.map((colName, index) => {
|
||||
const col = table?.columns.find(
|
||||
(c) => c.columnName === colName
|
||||
|
|
|
|||
|
|
@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC<Props> = ({ isOpen, onClose, onFilters
|
|||
전체 해제
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-[40vh] space-y-2 overflow-y-auto pr-1">
|
||||
{selectedGroupColumns.map((colName, index) => {
|
||||
const col = table?.columns.find((c) => c.columnName === colName);
|
||||
if (!col) return null;
|
||||
|
|
|
|||
|
|
@ -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,27 @@ 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 = React.useMemo(() => {
|
||||
const map: Record<string, { id?: number; tableName?: string }> = {};
|
||||
for (const tab of tabs as ExtendedTabItem[]) {
|
||||
const inlineComponents = tab.components || [];
|
||||
if (inlineComponents.length > 0) {
|
||||
// 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출
|
||||
const tableComp = inlineComponents.find(
|
||||
(c) => c.componentType === "v2-table-list" || c.componentType === "table-list",
|
||||
);
|
||||
const selectedTable = tableComp?.componentConfig?.selectedTable;
|
||||
if (selectedTable || tab.screenId) {
|
||||
map[tab.id] = {
|
||||
id: tab.screenId,
|
||||
tableName: selectedTable,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [tabs]);
|
||||
|
||||
// 컴포넌트 탭 목록 변경 시 동기화
|
||||
useEffect(() => {
|
||||
|
|
@ -218,7 +277,7 @@ export function TabsWidget({
|
|||
// 화면 레이아웃이 로드된 경우
|
||||
const loadedComponents = screenLayouts[tab.id];
|
||||
if (loadedComponents && loadedComponents.length > 0) {
|
||||
return renderScreenComponents(loadedComponents);
|
||||
return renderScreenComponents(tab, loadedComponents);
|
||||
}
|
||||
|
||||
// 아직 로드되지 않은 경우
|
||||
|
|
@ -245,7 +304,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 +337,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 +349,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 +393,7 @@ export function TabsWidget({
|
|||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
{...restProps}
|
||||
component={{
|
||||
id: comp.id,
|
||||
componentType: comp.componentType,
|
||||
|
|
@ -345,6 +408,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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isToday,
|
||||
} from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WebTypeComponentProps } from "@/lib/registry/types";
|
||||
import { WidgetComponent, DateTypeConfig } from "@/types/screen";
|
||||
|
||||
|
|
@ -10,99 +25,341 @@ export const DateWidget: React.FC<WebTypeComponentProps> = ({ component, value,
|
|||
const { placeholder, required, style } = widget;
|
||||
const config = widget.webTypeConfig as DateTypeConfig | undefined;
|
||||
|
||||
// 사용자가 테두리를 설정했는지 확인
|
||||
const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border);
|
||||
const borderClass = hasCustomBorder ? "!border-0" : "";
|
||||
|
||||
// 날짜 포맷팅 함수
|
||||
const formatDateValue = (val: string) => {
|
||||
if (!val) return "";
|
||||
const isDatetime = widget.widgetType === "datetime";
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar");
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12);
|
||||
const [timeValue, setTimeValue] = useState("00:00");
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [typingValue, setTypingValue] = useState("");
|
||||
|
||||
const parseDate = (val: string | undefined): Date | undefined => {
|
||||
if (!val) return undefined;
|
||||
try {
|
||||
const date = new Date(val);
|
||||
if (isNaN(date.getTime())) return val;
|
||||
|
||||
if (widget.widgetType === "datetime") {
|
||||
return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
|
||||
} else {
|
||||
return date.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
if (isNaN(date.getTime())) return undefined;
|
||||
return date;
|
||||
} catch {
|
||||
return val;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 유효성 검증
|
||||
const validateDate = (dateStr: string): boolean => {
|
||||
if (!dateStr) return true;
|
||||
|
||||
const date = new Date(dateStr);
|
||||
if (isNaN(date.getTime())) return false;
|
||||
|
||||
// 최소/최대 날짜 검증
|
||||
if (config?.minDate) {
|
||||
const minDate = new Date(config.minDate);
|
||||
if (date < minDate) return false;
|
||||
}
|
||||
|
||||
if (config?.maxDate) {
|
||||
const maxDate = new Date(config.maxDate);
|
||||
if (date > maxDate) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 입력값 처리
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value;
|
||||
|
||||
if (validateDate(inputValue)) {
|
||||
onChange?.(inputValue);
|
||||
}
|
||||
};
|
||||
|
||||
// 웹타입에 따른 input type 결정
|
||||
const getInputType = () => {
|
||||
switch (widget.widgetType) {
|
||||
case "datetime":
|
||||
return "datetime-local";
|
||||
case "date":
|
||||
default:
|
||||
return "date";
|
||||
}
|
||||
};
|
||||
|
||||
// 기본값 설정 (현재 날짜/시간)
|
||||
const getDefaultValue = () => {
|
||||
const getDefaultValue = (): string => {
|
||||
if (config?.defaultValue === "current") {
|
||||
const now = new Date();
|
||||
if (widget.widgetType === "datetime") {
|
||||
return now.toISOString().slice(0, 16);
|
||||
} else {
|
||||
return now.toISOString().slice(0, 10);
|
||||
}
|
||||
if (isDatetime) return now.toISOString().slice(0, 16);
|
||||
return now.toISOString().slice(0, 10);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const finalValue = value || getDefaultValue();
|
||||
const selectedDate = parseDate(finalValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setViewMode("calendar");
|
||||
if (selectedDate) {
|
||||
setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1));
|
||||
if (isDatetime) {
|
||||
const hours = String(selectedDate.getHours()).padStart(2, "0");
|
||||
const minutes = String(selectedDate.getMinutes()).padStart(2, "0");
|
||||
setTimeValue(`${hours}:${minutes}`);
|
||||
}
|
||||
} else {
|
||||
setCurrentMonth(new Date());
|
||||
setTimeValue("00:00");
|
||||
}
|
||||
} else {
|
||||
setIsTyping(false);
|
||||
setTypingValue("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const formatDisplayValue = (): string => {
|
||||
if (!selectedDate) return "";
|
||||
if (isDatetime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko });
|
||||
return format(selectedDate, "yyyy-MM-dd", { locale: ko });
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
let dateStr: string;
|
||||
if (isDatetime) {
|
||||
const [hours, minutes] = timeValue.split(":").map(Number);
|
||||
const dt = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours || 0, minutes || 0);
|
||||
dateStr = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}T${timeValue}`;
|
||||
} else {
|
||||
dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
onChange?.(dateStr);
|
||||
if (!isDatetime) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeChange = (newTime: string) => {
|
||||
setTimeValue(newTime);
|
||||
if (selectedDate) {
|
||||
const dateStr = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}T${newTime}`;
|
||||
onChange?.(dateStr);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange?.("");
|
||||
setIsTyping(false);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleTriggerInput = (raw: string) => {
|
||||
setIsTyping(true);
|
||||
setTypingValue(raw);
|
||||
if (!isOpen) setIsOpen(true);
|
||||
const digitsOnly = raw.replace(/\D/g, "");
|
||||
if (digitsOnly.length === 8) {
|
||||
const y = parseInt(digitsOnly.slice(0, 4), 10);
|
||||
const m = parseInt(digitsOnly.slice(4, 6), 10) - 1;
|
||||
const d = parseInt(digitsOnly.slice(6, 8), 10);
|
||||
const date = new Date(y, m, d);
|
||||
if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) {
|
||||
let dateStr: string;
|
||||
if (isDatetime) {
|
||||
dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T${timeValue}`;
|
||||
} else {
|
||||
dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
||||
}
|
||||
onChange?.(dateStr);
|
||||
setCurrentMonth(new Date(y, m, 1));
|
||||
if (!isDatetime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400);
|
||||
else setIsTyping(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetToday = () => {
|
||||
const today = new Date();
|
||||
if (isDatetime) {
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`;
|
||||
onChange?.(dateStr);
|
||||
} else {
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
|
||||
onChange?.(dateStr);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
|
||||
const startDate = new Date(monthStart);
|
||||
const dayOfWeek = startDate.getDay();
|
||||
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||
|
||||
return (
|
||||
<Input
|
||||
type={getInputType()}
|
||||
value={formatDateValue(finalValue)}
|
||||
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
|
||||
onChange={handleChange}
|
||||
disabled={readonly}
|
||||
required={required}
|
||||
className={`h-full w-full ${borderClass}`}
|
||||
min={config?.minDate}
|
||||
max={config?.maxDate}
|
||||
/>
|
||||
<Popover open={isOpen} onOpenChange={(v) => { if (!v) { setIsOpen(false); setIsTyping(false); } }}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"border-input bg-background flex h-full w-full cursor-pointer items-center rounded-md border px-3",
|
||||
readonly && "cursor-not-allowed opacity-50",
|
||||
!selectedDate && !isTyping && "text-muted-foreground",
|
||||
borderClass,
|
||||
)}
|
||||
onClick={() => { if (!readonly) setIsOpen(true); }}
|
||||
>
|
||||
<CalendarIcon className="text-muted-foreground mr-2 h-4 w-4 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={isTyping ? typingValue : (formatDisplayValue() || "")}
|
||||
placeholder={placeholder || config?.placeholder || "날짜를 선택하세요..."}
|
||||
disabled={readonly}
|
||||
onChange={(e) => handleTriggerInput(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onFocus={() => { if (!readonly && !isOpen) setIsOpen(true); }}
|
||||
onBlur={() => { if (!isOpen) setIsTyping(false); }}
|
||||
className="h-full w-full truncate bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||
/>
|
||||
{selectedDate && !readonly && !isTyping && (
|
||||
<X
|
||||
className="text-muted-foreground hover:text-foreground ml-auto h-3.5 w-3.5 shrink-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClear();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleSetToday}>
|
||||
오늘
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{viewMode === "year" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart - 12)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">
|
||||
{yearRangeStart} - {yearRangeStart + 11}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setYearRangeStart(yearRangeStart + 12)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => (
|
||||
<Button
|
||||
key={year}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
year === currentMonth.getFullYear() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
year === new Date().getFullYear() && year !== currentMonth.getFullYear() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(year, currentMonth.getMonth(), 1));
|
||||
setViewMode("month");
|
||||
}}
|
||||
>
|
||||
{year}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : viewMode === "month" ? (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{currentMonth.getFullYear()}년
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth(), 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4 grid grid-cols-4 gap-2">
|
||||
{Array.from({ length: 12 }, (_, i) => i).map((month) => (
|
||||
<Button
|
||||
key={month}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-9 text-xs",
|
||||
month === currentMonth.getMonth() && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
month === new Date().getMonth() && currentMonth.getFullYear() === new Date().getFullYear() && month !== currentMonth.getMonth() && "border-primary border",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), month, 1));
|
||||
setViewMode("calendar");
|
||||
}}
|
||||
>
|
||||
{month + 1}월
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-accent rounded-md px-2 py-1 text-sm font-medium transition-colors"
|
||||
onClick={() => {
|
||||
setYearRangeStart(Math.floor(currentMonth.getFullYear() / 12) * 12);
|
||||
setViewMode("year");
|
||||
}}
|
||||
>
|
||||
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) return <div key={index} className="p-2" />;
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = selectedDate ? isSameDay(date, selectedDate) : false;
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* datetime 타입: 시간 입력 */}
|
||||
{isDatetime && viewMode === "calendar" && (
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">시간:</span>
|
||||
<input
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={(e) => handleTimeChange(e.target.value)}
|
||||
className="border-input h-8 rounded-md border px-2 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
DateWidget.displayName = "DateWidget";
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { FolderTree, Loader2, Search, X } from "lucide-react";
|
||||
import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
interface CategoryColumn {
|
||||
|
|
@ -30,6 +30,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
const [columns, setColumns] = useState<CategoryColumn[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
// 검색어로 필터링된 컬럼 목록
|
||||
const filteredColumns = useMemo(() => {
|
||||
|
|
@ -49,6 +50,44 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
});
|
||||
}, [columns, searchQuery]);
|
||||
|
||||
// 테이블별로 그룹화된 컬럼 목록
|
||||
const groupedColumns = useMemo(() => {
|
||||
const groups: { tableName: string; tableLabel: string; columns: CategoryColumn[] }[] = [];
|
||||
const groupMap = new Map<string, CategoryColumn[]>();
|
||||
|
||||
for (const col of filteredColumns) {
|
||||
const key = col.tableName;
|
||||
if (!groupMap.has(key)) {
|
||||
groupMap.set(key, []);
|
||||
}
|
||||
groupMap.get(key)!.push(col);
|
||||
}
|
||||
|
||||
for (const [tblName, cols] of groupMap) {
|
||||
groups.push({
|
||||
tableName: tblName,
|
||||
tableLabel: cols[0]?.tableLabel || tblName,
|
||||
columns: cols,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [filteredColumns]);
|
||||
|
||||
// 선택된 컬럼이 있는 그룹을 자동 펼침
|
||||
useEffect(() => {
|
||||
if (!selectedColumn) return;
|
||||
const tableName = selectedColumn.split(".")[0];
|
||||
if (tableName) {
|
||||
setExpandedGroups((prev) => {
|
||||
if (prev.has(tableName)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(tableName);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [selectedColumn]);
|
||||
|
||||
useEffect(() => {
|
||||
// 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회
|
||||
loadCategoryColumnsByMenu();
|
||||
|
|
@ -72,9 +111,10 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
allColumns = response.data;
|
||||
}
|
||||
|
||||
// category 타입 컬럼만 필터링
|
||||
// category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외)
|
||||
const categoryColumns = allColumns.filter(
|
||||
(col: any) => col.inputType === "category" || col.input_type === "category"
|
||||
(col: any) => (col.inputType === "category" || col.input_type === "category")
|
||||
&& !col.categoryRef && !col.category_ref
|
||||
);
|
||||
|
||||
console.log("✅ 카테고리 컬럼 필터링 완료:", {
|
||||
|
|
@ -278,35 +318,114 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
{filteredColumns.length === 0 && searchQuery ? (
|
||||
<div className="text-muted-foreground py-4 text-center text-xs">
|
||||
'{searchQuery}'에 대한 검색 결과가 없습니다
|
||||
</div>
|
||||
) : null}
|
||||
{filteredColumns.map((column) => {
|
||||
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
||||
const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
|
||||
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
|
||||
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderTree
|
||||
className={`h-4 w-4 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
|
||||
<p className="text-muted-foreground text-xs">{column.tableLabel || column.tableName}</p>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{column.valueCount !== undefined ? `${column.valueCount}개` : "..."}
|
||||
</span>
|
||||
{groupedColumns.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group.tableName);
|
||||
const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0);
|
||||
const hasSelectedInGroup = group.columns.some(
|
||||
(c) => selectedColumn === `${c.tableName}.${c.columnName}`,
|
||||
);
|
||||
|
||||
// 그룹이 1개뿐이면 드롭다운 없이 바로 표시
|
||||
if (groupedColumns.length <= 1) {
|
||||
return (
|
||||
<div key={group.tableName} className="space-y-1.5">
|
||||
{group.columns.map((column) => {
|
||||
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
||||
const isSelected = selectedColumn === uniqueKey;
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
|
||||
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
|
||||
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderTree
|
||||
className={`h-4 w-4 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
|
||||
<p className="text-muted-foreground text-xs">{column.tableLabel || column.tableName}</p>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{column.valueCount !== undefined ? `${column.valueCount}개` : "..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.tableName} className="overflow-hidden rounded-lg border">
|
||||
{/* 드롭다운 헤더 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(group.tableName)) {
|
||||
next.delete(group.tableName);
|
||||
} else {
|
||||
next.add(group.tableName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left transition-colors ${
|
||||
hasSelectedInGroup ? "bg-primary/5" : "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<ChevronRight
|
||||
className={`h-3.5 w-3.5 shrink-0 transition-transform duration-200 ${
|
||||
isExpanded ? "rotate-90" : ""
|
||||
} ${hasSelectedInGroup ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className={`flex-1 text-xs font-semibold ${hasSelectedInGroup ? "text-primary" : ""}`}>
|
||||
{group.tableLabel}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{group.columns.length}개 컬럼 / {totalValues}개 값
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* 펼쳐진 컬럼 목록 */}
|
||||
{isExpanded && (
|
||||
<div className="space-y-1 border-t px-2 py-2">
|
||||
{group.columns.map((column) => {
|
||||
const uniqueKey = `${column.tableName}.${column.columnName}`;
|
||||
const isSelected = selectedColumn === uniqueKey;
|
||||
return (
|
||||
<div
|
||||
key={uniqueKey}
|
||||
onClick={() => onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)}
|
||||
className={`cursor-pointer rounded-md px-3 py-1.5 transition-all ${
|
||||
isSelected ? "bg-primary/10 font-semibold text-primary" : "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderTree
|
||||
className={`h-3.5 w-3.5 ${isSelected ? "text-primary" : "text-muted-foreground"}`}
|
||||
/>
|
||||
<span className="flex-1 text-xs">{column.columnLabel || column.columnName}</span>
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{column.valueCount !== undefined ? `${column.valueCount}개` : "..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] bg-black/80",
|
||||
"fixed inset-0 z-[1050] 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-[1100] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[999] bg-black/60",
|
||||
"fixed inset-0 z-[999] bg-black/60",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||
"bg-background fixed top-[50%] left-[50%] z-[1000] flex w-full max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border p-6 shadow-lg sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"bg-popover text-popover-foreground z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -63,7 +63,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
"bg-popover text-popover-foreground z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue