From 07777e314b23e90a6ff0330f4e07430af7961956 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 26 Mar 2026 14:04:51 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EB=B6=84=ED=95=A0=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20V2=20=EC=84=A4=EC=A0=95=EC=97=90=EC=84=9C=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=84=A0=ED=83=9D=20=EA=B8=B0=EB=8A=A5=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 추가 탭에 PanelColumnSection 컬럼 선택 기능 추가 - 우측 패널 custom 모드에서도 컬럼 설정 표시되도록 조건 제거 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../V2SplitPanelLayoutConfigPanel.tsx | 135 ++++++++++++------ 1 file changed, 89 insertions(+), 46 deletions(-) diff --git a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx index 88b0d7d3..be5f8b80 100644 --- a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx @@ -1507,53 +1507,51 @@ export const V2SplitPanelLayoutConfigPanel: React.FC {/* 우측 패널 컬럼 설정 (접이식) */} - {config.rightPanel?.displayMode !== "custom" && ( - - - - - -
- {loadingColumns[rightTableName] ? ( -
- - 컬럼 로딩 중... -
- ) : rightTableColumns.length === 0 ? ( -

- 테이블을 선택하면 컬럼이 표시됩니다 -

- ) : ( - updateRightPanel({ columns })} - /> - )} + + + + + +
+ {loadingColumns[rightTableName] ? ( +
+ + 컬럼 로딩 중... +
+ ) : rightTableColumns.length === 0 ? ( +

+ 테이블을 선택하면 컬럼이 표시됩니다 +

+ ) : ( + updateRightPanel({ columns })} + /> + )} +
+
+
{/* 우측 패널 데이터 필터 (접이식) */} @@ -2064,6 +2062,51 @@ export const V2SplitPanelLayoutConfigPanel: React.FC )} + {/* 탭 컬럼 설정 */} + {tab.tableName && (loadedTableColumns[tab.tableName] || []).length > 0 && ( + + + + + +
+ {loadingColumns[tab.tableName] ? ( +
+ + 컬럼 로딩 중... +
+ ) : ( + updateTab(tabIndex, { columns })} + /> + )} +
+
+
+ )} + {/* 탭 기능 토글 */}
Date: Thu, 26 Mar 2026 16:30:53 +0900 Subject: [PATCH 2/4] feat: add web crawling management functionality - Introduced a new crawling management feature allowing users to configure, execute, and log web crawls. - Added CRUD operations for crawl configurations, including URL analysis and preview capabilities. - Implemented a new service for handling crawling logic and scheduling tasks. - Integrated cheerio for HTML parsing and axios for HTTP requests. - Created a sample HTML page for testing crawling functionality. This commit enhances the application's data collection capabilities from external websites. --- backend-node/package-lock.json | 282 +++++++ backend-node/package.json | 2 + backend-node/src/app.ts | 7 + .../src/controllers/crawlController.ts | 124 +++ backend-node/src/routes/crawlRoutes.ts | 32 + backend-node/src/services/crawlService.ts | 489 +++++++++++ .../admin/automaticMng/crawlingList/page.tsx | 763 ++++++++++++++++++ .../components/layout/AdminPageRenderer.tsx | 1 + 8 files changed, 1700 insertions(+) create mode 100644 backend-node/src/controllers/crawlController.ts create mode 100644 backend-node/src/routes/crawlRoutes.ts create mode 100644 backend-node/src/services/crawlService.ts create mode 100644 frontend/app/(main)/admin/automaticMng/crawlingList/page.tsx diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index f482dc7b..24ef7619 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -13,6 +13,7 @@ "axios": "^1.11.0", "bcryptjs": "^2.4.3", "bwip-js": "^4.8.0", + "cheerio": "^1.2.0", "compression": "^1.7.4", "cors": "^2.8.5", "docx": "^9.5.1", @@ -36,6 +37,7 @@ "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", + "playwright": "^1.58.2", "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", @@ -4408,6 +4410,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bowser": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", @@ -4704,6 +4712,79 @@ "node": ">=10" } }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -5091,6 +5172,34 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -5539,6 +5648,31 @@ "node": ">=8.10.0" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ent": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz", @@ -9020,6 +9154,18 @@ "node": ">=8" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9254,6 +9400,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseley": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", @@ -9525,6 +9720,50 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -11146,6 +11385,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -11310,6 +11558,40 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index 53ee00b8..2217eff6 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -27,6 +27,7 @@ "axios": "^1.11.0", "bcryptjs": "^2.4.3", "bwip-js": "^4.8.0", + "cheerio": "^1.2.0", "compression": "^1.7.4", "cors": "^2.8.5", "docx": "^9.5.1", @@ -50,6 +51,7 @@ "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", + "playwright": "^1.58.2", "quill": "^2.0.3", "react-quill": "^2.0.0", "redis": "^4.6.10", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 7a3e0071..c087ddfb 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -115,6 +115,7 @@ import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리 import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리 +import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 @@ -325,6 +326,7 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 app.use("/api/production", productionRoutes); // 생산계획 관리 +app.use("/api/crawl", crawlRoutes); // 웹 크롤링 app.use("/api/material-status", materialStatusRoutes); // 자재현황 app.use("/api/process-info", processInfoRoutes); // 공정정보관리 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 @@ -415,6 +417,11 @@ async function initializeServices() { try { await BatchSchedulerService.initializeScheduler(); logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`); + + // 크롤링 스케줄러 초기화 + const { CrawlService } = await import("./services/crawlService"); + await CrawlService.initializeScheduler(); + logger.info(`🕷️ 크롤링 스케줄러가 시작되었습니다.`); } catch (error) { logger.error(`❌ 배치 스케줄러 초기화 실패:`, error); } diff --git a/backend-node/src/controllers/crawlController.ts b/backend-node/src/controllers/crawlController.ts new file mode 100644 index 00000000..c4f66c94 --- /dev/null +++ b/backend-node/src/controllers/crawlController.ts @@ -0,0 +1,124 @@ +import { Request, Response } from "express"; +import { CrawlService } from "../services/crawlService"; +import { logger } from "../utils/logger"; + +interface AuthenticatedRequest extends Request { + user?: { companyCode: string; userId: string }; +} + +// 설정 목록 조회 +export async function getCrawlConfigs(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode || "*"; + const configs = await CrawlService.getConfigs(companyCode); + return res.json({ success: true, data: configs }); + } catch (error: any) { + logger.error("크롤링 설정 조회 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 설정 상세 조회 +export async function getCrawlConfig(req: AuthenticatedRequest, res: Response) { + try { + const config = await CrawlService.getConfigById(req.params.id); + if (!config) return res.status(404).json({ success: false, message: "설정을 찾을 수 없습니다." }); + return res.json({ success: true, data: config }); + } catch (error: any) { + logger.error("크롤링 설정 상세 조회 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 설정 생성 +export async function createCrawlConfig(req: AuthenticatedRequest, res: Response) { + try { + const data = { + ...req.body, + company_code: req.user?.companyCode || req.body.company_code, + writer: req.user?.userId, + }; + const config = await CrawlService.createConfig(data); + return res.json({ success: true, data: config }); + } catch (error: any) { + logger.error("크롤링 설정 생성 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 설정 수정 +export async function updateCrawlConfig(req: AuthenticatedRequest, res: Response) { + try { + const config = await CrawlService.updateConfig(req.params.id, req.body); + if (!config) return res.status(404).json({ success: false, message: "설정을 찾을 수 없습니다." }); + return res.json({ success: true, data: config }); + } catch (error: any) { + logger.error("크롤링 설정 수정 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 설정 삭제 +export async function deleteCrawlConfig(req: AuthenticatedRequest, res: Response) { + try { + await CrawlService.deleteConfig(req.params.id); + return res.json({ success: true }); + } catch (error: any) { + logger.error("크롤링 설정 삭제 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 미리보기 +export async function previewCrawl(req: AuthenticatedRequest, res: Response) { + try { + const { url, row_selector, column_mappings, method, headers, request_body } = req.body; + if (!url) return res.status(400).json({ success: false, message: "URL은 필수입니다." }); + + const result = await CrawlService.preview(url, row_selector, column_mappings || [], method, headers, request_body); + return res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("크롤링 미리보기 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// URL 자동 분석 — 페이지의 테이블/리스트 구조를 감지 +export async function analyzeUrl(req: AuthenticatedRequest, res: Response) { + try { + const { url } = req.body; + if (!url) return res.status(400).json({ success: false, message: "URL은 필수입니다." }); + + const result = await CrawlService.analyzeUrl(url); + return res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("URL 분석 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 수동 실행 +export async function executeCrawl(req: AuthenticatedRequest, res: Response) { + try { + const config = await CrawlService.getConfigById(req.params.id); + if (!config) return res.status(404).json({ success: false, message: "설정을 찾을 수 없습니다." }); + + const result = await CrawlService.executeCrawl(config); + return res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("크롤링 수동 실행 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 실행 로그 조회 +export async function getCrawlLogs(req: AuthenticatedRequest, res: Response) { + try { + const limit = parseInt(req.query.limit as string) || 20; + const logs = await CrawlService.getLogs(req.params.id, limit); + return res.json({ success: true, data: logs }); + } catch (error: any) { + logger.error("크롤링 로그 조회 실패:", error); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/crawlRoutes.ts b/backend-node/src/routes/crawlRoutes.ts new file mode 100644 index 00000000..93b6176e --- /dev/null +++ b/backend-node/src/routes/crawlRoutes.ts @@ -0,0 +1,32 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { + getCrawlConfigs, + getCrawlConfig, + createCrawlConfig, + updateCrawlConfig, + deleteCrawlConfig, + previewCrawl, + analyzeUrl, + executeCrawl, + getCrawlLogs, +} from "../controllers/crawlController"; + +const router = Router(); + +// 설정 CRUD +router.get("/configs", authenticateToken, getCrawlConfigs); +router.get("/configs/:id", authenticateToken, getCrawlConfig); +router.post("/configs", authenticateToken, createCrawlConfig); +router.put("/configs/:id", authenticateToken, updateCrawlConfig); +router.delete("/configs/:id", authenticateToken, deleteCrawlConfig); + +// 분석 & 미리보기 & 실행 +router.post("/analyze", authenticateToken, analyzeUrl); +router.post("/preview", authenticateToken, previewCrawl); +router.post("/execute/:id", authenticateToken, executeCrawl); + +// 실행 로그 +router.get("/configs/:id/logs", authenticateToken, getCrawlLogs); + +export default router; diff --git a/backend-node/src/services/crawlService.ts b/backend-node/src/services/crawlService.ts new file mode 100644 index 00000000..8c829917 --- /dev/null +++ b/backend-node/src/services/crawlService.ts @@ -0,0 +1,489 @@ +import * as cheerio from "cheerio"; +import axios from "axios"; +import cron, { ScheduledTask } from "node-cron"; +import { query } from "../database/db"; +import { logger } from "../utils/logger"; + +export interface CrawlConfig { + id: string; + company_code: string; + name: string; + url: string; + method: string; + headers: Record; + request_body?: string; + selector_type: string; + row_selector: string; + column_mappings: Array<{ + selector: string; + column: string; + type: "text" | "number" | "date"; + attribute?: string; // href, src 등 속성값 추출 + }>; + target_table: string; + upsert_key?: string; + cron_schedule?: string; + is_active: string; + writer?: string; +} + +export interface CrawlResult { + collected: number; + saved: number; + errors: string[]; +} + +const DEFAULT_HEADERS = { + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", +}; + +export class CrawlService { + private static scheduledTasks: Map = new Map(); + + // ─── 스케줄러 ─── + + static async initializeScheduler() { + try { + const configs = await query( + `SELECT * FROM crawl_configs WHERE is_active = 'Y' AND cron_schedule IS NOT NULL AND cron_schedule != ''` + ); + + logger.info(`크롤링 스케줄러: ${configs.length}개 설정 등록`); + + for (const config of configs) { + this.scheduleConfig(config); + } + } catch (error) { + logger.error("크롤링 스케줄러 초기화 실패:", error); + } + } + + static scheduleConfig(config: CrawlConfig) { + if (!config.cron_schedule || !cron.validate(config.cron_schedule)) { + logger.warn(`크롤링 [${config.name}]: 유효하지 않은 cron 표현식 - ${config.cron_schedule}`); + return; + } + + // 기존 스케줄 제거 + if (this.scheduledTasks.has(config.id)) { + this.scheduledTasks.get(config.id)!.stop(); + this.scheduledTasks.delete(config.id); + } + + const task = cron.schedule( + config.cron_schedule, + async () => { + logger.info(`크롤링 [${config.name}] 스케줄 실행 시작`); + await this.executeCrawl(config); + }, + { timezone: "Asia/Seoul" } + ); + + this.scheduledTasks.set(config.id, task); + logger.info(`크롤링 [${config.name}] 스케줄 등록: ${config.cron_schedule}`); + } + + static unscheduleConfig(configId: string) { + if (this.scheduledTasks.has(configId)) { + this.scheduledTasks.get(configId)!.stop(); + this.scheduledTasks.delete(configId); + } + } + + // ─── CRUD ─── + + static async getConfigs(companyCode: string) { + const condition = companyCode === "*" ? "" : "WHERE company_code = $1"; + const params = companyCode === "*" ? [] : [companyCode]; + return query(`SELECT * FROM crawl_configs ${condition} ORDER BY created_date DESC`, params); + } + + static async getConfigById(id: string) { + const rows = await query(`SELECT * FROM crawl_configs WHERE id = $1`, [id]); + return rows[0] || null; + } + + static async createConfig(data: Partial) { + const result = await query( + `INSERT INTO crawl_configs (company_code, name, url, method, headers, request_body, selector_type, row_selector, column_mappings, target_table, upsert_key, cron_schedule, is_active, writer) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`, + [ + data.company_code, + data.name, + data.url, + data.method || "GET", + JSON.stringify(data.headers || {}), + data.request_body || null, + data.selector_type || "css", + data.row_selector || null, + JSON.stringify(data.column_mappings || []), + data.target_table, + data.upsert_key || null, + data.cron_schedule || null, + data.is_active || "Y", + data.writer || null, + ] + ); + + const config = result[0]; + if (config.is_active === "Y" && config.cron_schedule) { + this.scheduleConfig(config); + } + return config; + } + + static async updateConfig(id: string, data: Partial) { + const result = await query( + `UPDATE crawl_configs SET + name = COALESCE($2, name), + url = COALESCE($3, url), + method = COALESCE($4, method), + headers = COALESCE($5, headers), + request_body = $6, + selector_type = COALESCE($7, selector_type), + row_selector = $8, + column_mappings = COALESCE($9, column_mappings), + target_table = COALESCE($10, target_table), + upsert_key = $11, + cron_schedule = $12, + is_active = COALESCE($13, is_active), + updated_date = now() + WHERE id = $1 RETURNING *`, + [ + id, + data.name, + data.url, + data.method, + data.headers ? JSON.stringify(data.headers) : null, + data.request_body ?? null, + data.selector_type, + data.row_selector ?? null, + data.column_mappings ? JSON.stringify(data.column_mappings) : null, + data.target_table, + data.upsert_key ?? null, + data.cron_schedule ?? null, + data.is_active, + ] + ); + + const config = result[0]; + if (config) { + this.unscheduleConfig(id); + if (config.is_active === "Y" && config.cron_schedule) { + this.scheduleConfig(config); + } + } + return config; + } + + static async deleteConfig(id: string) { + this.unscheduleConfig(id); + await query(`DELETE FROM crawl_configs WHERE id = $1`, [id]); + } + + // ─── 크롤링 실행 ─── + + static async executeCrawl(config: CrawlConfig): Promise { + const logId = await this.createLog(config.id, config.company_code); + const errors: string[] = []; + let collected = 0; + let saved = 0; + + try { + // 1. HTTP 요청 + const headers = { ...DEFAULT_HEADERS, ...(typeof config.headers === "string" ? JSON.parse(config.headers) : config.headers || {}) }; + const response = await axios({ + method: (config.method || "GET") as any, + url: config.url, + headers, + data: config.request_body || undefined, + timeout: 30000, + responseType: "text", + }); + + const html = response.data; + const htmlPreview = typeof html === "string" ? html.substring(0, 2000) : ""; + + // 2. DOM 파싱 + const $ = cheerio.load(html); + const mappings = typeof config.column_mappings === "string" + ? JSON.parse(config.column_mappings) + : config.column_mappings || []; + + // 3. 행 추출 + const rows: Record[] = []; + + if (config.row_selector) { + $(config.row_selector).each((_, el) => { + const row: Record = {}; + for (const mapping of mappings) { + const $el = $(el).find(mapping.selector); + const raw = mapping.attribute ? $el.attr(mapping.attribute) || "" : $el.text().trim(); + row[mapping.column] = this.castValue(raw, mapping.type); + } + rows.push(row); + }); + } else { + // row_selector 없으면 column_mappings의 selector로 직접 추출 (단일 행) + const row: Record = {}; + for (const mapping of mappings) { + const $el = $(mapping.selector); + const raw = mapping.attribute ? $el.attr(mapping.attribute) || "" : $el.text().trim(); + row[mapping.column] = this.castValue(raw, mapping.type); + } + rows.push(row); + } + + collected = rows.length; + + // 4. DB 저장 + for (const row of rows) { + try { + row.company_code = config.company_code; + + if (config.upsert_key) { + await this.upsertRow(config.target_table, row, config.upsert_key, config.company_code); + } else { + await this.insertRow(config.target_table, row); + } + saved++; + } catch (err: any) { + errors.push(`행 저장 실패: ${err.message}`); + } + } + + // 5. 상태 업데이트 + await this.updateLog(logId, "success", collected, saved, null, htmlPreview); + await query( + `UPDATE crawl_configs SET last_executed_at = now(), last_status = 'success', last_error = null WHERE id = $1`, + [config.id] + ); + + logger.info(`크롤링 [${config.name}] 완료: ${collected}건 수집, ${saved}건 저장`); + } catch (error: any) { + const errMsg = error.message || "Unknown error"; + errors.push(errMsg); + await this.updateLog(logId, "fail", collected, saved, errMsg, null); + await query( + `UPDATE crawl_configs SET last_executed_at = now(), last_status = 'fail', last_error = $2 WHERE id = $1`, + [config.id, errMsg] + ); + logger.error(`크롤링 [${config.name}] 실패:`, error); + } + + return { collected, saved, errors }; + } + + // ─── URL 자동 분석 ─── + + static async analyzeUrl(url: string) { + const response = await axios({ + method: "GET", + url, + headers: DEFAULT_HEADERS, + timeout: 15000, + responseType: "text", + }); + + const $ = cheerio.load(response.data); + const tables: Array<{ + index: number; + selector: string; + caption: string; + headers: string[]; + rowCount: number; + sampleRows: string[][]; + }> = []; + + // HTML 자동 감지 + $("table").each((i, tableEl) => { + const $table = $(tableEl); + // 헤더 추출 + const headers: string[] = []; + $table.find("thead th, thead td, tr:first-child th").each((_, th) => { + headers.push($(th).text().trim()); + }); + // 헤더가 없으면 첫 행에서 추출 시도 + if (headers.length === 0) { + $table.find("tr:first-child td").each((_, td) => { + headers.push($(td).text().trim()); + }); + } + + // 데이터 행 수 + const bodyRows = $table.find("tbody tr"); + const allRows = bodyRows.length > 0 ? bodyRows : $table.find("tr").slice(headers.length > 0 ? 1 : 0); + const rowCount = allRows.length; + + // 샘플 (최대 3행) + const sampleRows: string[][] = []; + allRows.slice(0, 3).each((_, tr) => { + const cells: string[] = []; + $(tr).find("td, th").each((_, td) => { + cells.push($(td).text().trim()); + }); + sampleRows.push(cells); + }); + + if (headers.length > 0 || rowCount > 0) { + // 선택자 생성 + let selector = "table"; + const id = $table.attr("id"); + const cls = $table.attr("class"); + if (id) selector = `table#${id}`; + else if (cls) selector = `table.${cls.split(/\s+/)[0]}`; + else if (i > 0) selector = `table:nth-of-type(${i + 1})`; + + const caption = $table.find("caption").text().trim() || $table.attr("summary") || ""; + + tables.push({ + index: i, + selector, + caption, + headers, + rowCount, + sampleRows, + }); + } + }); + + return { + title: $("title").text().trim(), + tableCount: tables.length, + tables, + htmlLength: response.data.length, + }; + } + + // ─── 미리보기 ─── + + static async preview( + url: string, + rowSelector: string, + columnMappings: CrawlConfig["column_mappings"], + method = "GET", + headers: Record = {}, + requestBody?: string + ) { + const mergedHeaders = { ...DEFAULT_HEADERS, ...headers }; + const response = await axios({ + method: method as any, + url, + headers: mergedHeaders, + data: requestBody || undefined, + timeout: 15000, + responseType: "text", + }); + + const $ = cheerio.load(response.data); + const rows: Record[] = []; + + if (rowSelector) { + $(rowSelector) + .slice(0, 10) // 미리보기는 10행까지 + .each((_, el) => { + const row: Record = {}; + for (const mapping of columnMappings) { + const $el = $(el).find(mapping.selector); + const raw = mapping.attribute ? $el.attr(mapping.attribute) || "" : $el.text().trim(); + row[mapping.column] = this.castValue(raw, mapping.type); + } + rows.push(row); + }); + } + + return { + totalElements: rowSelector ? $(rowSelector).length : 0, + previewRows: rows, + htmlLength: response.data.length, + }; + } + + // ─── 유틸 ─── + + private static castValue(raw: string, type: string): any { + if (!raw) return null; + switch (type) { + case "number": { + const cleaned = raw.replace(/[^0-9.\-]/g, ""); + const num = parseFloat(cleaned); + return isNaN(num) ? null : num; + } + case "date": + return raw; + default: + return raw; + } + } + + private static async insertRow(tableName: string, row: Record) { + const cols = Object.keys(row); + const vals = Object.values(row); + const placeholders = cols.map((_, i) => `$${i + 1}`).join(", "); + const colNames = cols.map((c) => `"${c}"`).join(", "); + + await query(`INSERT INTO "${tableName}" (${colNames}) VALUES (${placeholders})`, vals); + } + + private static async upsertRow(tableName: string, row: Record, upsertKey: string, companyCode: string) { + const existing = await query( + `SELECT 1 FROM "${tableName}" WHERE "${upsertKey}" = $1 AND company_code = $2 LIMIT 1`, + [row[upsertKey], companyCode] + ); + + if (existing.length > 0) { + const setClauses: string[] = []; + const vals: any[] = []; + let idx = 1; + for (const [k, v] of Object.entries(row)) { + if (k === upsertKey || k === "company_code") continue; + setClauses.push(`"${k}" = $${idx}`); + vals.push(v); + idx++; + } + if (setClauses.length > 0) { + vals.push(row[upsertKey], companyCode); + await query( + `UPDATE "${tableName}" SET ${setClauses.join(", ")}, updated_date = now() WHERE "${upsertKey}" = $${idx} AND company_code = $${idx + 1}`, + vals + ); + } + } else { + await this.insertRow(tableName, row); + } + } + + private static async createLog(configId: string, companyCode: string): Promise { + const result = await query( + `INSERT INTO crawl_execution_logs (config_id, company_code, status) VALUES ($1, $2, 'running') RETURNING id`, + [configId, companyCode] + ); + return result[0].id; + } + + private static async updateLog( + logId: string, + status: string, + collected: number, + saved: number, + errorMessage: string | null, + htmlPreview: string | null + ) { + await query( + `UPDATE crawl_execution_logs SET status = $2, rows_collected = $3, rows_saved = $4, error_message = $5, response_html_preview = $6, finished_at = now() WHERE id = $1`, + [logId, status, collected, saved, errorMessage, htmlPreview] + ); + } + + // ─── 로그 조회 ─── + + static async getLogs(configId: string, limit = 20) { + return query( + `SELECT * FROM crawl_execution_logs WHERE config_id = $1 ORDER BY started_at DESC LIMIT $2`, + [configId, limit] + ); + } +} diff --git a/frontend/app/(main)/admin/automaticMng/crawlingList/page.tsx b/frontend/app/(main)/admin/automaticMng/crawlingList/page.tsx new file mode 100644 index 00000000..47837764 --- /dev/null +++ b/frontend/app/(main)/admin/automaticMng/crawlingList/page.tsx @@ -0,0 +1,763 @@ +"use client"; + +/** + * 크롤링 관리 — 외부 웹사이트 데이터 수집 설정/실행/로그 관리 + */ + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + Plus, + Search, + Play, + Pencil, + Trash2, + RefreshCw, + Globe, + Eye, + Clock, + CheckCircle, + XCircle, + Loader2, + Check, + ChevronsUpDown, +} from "lucide-react"; +import { toast } from "sonner"; +import { apiClient } from "@/lib/api/client"; +import { tableTypeApi } from "@/lib/api/screen"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { cn } from "@/lib/utils"; + +interface CrawlConfig { + id: string; + company_code: string; + name: string; + url: string; + method: string; + headers: Record; + request_body?: string; + selector_type: string; + row_selector: string; + column_mappings: Array<{ + selector: string; + column: string; + type: string; + attribute?: string; + }>; + target_table: string; + upsert_key?: string; + cron_schedule?: string; + is_active: string; + last_executed_at?: string; + last_status?: string; + last_error?: string; +} + +interface CrawlLog { + id: string; + status: string; + rows_collected: number; + rows_saved: number; + error_message?: string; + started_at: string; + finished_at?: string; +} + +const EMPTY_CONFIG: Partial = { + name: "", + url: "", + method: "GET", + headers: {}, + selector_type: "css", + row_selector: "", + column_mappings: [], + target_table: "", + upsert_key: "", + cron_schedule: "", + is_active: "Y", +}; + +export default function CrawlingManagementPage() { + const [configs, setConfigs] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [searchText, setSearchText] = useState(""); + const [loading, setLoading] = useState(false); + + // 모달 + const [modalOpen, setModalOpen] = useState(false); + const [modalMode, setModalMode] = useState<"add" | "edit">("add"); + const [form, setForm] = useState>(EMPTY_CONFIG); + const [saving, setSaving] = useState(false); + + // 테이블/컬럼 목록 + const [allTables, setAllTables] = useState>([]); + const [targetColumns, setTargetColumns] = useState>([]); + const [tablePopoverOpen, setTablePopoverOpen] = useState(false); + + // URL 분석 + const [analyzing, setAnalyzing] = useState(false); + const [analyzedTables, setAnalyzedTables] = useState>([]); + const [selectedAnalyzedIdx, setSelectedAnalyzedIdx] = useState(null); + + // 컬럼 매핑 시각적 폼 + const [mappingRows, setMappingRows] = useState>([]); + + // 미리보기 + const [previewOpen, setPreviewOpen] = useState(false); + const [previewData, setPreviewData] = useState(null); + const [previewing, setPreviewing] = useState(false); + + // 실행 로그 + const [logs, setLogs] = useState([]); + const [logsLoading, setLogsLoading] = useState(false); + + // 실행 중 + const [executing, setExecuting] = useState(null); + + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + // ─── 데이터 로드 ─── + + const loadConfigs = useCallback(async () => { + setLoading(true); + try { + const res = await apiClient.get("/crawl/configs"); + setConfigs(res.data.data || []); + } catch { + toast.error("크롤링 설정 로드 실패"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadConfigs(); + tableTypeApi.getTables().then((t) => setAllTables(t || [])).catch(() => {}); + }, [loadConfigs]); + + // target_table 변경 시 컬럼 목록 로드 + useEffect(() => { + if (!form.target_table) { setTargetColumns([]); return; } + tableTypeApi.getColumns(form.target_table).then((cols) => { + setTargetColumns(cols.map((c: any) => ({ columnName: c.columnName || c.column_name, columnLabel: c.displayName || c.columnLabel || c.column_label }))); + }).catch(() => setTargetColumns([])); + }, [form.target_table]); + + const loadLogs = useCallback(async (configId: string) => { + setLogsLoading(true); + try { + const res = await apiClient.get(`/crawl/configs/${configId}/logs?limit=20`); + setLogs(res.data.data || []); + } catch { + setLogs([]); + } finally { + setLogsLoading(false); + } + }, []); + + useEffect(() => { + if (selectedId) loadLogs(selectedId); + else setLogs([]); + }, [selectedId, loadLogs]); + + // ─── 필터링 ─── + + const filteredConfigs = configs.filter( + (c) => + !searchText || + c.name.toLowerCase().includes(searchText.toLowerCase()) || + c.url.toLowerCase().includes(searchText.toLowerCase()) + ); + + const selectedConfig = configs.find((c) => c.id === selectedId); + + // ─── CRUD ─── + + const openAddModal = () => { + setModalMode("add"); + setForm({ ...EMPTY_CONFIG }); + setMappingRows([]); + setAnalyzedTables([]); + setSelectedAnalyzedIdx(null); + setModalOpen(true); + }; + + const openEditModal = (config: CrawlConfig) => { + setModalMode("edit"); + setForm({ ...config }); + const mappings = typeof config.column_mappings === "string" + ? JSON.parse(config.column_mappings) : config.column_mappings || []; + setMappingRows(mappings.map((m: any) => ({ selector: m.selector || "", column: m.column || "", type: m.type || "text" }))); + setAnalyzedTables([]); + setSelectedAnalyzedIdx(null); + setModalOpen(true); + }; + + // URL 분석 + const handleAnalyze = async () => { + if (!form.url) { toast.error("URL을 입력하세요."); return; } + setAnalyzing(true); + try { + const res = await apiClient.post("/crawl/analyze", { url: form.url }); + const data = res.data.data; + setAnalyzedTables(data.tables || []); + if (data.tables?.length > 0) { + toast.success(`${data.tables.length}개 테이블 감지됨`); + } else { + toast.info("페이지에서 테이블을 찾지 못했습니다."); + } + } catch (err: any) { + toast.error(err.response?.data?.message || "URL 분석 실패"); + } finally { + setAnalyzing(false); + } + }; + + // 분석된 테이블 선택 시 자동 매핑 생성 + const handleSelectAnalyzedTable = (idx: number) => { + const table = analyzedTables[idx]; + if (!table) return; + setSelectedAnalyzedIdx(idx); + setForm((p) => ({ ...p, row_selector: `${table.selector} tbody tr` })); + // 헤더 기반으로 컬럼 매핑 자동 생성 + const newMappings = table.headers.map((h, i) => ({ + selector: `td:nth-child(${i + 1})`, + column: h.replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_가-힣]/g, "").toLowerCase() || `col_${i + 1}`, + type: "text", + })); + setMappingRows(newMappings); + }; + + const handleSave = async () => { + if (!form.name || !form.url || !form.target_table) { + toast.error("이름, URL, 대상 테이블은 필수입니다."); + return; + } + + setSaving(true); + try { + const payload = { + ...form, + column_mappings: mappingRows.filter((m) => m.selector && m.column), + headers: form.headers || {}, + }; + + if (modalMode === "add") { + await apiClient.post("/crawl/configs", payload); + toast.success("크롤링 설정이 생성되었습니다."); + } else { + await apiClient.put(`/crawl/configs/${form.id}`, payload); + toast.success("크롤링 설정이 수정되었습니다."); + } + + setModalOpen(false); + loadConfigs(); + } catch (err: any) { + toast.error(err.response?.data?.message || "저장 실패"); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + const ok = await confirm("정말 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await apiClient.delete(`/crawl/configs/${id}`); + toast.success("삭제되었습니다."); + if (selectedId === id) setSelectedId(null); + loadConfigs(); + } catch { + toast.error("삭제 실패"); + } + }; + + // ─── 실행 & 미리보기 ─── + + const handleExecute = async (id: string) => { + setExecuting(id); + try { + const res = await apiClient.post(`/crawl/execute/${id}`); + const data = res.data.data; + toast.success(`수집 ${data.collected}건, 저장 ${data.saved}건`); + loadConfigs(); + if (selectedId === id) loadLogs(id); + } catch (err: any) { + toast.error(err.response?.data?.message || "실행 실패"); + } finally { + setExecuting(null); + } + }; + + const handlePreview = async () => { + setPreviewing(true); + try { + const res = await apiClient.post("/crawl/preview", { + url: form.url, + row_selector: form.row_selector, + column_mappings: mappingRows.filter((m) => m.selector && m.column), + method: form.method, + headers: form.headers || {}, + request_body: form.request_body, + }); + setPreviewData(res.data.data); + setPreviewOpen(true); + } catch (err: any) { + toast.error(err.response?.data?.message || "미리보기 실패"); + } finally { + setPreviewing(false); + } + }; + + // ─── 렌더링 ─── + + return ( +
+ {/* 좌측: 설정 목록 */} +
+
+

크롤링 설정

+
+ + +
+
+ +
+
+ + setSearchText(e.target.value)} + placeholder="검색..." + className="h-8 pl-8 text-xs" + /> +
+
+ +
+ {filteredConfigs.length === 0 ? ( +
+ {loading ? "로딩 중..." : "설정이 없습니다."} +
+ ) : ( + filteredConfigs.map((config) => ( +
setSelectedId(config.id)} + > +
+
+ + {config.name} +
+ + {config.is_active === "Y" ? "활성" : "비활성"} + +
+
{config.url}
+
+ {config.cron_schedule && ( + + {config.cron_schedule} + + )} + {config.last_status && ( + + {config.last_status === "success" ? ( + + ) : ( + + )} + {config.last_status} + + )} +
+
+ )) + )} +
+
+ + {/* 우측: 상세 + 로그 */} +
+ {selectedConfig ? ( + <> + {/* 상세 정보 */} +
+
+

{selectedConfig.name}

+
+ + + +
+
+ +
+
+ URL +
{selectedConfig.url}
+
+
+ 대상 테이블 +
{selectedConfig.target_table}
+
+
+ 행 선택자 +
{selectedConfig.row_selector || "-"}
+
+
+ 스케줄 +
{selectedConfig.cron_schedule || "수동 실행"}
+
+
+ UPSERT 키 +
{selectedConfig.upsert_key || "-"}
+
+
+ 컬럼 매핑 +
{(selectedConfig.column_mappings || []).length}개
+
+
+ + {selectedConfig.last_error && ( +
+ {selectedConfig.last_error} +
+ )} +
+ + {/* 실행 로그 */} +
+
+

실행 로그

+ +
+
+ {logs.length === 0 ? ( +
실행 로그가 없습니다.
+ ) : ( +
+ + + + + + + + + + + {logs.map((log) => ( + + + + + + + + ))} + +
상태시작수집저장에러
+ + {log.status} + + {new Date(log.started_at).toLocaleString("ko-KR")}{log.rows_collected}{log.rows_saved} + {log.error_message || "-"} +
+ )} +
+
+ + ) : ( +
+ 좌측에서 크롤링 설정을 선택하세요. +
+ )} + + + {/* 추가/수정 모달 */} + + + + {modalMode === "add" ? "크롤링 설정 추가" : "크롤링 설정 수정"} + +
+ {/* STEP 1: 기본 정보 */} +
+

1. 기본 정보

+
+
+ + setForm((p) => ({ ...p, name: e.target.value }))} placeholder="예: 철강 시세 수집" className="h-8 text-xs" /> +
+
+
+ + setForm((p) => ({ ...p, cron_schedule: e.target.value }))} placeholder="0 9 * * 1-5" className="h-8 text-xs font-mono" /> +
+
+ setForm((p) => ({ ...p, is_active: v ? "Y" : "N" }))} /> + +
+
+
+
+ + {/* STEP 2: URL 입력 + 분석 */} +
+

2. 수집할 웹페이지

+
+ setForm((p) => ({ ...p, url: e.target.value }))} placeholder="https://example.com/prices" className="h-8 flex-1 text-xs font-mono" /> + +
+ + {/* 분석 결과: 감지된 테이블 목록 */} + {analyzedTables.length > 0 && ( +
+ +
+ {analyzedTables.map((t, idx) => ( +
handleSelectAnalyzedTable(idx)} + > +
+ {t.caption || `테이블 ${idx + 1}`} + {t.rowCount}행 · {t.headers.length}열 +
+ {t.headers.length > 0 && ( +
+ 컬럼: {t.headers.join(", ")} +
+ )} + {t.sampleRows.length > 0 && ( +
+ 샘플: {t.sampleRows[0].join(" | ")} +
+ )} +
+ ))} +
+
+ )} +
+ + {/* STEP 3: 컬럼 매핑 (시각적 폼) */} +
+
+

3. 컬럼 매핑

+ +
+ {mappingRows.length === 0 ? ( +
+ 위에서 "페이지 분석"을 클릭하면 자동으로 매핑이 생성됩니다. +
+ ) : ( +
+
+ CSS 선택자 + 저장 컬럼명 + 타입 + +
+ {mappingRows.map((row, i) => ( +
+ { const n = [...mappingRows]; n[i] = { ...n[i], selector: e.target.value }; setMappingRows(n); }} placeholder="td:nth-child(1)" className="h-7 text-xs font-mono" /> + { const n = [...mappingRows]; n[i] = { ...n[i], column: e.target.value }; setMappingRows(n); }} placeholder="item_name" className="h-7 text-xs" /> + + +
+ ))} +
+ )} + {form.row_selector && ( +
+ 행 선택자: {form.row_selector} +
+ )} +
+ + {/* STEP 4: 저장 대상 */} +
+

4. 저장 설정

+
+
+ + + + + + + + + 테이블을 찾을 수 없습니다. + + {allTables.map((t) => ( + { setForm((p) => ({ ...p, target_table: t.tableName, upsert_key: "" })); setTablePopoverOpen(false); }}> + + {t.displayName || t.tableName} + {t.displayName && ({t.tableName})} + + ))} + + + + +
+
+ + +
+
+
+
+ + + + + +
+
+ + {/* 미리보기 모달 */} + + + + 크롤링 미리보기 + + {previewData && ( +
+
+ + 총 요소: {previewData.totalElements} + + + HTML 크기: {(previewData.htmlLength / 1024).toFixed(1)}KB + + + 미리보기 행: {previewData.previewRows?.length || 0} + +
+ {previewData.previewRows?.length > 0 ? ( +
+ + + + {Object.keys(previewData.previewRows[0]).map((key) => ( + + ))} + + + + {previewData.previewRows.map((row: any, i: number) => ( + + {Object.values(row).map((val: any, j: number) => ( + + ))} + + ))} + +
+ {key} +
+ {val != null ? String(val) : "-"} +
+
+ ) : ( +
+ 추출된 데이터가 없습니다. 선택자를 확인하세요. +
+ )} +
+ )} +
+
+ + {ConfirmDialogComponent} + + ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 49a136c5..94813685 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -89,6 +89,7 @@ const ADMIN_PAGE_REGISTRY: Record> = { // 자동화 관리 "/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }), + "/admin/automaticMng/crawlingList": dynamic(() => import("@/app/(main)/admin/automaticMng/crawlingList/page"), { ssr: false, loading: LoadingFallback }), // 설계 관리 (커스텀 페이지) "/design/task-management": dynamic(() => import("@/app/(main)/design/task-management/page"), { ssr: false, loading: LoadingFallback }), From f32861df8b8d16da3c94a76af4759a1a2acefd66 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 27 Mar 2026 14:48:15 +0900 Subject: [PATCH 3/4] feat: add new design management pages and session files - Introduced multiple new pages for design management, including change management, design requests, my work, project management, and task management. - Added session files to track design sessions with relevant details such as session ID, end time, and reason. - Enhanced the overall structure and organization of the design management features, improving user experience and functionality. This commit expands the design management capabilities within the application, allowing for better tracking and handling of design-related tasks. --- .../037169c7-72ba-4843-8e9a-417ca1423715.json | 8 +++ .../8145031e-d7ea-4aa3-94d7-ddaa69383b8a.json | 8 +++ .../design/change-management/page.tsx | 0 .../design/design-request/page.tsx | 0 .../{ => COMPANY_7}/design/my-work/page.tsx | 0 .../{ => COMPANY_7}/design/project/page.tsx | 0 .../design/task-management/page.tsx | 0 .../{ => COMPANY_7}/equipment/info/page.tsx | 0 .../logistics/material-status/page.tsx | 0 .../logistics/outbound/page.tsx | 0 .../logistics/packaging/page.tsx | 0 .../logistics/receiving/page.tsx | 0 .../master-data/department/page.tsx | 0 .../master-data/item-info/page.tsx | 0 .../outsourcing/subcontractor-item/page.tsx | 0 .../outsourcing/subcontractor/page.tsx | 0 .../production/plan-management/page.tsx | 0 .../process-info/ItemRoutingTab.tsx | 0 .../process-info/ProcessMasterTab.tsx | 0 .../process-info/ProcessWorkStandardTab.tsx | 0 .../production/process-info/page.tsx | 0 .../WorkStandardEditModal.tsx | 0 .../production/work-instruction/page.tsx | 0 .../{ => COMPANY_7}/sales/claim/page.tsx | 0 .../{ => COMPANY_7}/sales/customer/page.tsx | 0 .../{ => COMPANY_7}/sales/order/page.tsx | 0 .../{ => COMPANY_7}/sales/sales-item/page.tsx | 0 .../sales/shipping-order/page.tsx | 0 .../sales/shipping-plan/page.tsx | 0 .../components/layout/AdminPageRenderer.tsx | 52 ++++++++++++------- 30 files changed, 48 insertions(+), 20 deletions(-) create mode 100644 .omc/sessions/037169c7-72ba-4843-8e9a-417ca1423715.json create mode 100644 .omc/sessions/8145031e-d7ea-4aa3-94d7-ddaa69383b8a.json rename frontend/app/(main)/{ => COMPANY_7}/design/change-management/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/design/design-request/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/design/my-work/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/design/project/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/design/task-management/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/equipment/info/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/logistics/material-status/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/logistics/outbound/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/logistics/packaging/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/logistics/receiving/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/master-data/department/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/master-data/item-info/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/outsourcing/subcontractor-item/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/outsourcing/subcontractor/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/production/plan-management/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/production/process-info/ItemRoutingTab.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/production/process-info/ProcessMasterTab.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/production/process-info/ProcessWorkStandardTab.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/production/process-info/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/production/work-instruction/WorkStandardEditModal.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/production/work-instruction/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/sales/claim/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/sales/customer/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/sales/order/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/sales/sales-item/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/sales/shipping-order/page.tsx (100%) rename frontend/app/(main)/{ => COMPANY_7}/sales/shipping-plan/page.tsx (100%) diff --git a/.omc/sessions/037169c7-72ba-4843-8e9a-417ca1423715.json b/.omc/sessions/037169c7-72ba-4843-8e9a-417ca1423715.json new file mode 100644 index 00000000..319727ce --- /dev/null +++ b/.omc/sessions/037169c7-72ba-4843-8e9a-417ca1423715.json @@ -0,0 +1,8 @@ +{ + "session_id": "037169c7-72ba-4843-8e9a-417ca1423715", + "ended_at": "2026-03-26T08:24:13.261Z", + "reason": "other", + "agents_spawned": 0, + "agents_completed": 0, + "modes_used": [] +} \ No newline at end of file diff --git a/.omc/sessions/8145031e-d7ea-4aa3-94d7-ddaa69383b8a.json b/.omc/sessions/8145031e-d7ea-4aa3-94d7-ddaa69383b8a.json new file mode 100644 index 00000000..2d90700f --- /dev/null +++ b/.omc/sessions/8145031e-d7ea-4aa3-94d7-ddaa69383b8a.json @@ -0,0 +1,8 @@ +{ + "session_id": "8145031e-d7ea-4aa3-94d7-ddaa69383b8a", + "ended_at": "2026-03-26T09:35:10.082Z", + "reason": "other", + "agents_spawned": 0, + "agents_completed": 0, + "modes_used": [] +} \ No newline at end of file diff --git a/frontend/app/(main)/design/change-management/page.tsx b/frontend/app/(main)/COMPANY_7/design/change-management/page.tsx similarity index 100% rename from frontend/app/(main)/design/change-management/page.tsx rename to frontend/app/(main)/COMPANY_7/design/change-management/page.tsx diff --git a/frontend/app/(main)/design/design-request/page.tsx b/frontend/app/(main)/COMPANY_7/design/design-request/page.tsx similarity index 100% rename from frontend/app/(main)/design/design-request/page.tsx rename to frontend/app/(main)/COMPANY_7/design/design-request/page.tsx diff --git a/frontend/app/(main)/design/my-work/page.tsx b/frontend/app/(main)/COMPANY_7/design/my-work/page.tsx similarity index 100% rename from frontend/app/(main)/design/my-work/page.tsx rename to frontend/app/(main)/COMPANY_7/design/my-work/page.tsx diff --git a/frontend/app/(main)/design/project/page.tsx b/frontend/app/(main)/COMPANY_7/design/project/page.tsx similarity index 100% rename from frontend/app/(main)/design/project/page.tsx rename to frontend/app/(main)/COMPANY_7/design/project/page.tsx diff --git a/frontend/app/(main)/design/task-management/page.tsx b/frontend/app/(main)/COMPANY_7/design/task-management/page.tsx similarity index 100% rename from frontend/app/(main)/design/task-management/page.tsx rename to frontend/app/(main)/COMPANY_7/design/task-management/page.tsx diff --git a/frontend/app/(main)/equipment/info/page.tsx b/frontend/app/(main)/COMPANY_7/equipment/info/page.tsx similarity index 100% rename from frontend/app/(main)/equipment/info/page.tsx rename to frontend/app/(main)/COMPANY_7/equipment/info/page.tsx diff --git a/frontend/app/(main)/logistics/material-status/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/material-status/page.tsx similarity index 100% rename from frontend/app/(main)/logistics/material-status/page.tsx rename to frontend/app/(main)/COMPANY_7/logistics/material-status/page.tsx diff --git a/frontend/app/(main)/logistics/outbound/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx similarity index 100% rename from frontend/app/(main)/logistics/outbound/page.tsx rename to frontend/app/(main)/COMPANY_7/logistics/outbound/page.tsx diff --git a/frontend/app/(main)/logistics/packaging/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/packaging/page.tsx similarity index 100% rename from frontend/app/(main)/logistics/packaging/page.tsx rename to frontend/app/(main)/COMPANY_7/logistics/packaging/page.tsx diff --git a/frontend/app/(main)/logistics/receiving/page.tsx b/frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx similarity index 100% rename from frontend/app/(main)/logistics/receiving/page.tsx rename to frontend/app/(main)/COMPANY_7/logistics/receiving/page.tsx diff --git a/frontend/app/(main)/master-data/department/page.tsx b/frontend/app/(main)/COMPANY_7/master-data/department/page.tsx similarity index 100% rename from frontend/app/(main)/master-data/department/page.tsx rename to frontend/app/(main)/COMPANY_7/master-data/department/page.tsx diff --git a/frontend/app/(main)/master-data/item-info/page.tsx b/frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx similarity index 100% rename from frontend/app/(main)/master-data/item-info/page.tsx rename to frontend/app/(main)/COMPANY_7/master-data/item-info/page.tsx diff --git a/frontend/app/(main)/outsourcing/subcontractor-item/page.tsx b/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page.tsx similarity index 100% rename from frontend/app/(main)/outsourcing/subcontractor-item/page.tsx rename to frontend/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page.tsx diff --git a/frontend/app/(main)/outsourcing/subcontractor/page.tsx b/frontend/app/(main)/COMPANY_7/outsourcing/subcontractor/page.tsx similarity index 100% rename from frontend/app/(main)/outsourcing/subcontractor/page.tsx rename to frontend/app/(main)/COMPANY_7/outsourcing/subcontractor/page.tsx diff --git a/frontend/app/(main)/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx similarity index 100% rename from frontend/app/(main)/production/plan-management/page.tsx rename to frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx diff --git a/frontend/app/(main)/production/process-info/ItemRoutingTab.tsx b/frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx similarity index 100% rename from frontend/app/(main)/production/process-info/ItemRoutingTab.tsx rename to frontend/app/(main)/COMPANY_7/production/process-info/ItemRoutingTab.tsx diff --git a/frontend/app/(main)/production/process-info/ProcessMasterTab.tsx b/frontend/app/(main)/COMPANY_7/production/process-info/ProcessMasterTab.tsx similarity index 100% rename from frontend/app/(main)/production/process-info/ProcessMasterTab.tsx rename to frontend/app/(main)/COMPANY_7/production/process-info/ProcessMasterTab.tsx diff --git a/frontend/app/(main)/production/process-info/ProcessWorkStandardTab.tsx b/frontend/app/(main)/COMPANY_7/production/process-info/ProcessWorkStandardTab.tsx similarity index 100% rename from frontend/app/(main)/production/process-info/ProcessWorkStandardTab.tsx rename to frontend/app/(main)/COMPANY_7/production/process-info/ProcessWorkStandardTab.tsx diff --git a/frontend/app/(main)/production/process-info/page.tsx b/frontend/app/(main)/COMPANY_7/production/process-info/page.tsx similarity index 100% rename from frontend/app/(main)/production/process-info/page.tsx rename to frontend/app/(main)/COMPANY_7/production/process-info/page.tsx diff --git a/frontend/app/(main)/production/work-instruction/WorkStandardEditModal.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx similarity index 100% rename from frontend/app/(main)/production/work-instruction/WorkStandardEditModal.tsx rename to frontend/app/(main)/COMPANY_7/production/work-instruction/WorkStandardEditModal.tsx diff --git a/frontend/app/(main)/production/work-instruction/page.tsx b/frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx similarity index 100% rename from frontend/app/(main)/production/work-instruction/page.tsx rename to frontend/app/(main)/COMPANY_7/production/work-instruction/page.tsx diff --git a/frontend/app/(main)/sales/claim/page.tsx b/frontend/app/(main)/COMPANY_7/sales/claim/page.tsx similarity index 100% rename from frontend/app/(main)/sales/claim/page.tsx rename to frontend/app/(main)/COMPANY_7/sales/claim/page.tsx diff --git a/frontend/app/(main)/sales/customer/page.tsx b/frontend/app/(main)/COMPANY_7/sales/customer/page.tsx similarity index 100% rename from frontend/app/(main)/sales/customer/page.tsx rename to frontend/app/(main)/COMPANY_7/sales/customer/page.tsx diff --git a/frontend/app/(main)/sales/order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/order/page.tsx similarity index 100% rename from frontend/app/(main)/sales/order/page.tsx rename to frontend/app/(main)/COMPANY_7/sales/order/page.tsx diff --git a/frontend/app/(main)/sales/sales-item/page.tsx b/frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx similarity index 100% rename from frontend/app/(main)/sales/sales-item/page.tsx rename to frontend/app/(main)/COMPANY_7/sales/sales-item/page.tsx diff --git a/frontend/app/(main)/sales/shipping-order/page.tsx b/frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx similarity index 100% rename from frontend/app/(main)/sales/shipping-order/page.tsx rename to frontend/app/(main)/COMPANY_7/sales/shipping-order/page.tsx diff --git a/frontend/app/(main)/sales/shipping-plan/page.tsx b/frontend/app/(main)/COMPANY_7/sales/shipping-plan/page.tsx similarity index 100% rename from frontend/app/(main)/sales/shipping-plan/page.tsx rename to frontend/app/(main)/COMPANY_7/sales/shipping-plan/page.tsx diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 94813685..1472dbce 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -5,6 +5,7 @@ import dynamic from "next/dynamic"; import { Loader2 } from "lucide-react"; import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page"; import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; const LoadingFallback = () => (
@@ -91,23 +92,9 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/crawlingList": dynamic(() => import("@/app/(main)/admin/automaticMng/crawlingList/page"), { ssr: false, loading: LoadingFallback }), - // 설계 관리 (커스텀 페이지) - "/design/task-management": dynamic(() => import("@/app/(main)/design/task-management/page"), { ssr: false, loading: LoadingFallback }), - "/design/my-work": dynamic(() => import("@/app/(main)/design/my-work/page"), { ssr: false, loading: LoadingFallback }), - "/design/design-request": dynamic(() => import("@/app/(main)/design/design-request/page"), { ssr: false, loading: LoadingFallback }), + // 회사별 커스텀 페이지는 레지스트리에서 제거 — 회사코드 prefix로 자동 import 처리 + // (design, sales, production, logistics, equipment, outsourcing, master-data) - // 영업 관리 (커스텀 페이지) - "/sales/shipping-plan": dynamic(() => import("@/app/(main)/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }), - "/sales/shipping-order": dynamic(() => import("@/app/(main)/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }), - - // 생산 관리 (커스텀 페이지) - "/production/work-instruction": dynamic(() => import("@/app/(main)/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), - - // 물류 관리 (커스텀 페이지) - "/logistics/material-status": dynamic(() => import("@/app/(main)/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }), - - // 설계 관리 (커스텀 페이지) - "/design/change-management": dynamic(() => import("@/app/(main)/design/change-management/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }), @@ -292,10 +279,34 @@ interface AdminPageRendererProps { url: string; } +// 회사별 커스텀 페이지 경로 prefix 목록 +// 이 prefix로 시작하는 URL은 회사코드 폴더에서 로드 +const COMPANY_PAGE_PREFIXES = [ + "/sales/", + "/master-data/", + "/production/", + "/equipment/", + "/logistics/", + "/outsourcing/", + "/design/", +]; + +function isCompanyPage(url: string): boolean { + return COMPANY_PAGE_PREFIXES.some((prefix) => url.startsWith(prefix)); +} + export function AdminPageRenderer({ url }: AdminPageRendererProps) { + const { user } = useAuth(); + const companyCode = user?.companyCode || user?.company_code; const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, ""); - console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl }); + // 회사별 커스텀 페이지: companyCode를 prefix로 붙여 경로 변환 + // 예: /sales/order → /COMPANY_7/sales/order + const resolvedUrl = (companyCode && isCompanyPage(cleanUrl)) + ? `/${companyCode}${cleanUrl}` + : cleanUrl; + + console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl, resolvedUrl, companyCode }); // 화면 할당: /screens/[id] const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/); @@ -318,7 +329,7 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) { return ; } - // URL 직접 입력: 레지스트리 매칭 + // URL 직접 입력: 레지스트리 매칭 (admin 페이지 등 공통 페이지) const PageComponent = useMemo(() => { return ADMIN_PAGE_REGISTRY[cleanUrl] || null; }, [cleanUrl]); @@ -340,6 +351,7 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) { } // 레지스트리/패턴에 없으면 DynamicAdminLoader가 자동 import 시도 - console.log("[AdminPageRenderer] → 자동 import 시도:", cleanUrl); - return ; + // 회사별 페이지는 resolvedUrl로 import (예: COMPANY_7/sales/order/page) + console.log("[AdminPageRenderer] → 자동 import 시도:", resolvedUrl); + return ; } From 2e9b67a509fc4472fa08ffa8bec7522a79527af6 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 27 Mar 2026 22:32:18 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20COMPANY=5F9=20=EC=88=98=EC=A3=BC?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=83=9D=EC=82=B0=EA=B3=84=ED=9A=8D/?= =?UTF-8?q?=EA=B3=B5=EC=A0=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - COMPANY_9 수주관리(sales/order) 하드코딩 페이지 추가 - 생산계획 서비스 로직 정리 및 공정 컨트롤러 수정 - COMPANY_7 생산계획관리 페이지 개선 - AdminPageRenderer 기능 확장 --- .../src/controllers/processInfoController.ts | 2 +- .../src/services/productionPlanService.ts | 35 +- .../production/plan-management/page.tsx | 24 +- .../app/(main)/COMPANY_9/sales/order/page.tsx | 1143 +++++++++++++++++ .../components/layout/AdminPageRenderer.tsx | 64 +- 5 files changed, 1228 insertions(+), 40 deletions(-) create mode 100644 frontend/app/(main)/COMPANY_9/sales/order/page.tsx diff --git a/backend-node/src/controllers/processInfoController.ts b/backend-node/src/controllers/processInfoController.ts index 3b64928b..025d6d66 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -214,7 +214,7 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response) const params = companyCode === "*" ? [] : [companyCode]; const result = await pool.query( - `SELECT objid AS id, equipment_code, equipment_name FROM equipment_mng ${condition} ORDER BY equipment_code`, + `SELECT id, equipment_code, equipment_name FROM equipment_mng ${condition} ORDER BY equipment_code`, params ); diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index 0481922c..adeef0ea 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -401,22 +401,9 @@ export async function previewSchedule( const dailyCapacity = item.daily_capacity || 800; const itemLeadTime = item.lead_time || 0; - let requiredQty = item.required_qty; - - // recalculate_unstarted 시, 삭제된 수량을 비율로 분배 - if (options.recalculate_unstarted) { - const deletedQtyForItem = deletedSchedules - .filter((d: any) => d.item_code === item.item_code) - .reduce((sum: number, d: any) => sum + (parseFloat(d.plan_qty) || 0), 0); - if (deletedQtyForItem > 0) { - const totalRequestedForItem = items - .filter((i) => i.item_code === item.item_code) - .reduce((sum, i) => sum + i.required_qty, 0); - if (totalRequestedForItem > 0) { - requiredQty += Math.round(deletedQtyForItem * (item.required_qty / totalRequestedForItem)); - } - } - } + // 프론트에서 이미 전체 잔량 기준으로 계산하여 보내므로 그대로 사용 + // (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨) + const requiredQty = item.required_qty; if (requiredQty <= 0) continue; @@ -543,19 +530,9 @@ export async function generateSchedule( // 필요 수량 계산 (삭제된 planned 수량을 비율로 분배) const dailyCapacity = item.daily_capacity || 800; const itemLeadTime = item.lead_time || 0; - let requiredQty = item.required_qty; - - if (options.recalculate_unstarted) { - const deletedQty = deletedQtyByItem.get(item.item_code) || 0; - if (deletedQty > 0) { - const totalRequestedForItem = items - .filter((i) => i.item_code === item.item_code) - .reduce((sum, i) => sum + i.required_qty, 0); - if (totalRequestedForItem > 0) { - requiredQty += Math.round(deletedQty * (item.required_qty / totalRequestedForItem)); - } - } - } + // 프론트에서 이미 전체 잔량 기준으로 계산하여 보내므로 그대로 사용 + // (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨) + const requiredQty = item.required_qty; if (requiredQty <= 0) continue; // 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산 diff --git a/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx index 51f7af14..a49555b6 100644 --- a/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx +++ b/frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx @@ -409,7 +409,10 @@ export default function ProductionPlanManagementPage() { .filter((item) => selectedItemGroups.has(item.item_code)) .forEach((item) => { const leadTime = Number(item.lead_time) || 0; - const totalRequired = Number(item.required_plan_qty); + // 재계산 모드: 기존 planned를 삭제 후 재생성 → 수주 잔량에서 진행중만 빼기 + const totalRequired = recalculateUnstarted + ? Number(item.total_balance_qty || 0) - Number(item.in_progress_qty || 0) + : Number(item.required_plan_qty); if (totalRequired <= 0) return; // 수주가 여러 건이고 납기일이 다르면 각각 분리 @@ -460,6 +463,14 @@ export default function ProductionPlanManagementPage() { } }); + // items가 비어있으면 사용자에게 알림 + + + if (items.length === 0) { + toast.error("계획 수량이 있는 품목이 없습니다. 수주 잔량을 확인해주세요."); + return; + } + setGenerating(true); try { const req: GenerateScheduleRequest = { @@ -491,7 +502,9 @@ export default function ProductionPlanManagementPage() { .filter((item) => selectedItemGroups.has(item.item_code)) .forEach((item) => { const leadTime = Number(item.lead_time) || 0; - const totalRequired = Number(item.required_plan_qty); + const totalRequired = recalculateUnstarted + ? Number(item.required_plan_qty) + Number(item.existing_plan_qty || 0) + : Number(item.required_plan_qty); if (totalRequired <= 0) return; if (item.orders && item.orders.length > 1) { @@ -768,9 +781,12 @@ export default function ProductionPlanManagementPage() { .map((item) => ({ item_code: item.item_code, item_name: item.item_name, - required_qty: Number(item.required_plan_qty), + required_qty: (importMode !== "new" && recalculateUnstarted) + ? Number(item.total_balance_qty || 0) - Number(item.in_progress_qty || 0) + : Number(item.required_plan_qty), earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0], - })); + })) + .filter((item) => item.required_qty > 0); setGenerating(true); try { diff --git a/frontend/app/(main)/COMPANY_9/sales/order/page.tsx b/frontend/app/(main)/COMPANY_9/sales/order/page.tsx new file mode 100644 index 00000000..6f4502bd --- /dev/null +++ b/frontend/app/(main)/COMPANY_9/sales/order/page.tsx @@ -0,0 +1,1143 @@ +"use client"; + +/** + * 제일그라스(COMPANY_9) 수주관리 — 하드코딩 페이지 + * + * 좌측: 수주 마스터 목록 (order_no 그룹핑 집계) + * 우측: 선택한 수주의 품목 상세 (sales_order_detail) + * + * 특화: 가로/세로/두께/면적 컬럼 (유리 업종) + * 특화: 품목 자동 등록 (item_info에 없으면 저장 시 자동 INSERT) + */ + +import React, { useState, useEffect, useCallback, useMemo } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { + Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, + ClipboardList, Package, Search, X, Settings2, GripVertical, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; +import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; +import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule"; + +const MASTER_TABLE = "sales_order_mng"; +const DETAIL_TABLE = "sales_order_detail"; +const ITEM_TABLE = "item_info"; + +const formatNumber = (val: string) => { + const num = val.replace(/[^\d.-]/g, ""); + if (!num) return ""; + const parts = num.split("."); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return parts.join("."); +}; +const parseNumber = (val: string) => val.replace(/,/g, ""); + +// 좌측: 수주 마스터 집계 목록 +const LEFT_COLUMNS: DataGridColumn[] = [ + { key: "order_no", label: "수주번호", width: "w-[120px]" }, + { key: "partner_name", label: "거래처", minWidth: "min-w-[100px]" }, + { key: "item_count", label: "품목수", width: "w-[60px]", align: "right" }, + { key: "total_qty", label: "총수량", width: "w-[80px]", formatNumber: true, align: "right" }, + { key: "total_ship_qty", label: "출하량", width: "w-[70px]", formatNumber: true, align: "right" }, + { key: "total_balance", label: "잔량", width: "w-[70px]", formatNumber: true, align: "right" }, + { key: "total_amount", label: "총금액", width: "w-[100px]", formatNumber: true, align: "right" }, + { key: "due_date", label: "납기일", width: "w-[100px]" }, + { key: "status", label: "상태", width: "w-[70px]" }, +]; + +// 우측: 품목 상세 (가로/세로/두께/면적 포함) +const RIGHT_COLUMNS: DataGridColumn[] = [ + { key: "division", label: "구분", width: "w-[70px]" }, + { key: "part_name", label: "품명", minWidth: "min-w-[120px]" }, + { key: "spec", label: "규격", width: "w-[100px]" }, + { key: "width", label: "가로", width: "w-[65px]", formatNumber: true, align: "right" }, + { key: "height", label: "세로", width: "w-[65px]", formatNumber: true, align: "right" }, + { key: "thickness", label: "두께", width: "w-[60px]", align: "right" }, + { key: "area", label: "면적", width: "w-[70px]", align: "right" }, + { key: "unit", label: "단위", width: "w-[50px]" }, + { key: "qty", label: "수량", width: "w-[70px]", formatNumber: true, align: "right" }, + { key: "ship_qty", label: "출하", width: "w-[60px]", formatNumber: true, align: "right" }, + { key: "balance_qty", label: "잔량", width: "w-[60px]", formatNumber: true, align: "right" }, + { key: "unit_price", label: "단가", width: "w-[85px]", formatNumber: true, align: "right" }, + { key: "amount", label: "금액", width: "w-[95px]", formatNumber: true, align: "right" }, + { key: "due_date", label: "납기일", width: "w-[100px]" }, + { key: "memo", label: "비고", width: "w-[80px]" }, +]; + +export default function JeilGlassOrderPage() { + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + // 좌측: 수주 목록 + const [masterOrders, setMasterOrders] = useState([]); + const [allDetails, setAllDetails] = useState([]); + const [loading, setLoading] = useState(false); + const [totalCount, setTotalCount] = useState(0); + const [searchFilters, setSearchFilters] = useState([]); + const [selectedOrderNo, setSelectedOrderNo] = useState(null); + const [checkedIds, setCheckedIds] = useState([]); + + // 우측: 디테일 + const [detailItems, setDetailItems] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); + + // 모달 + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [saving, setSaving] = useState(false); + const [masterForm, setMasterForm] = useState>({}); + const [modalDetailRows, setModalDetailRows] = useState([]); + + // 품목 선택 모달 + const [itemSelectOpen, setItemSelectOpen] = useState(false); + const [itemSearchKeyword, setItemSearchKeyword] = useState(""); + const [itemSearchResults, setItemSearchResults] = useState([]); + const [itemSearchLoading, setItemSearchLoading] = useState(false); + const [itemCheckedIds, setItemCheckedIds] = useState>(new Set()); + + // 기타 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); + const [categoryOptions, setCategoryOptions] = useState>({}); + + // 채번 + const [numberingRuleId, setNumberingRuleId] = useState(null); + + // 테이블 설정 + const applyTableSettings = (settings: TableSettings) => { + if (settings.filters) setFilterConfig(settings.filters); + }; + useEffect(() => { + const saved = loadTableSettings(MASTER_TABLE, "jeilglass-order"); + if (saved?.filters) setFilterConfig(saved.filters); + }, []); + + // 채번규칙 로드 + useEffect(() => { + const loadRule = async () => { + try { + const res = await apiClient.get(`/numbering-rules/by-column/${MASTER_TABLE}/order_no`); + const rule = res.data?.data; + if (rule?.ruleId || rule?.rule_id) { + setNumberingRuleId(rule.ruleId || rule.rule_id); + } + } catch { /* 채번규칙 없음 — fallback 사용 */ } + }; + loadRule(); + }, []); + + // 카테고리 로드 + useEffect(() => { + const loadCategories = async () => { + const optMap: Record = {}; + const flatten = (vals: any[]): { code: string; label: string }[] => { + const result: { code: string; label: string }[] = []; + for (const v of vals) { + result.push({ code: v.valueCode || v.code, label: v.valueLabel || v.label || v.valueCode }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + // 마스터 카테고리 + for (const col of ["sell_mode", "input_mode", "price_mode"]) { + try { + const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`); + optMap[col] = flatten(res.data?.data || []); + } catch { /* skip */ } + } + // 거래처 + try { + const res = await apiClient.post(`/table-management/tables/customer_mng/data`, { page: 1, size: 500, autoFilter: true }); + const custs = res.data?.data?.data || res.data?.data?.rows || []; + optMap["partner_id"] = custs.map((c: any) => ({ + code: c.customer_code, + label: `${c.customer_name} (${c.customer_code})`, + })); + } catch { /* skip */ } + // 담당자 + try { + const res = await apiClient.post(`/table-management/tables/user_info/data`, { page: 1, size: 200, autoFilter: true }); + const users = res.data?.data?.data || res.data?.data?.rows || []; + optMap["manager_id"] = users.map((u: any) => ({ + code: u.user_id || u.id, + label: `${u.user_name || ""}${u.position_name ? ` (${u.position_name})` : ""}`, + })); + } catch { /* skip */ } + // 품목 카테고리 (단위, 구분, 재질, 유형) + for (const col of ["unit", "division", "material", "type"]) { + try { + const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) { + optMap[`item_${col}`] = flatten(res.data.data); + } + } catch { /* skip */ } + } + setCategoryOptions(optMap); + }; + loadCategories(); + }, []); + + // 수주 목록 조회 (디테일 전체 → order_no 그룹핑) + const fetchMasterOrders = useCallback(async () => { + setLoading(true); + try { + const filters: any[] = searchFilters.map((f) => ({ + columnName: f.columnName, + operator: f.operator, + value: f.value, + })); + const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { + page: 1, size: 500, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + sort: { columnName: "order_no", order: "desc" }, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + setAllDetails(rows); + + // 마스터 조회 (거래처 정보 확보) + const orderNos = [...new Set(rows.map((r: any) => r.order_no).filter(Boolean))]; + let masterMap: Record = {}; + if (orderNos.length > 0) { + try { + const mRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + page: 1, size: orderNos.length + 10, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "in", value: orderNos }] }, + autoFilter: true, + }); + const masters = mRes.data?.data?.data || mRes.data?.data?.rows || []; + for (const m of masters) masterMap[m.order_no] = m; + } catch { /* skip */ } + } + + // 거래처 코드 → 이름 변환 + const resolvePartner = (code: string) => { + if (!code) return ""; + return categoryOptions["partner_id"]?.find((o) => o.code === code)?.label?.split(" (")[0] || code; + }; + + // order_no 기준 집계 + const grouped: Record = {}; + for (const row of rows) { + const no = row.order_no; + if (!no) continue; + if (!grouped[no]) { + const master = masterMap[no] || {}; + grouped[no] = { + id: `master_${no}`, + order_no: no, + partner_name: resolvePartner(master.partner_id), + item_count: 0, + total_qty: 0, + total_ship_qty: 0, + total_balance: 0, + total_amount: 0, + due_date: row.due_date || "", + status: master.status || "", + }; + } + const g = grouped[no]; + g.item_count += 1; + g.total_qty += parseFloat(row.qty) || 0; + g.total_ship_qty += parseFloat(row.ship_qty) || 0; + g.total_balance += parseFloat(row.balance_qty) || 0; + g.total_amount += parseFloat(row.amount) || 0; + if (row.due_date && (!g.due_date || row.due_date > g.due_date)) g.due_date = row.due_date; + } + const list = Object.values(grouped); + setMasterOrders(list); + setTotalCount(list.length); + } catch (err) { + console.error("수주 조회 실패:", err); + toast.error("수주 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, [searchFilters, categoryOptions]); + + useEffect(() => { fetchMasterOrders(); }, [fetchMasterOrders]); + + // 통계 + const stats = useMemo(() => { + let totalAmount = 0, totalQty = 0; + for (const m of masterOrders) { + totalAmount += m.total_amount || 0; + totalQty += m.total_qty || 0; + } + return { totalAmount, totalQty }; + }, [masterOrders]); + + // 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환) + useEffect(() => { + if (!selectedOrderNo) { setDetailItems([]); return; } + const items = allDetails + .filter((d) => d.order_no === selectedOrderNo) + .map((d) => ({ + ...d, + division: categoryOptions["item_division"]?.find((o) => o.code === d.division)?.label || d.division || "", + })); + setDetailItems(items); + }, [selectedOrderNo, allDetails, categoryOptions]); + + // 좌측 행 클릭 + const handleMasterRowClick = (row: any) => { + setSelectedOrderNo(row.order_no); + }; + + // 등록 모달 + const openRegisterModal = async () => { + let previewOrderNo = ""; + if (numberingRuleId) { + const res = await previewNumberingCode(numberingRuleId); + if (res.success && res.data?.generatedCode) { + previewOrderNo = res.data.generatedCode; + } + } + if (!previewOrderNo) { + previewOrderNo = `ORD-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${String(Date.now()).slice(-4)}`; + } + setMasterForm({ order_no: previewOrderNo, manager_id: user?.userId || "" }); + setModalDetailRows([]); + setIsEditMode(false); + setIsModalOpen(true); + }; + + // 수정 모달 + const openEditModal = async (orderNo: string) => { + try { + const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, + autoFilter: true, + }); + const masterData = (masterRes.data?.data?.data || masterRes.data?.data?.rows || [])[0]; + + const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, + autoFilter: true, + }); + const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; + + setMasterForm(masterData || {}); + setModalDetailRows(detailData.map((d: any, i: number) => ({ + ...d, + _id: d.id || `row_${i}`, + _fromItemInfo: !!d.part_code, + _divisionLabel: categoryOptions["item_division"]?.find((o: any) => o.code === d.division)?.label || d.division || "", + }))); + setIsEditMode(true); + setIsModalOpen(true); + } catch (err) { + console.error("수주 상세 조회 실패:", err); + toast.error("수주 정보를 불러오는데 실패했습니다."); + } + }; + + // 삭제 + const handleDelete = async () => { + if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; } + const selectedItems = masterOrders.filter((o) => checkedIds.includes(o.id)); + const orderNos = selectedItems.map((o) => o.order_no); + const ok = await confirm(`${orderNos.length}건의 수주를 삭제하시겠습니까?`, { + description: "삭제된 데이터는 복구할 수 없습니다.", + variant: "destructive", + confirmText: "삭제", + }); + if (!ok) return; + try { + for (const orderNo of orderNos) { + // 디테일 삭제 + const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, + autoFilter: true, + }); + const details = detailRes.data?.data?.data || detailRes.data?.data?.rows || []; + if (details.length > 0) { + await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { + data: details.map((d: any) => ({ id: d.id })), + }); + } + // 마스터 삭제 + const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] }, + autoFilter: true, + }); + const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || []; + if (masters.length > 0) { + await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, { + data: masters.map((m: any) => ({ id: m.id })), + }); + } + } + toast.success("삭제되었습니다."); + setCheckedIds([]); + setSelectedOrderNo(null); + fetchMasterOrders(); + } catch (err) { + console.error("삭제 실패:", err); + toast.error("삭제에 실패했습니다."); + } + }; + + // 품목 자동 등록 (item_info에 없으면 등록) + const autoRegisterItems = async (rows: any[]) => { + for (const row of rows) { + if (row.part_code || !row.part_name) continue; + try { + // item_info에서 품명으로 검색 + const searchRes = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "item_name", operator: "equals", value: row.part_name }] }, + autoFilter: true, + }); + const found = (searchRes.data?.data?.data || searchRes.data?.data?.rows || [])[0]; + if (found) { + row.part_code = found.item_number; + continue; + } + // 없으면 자동 등록 + await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { + item_name: row.part_name, + size: row.spec || "", + unit: row.unit || "", + }); + // 등록 후 재조회하여 item_number 획득 + const reSearch = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { + page: 1, size: 1, + dataFilter: { enabled: true, filters: [{ columnName: "item_name", operator: "equals", value: row.part_name }] }, + autoFilter: true, + sort: { columnName: "created_date", order: "desc" }, + }); + const newItem = (reSearch.data?.data?.data || reSearch.data?.data?.rows || [])[0]; + if (newItem) row.part_code = newItem.item_number; + } catch (err) { + console.warn("품목 자동 등록 실패:", row.part_name, err); + } + } + }; + + // 저장 + const handleSave = async () => { + if (modalDetailRows.length === 0) { + toast.error("품목을 1개 이상 추가해주세요."); + return; + } + + setSaving(true); + try { + // 품목 자동 등록 + await autoRegisterItems(modalDetailRows); + + // 신규 등록 시 채번 할당 + if (!isEditMode) { + if (numberingRuleId) { + const allocRes = await allocateNumberingCode(numberingRuleId, masterForm.order_no); + if (allocRes.success && allocRes.data?.generatedCode) { + masterForm.order_no = allocRes.data.generatedCode; + } + } + if (!masterForm.order_no) { + masterForm.order_no = `ORD-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${String(Date.now()).slice(-4)}`; + } + } + + const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm; + + if (isEditMode && id) { + await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, { + originalData: { id }, + updatedData: masterFields, + }); + const existingDetails = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: masterForm.order_no }] }, + autoFilter: true, + }); + const existings = existingDetails.data?.data?.data || existingDetails.data?.data?.rows || []; + if (existings.length > 0) { + await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, { + data: existings.map((d: any) => ({ id: d.id })), + }); + } + } else { + await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields); + } + + for (let i = 0; i < modalDetailRows.length; i++) { + const row = modalDetailRows[i]; + const { _id, _fromItemInfo, _divisionLabel, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row; + await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, { + ...detailFields, + order_no: masterForm.order_no, + seq_no: String(i + 1), + }); + } + + toast.success(isEditMode ? "수정되었습니다." : "등록되었습니다."); + setIsModalOpen(false); + fetchMasterOrders(); + } catch (err: any) { + console.error("저장 실패:", err); + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 품목 검색 + const searchItems = async () => { + setItemSearchLoading(true); + try { + const filters: any[] = []; + if (itemSearchKeyword) { + filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); + } + const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, { + page: 1, size: 50, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setItemSearchResults([]); } + finally { setItemSearchLoading(false); } + }; + + // 품목 선택 → 리피터에 추가 + const addSelectedItemsToDetail = () => { + const selected = itemSearchResults.filter((i) => itemCheckedIds.has(i.id)); + const resolveUnit = (code: string) => { + if (!code) return ""; + return categoryOptions["item_unit"]?.find((o) => o.code === code)?.label || code; + }; + const resolveDivision = (code: string) => { + if (!code) return ""; + return categoryOptions["item_division"]?.find((o) => o.code === code)?.label || code; + }; + const newRows = selected.map((item) => ({ + _id: `new_${Date.now()}_${Math.random()}`, + _fromItemInfo: true, + part_code: item.item_number || "", + part_name: item.item_name || "", + spec: item.size || "", + division: item.division || "", + _divisionLabel: resolveDivision(item.division), + unit: resolveUnit(item.unit) || "", + width: "", height: "", thickness: "", area: "", + qty: "", unit_price: item.selling_price || item.standard_price || "", amount: "", + due_date: "", memo: "", + })); + setModalDetailRows((prev) => [...prev, ...newRows]); + setItemSelectOpen(false); + setItemCheckedIds(new Set()); + }; + + // 빈 행 추가 (품명 직접 입력용) + const addEmptyRow = () => { + setModalDetailRows((prev) => [...prev, { + _id: `new_${Date.now()}_${Math.random()}`, + _fromItemInfo: false, + part_code: "", part_name: "", spec: "", division: "", _divisionLabel: "", unit: "㎡", + width: "", height: "", thickness: "", area: "", + qty: "", unit_price: "", amount: "", + due_date: "", memo: "", + }]); + }; + + // 구분(division) 라벨로 면적 계산 제수 결정 + const getAreaDivisor = (divisionCode: string) => { + const label = categoryOptions["item_division"]?.find((o) => o.code === divisionCode)?.label || ""; + // 원판, 원자재 → 92,094 / 그 외(제품 등) → 91,808 + if (label.includes("원판") || label.includes("원자재")) return 92094; + return 91808; + }; + + // 면적 계산 (구분에 따른 제수 적용) + const calcArea = (row: any) => { + const w = parseFloat(row.width) || 0; + const h = parseFloat(row.height) || 0; + if (w <= 0 || h <= 0) return ""; + const divisor = getAreaDivisor(row.division); + return (w * h / divisor).toFixed(4); + }; + + // 리피터 행 값 변경 + 면적/금액 자동 계산 + const updateDetailRow = (idx: number, field: string, value: string) => { + setModalDetailRows((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], [field]: value }; + // 면적 자동 계산 (구분/가로/세로 변경 시) + if (field === "width" || field === "height" || field === "division") { + next[idx].area = calcArea(next[idx]); + } + // 금액 자동 계산 + if (field === "qty" || field === "unit_price") { + const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0; + const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0; + next[idx].amount = (qty * price).toString(); + } + return next; + }); + }; + + const removeDetailRow = (idx: number) => { + setModalDetailRows((prev) => prev.filter((_, i) => i !== idx)); + }; + + // 엑셀 다운로드 (마스터+디테일 통합) + const handleExcelDownload = async () => { + if (allDetails.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; } + // 마스터 정보 매핑 + const masterMap: Record = {}; + for (const m of masterOrders) masterMap[m.order_no] = m; + + const resolveDiv = (code: string) => + categoryOptions["item_division"]?.find((o) => o.code === code)?.label || code || ""; + + const data = allDetails.map((o) => { + const master = masterMap[o.order_no] || {}; + return { + "수주번호": o.order_no || "", + "거래처": master.partner_name || "", + "상태": master.status || "", + "구분": resolveDiv(o.division), + "품명": o.part_name || "", + "규격": o.spec || "", + "가로": o.width || "", + "세로": o.height || "", + "두께": o.thickness || "", + "면적": o.area || "", + "단위": o.unit || "", + "수량": o.qty || "", + "출하": o.ship_qty || "", + "잔량": o.balance_qty || "", + "단가": o.unit_price || "", + "금액": o.amount || "", + "납기일": o.due_date || "", + "비고": o.memo || "", + }; + }); + await exportToExcel(data, "제일그라스_수주관리.xlsx", "수주목록"); + toast.success("다운로드 완료"); + }; + + // 엑셀 업로드 후처리: order_no가 비어있는 디테일에 마스터 자동 생성 + const handleExcelUploadSuccess = async () => { + try { + // 마스터 없는 디테일(order_no 비어있는) 조회 + const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, { + page: 1, size: 500, autoFilter: true, + sort: { columnName: "created_date", order: "desc" }, + }); + const allRows = res.data?.data?.data || res.data?.data?.rows || []; + + // order_no가 비어있는 행들 수집 + const noOrderRows = allRows.filter((r: any) => !r.order_no); + if (noOrderRows.length > 0) { + // 채번 후 마스터 생성 + 디테일에 order_no 설정 + let orderNo = ""; + if (numberingRuleId) { + const allocRes = await allocateNumberingCode(numberingRuleId); + if (allocRes.success && allocRes.data?.generatedCode) orderNo = allocRes.data.generatedCode; + } + if (!orderNo) { + orderNo = `ORD-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-${String(Date.now()).slice(-4)}`; + } + + // 마스터 생성 + await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, { + order_no: orderNo, + status: "수주", + manager_id: user?.userId || "", + order_date: new Date().toISOString().slice(0, 10), + }); + + // 디테일에 order_no + 면적/금액 계산하여 업데이트 + for (let i = 0; i < noOrderRows.length; i++) { + const row = noOrderRows[i]; + const w = parseFloat(row.width) || 0; + const h = parseFloat(row.height) || 0; + const qty = parseFloat(row.qty) || 0; + const price = parseFloat(row.unit_price) || 0; + const area = w > 0 && h > 0 ? (w * h / 91808).toFixed(4) : ""; + const amount = (qty * price).toString(); + + await apiClient.put(`/table-management/tables/${DETAIL_TABLE}/edit`, { + originalData: { id: row.id }, + updatedData: { + order_no: orderNo, + seq_no: String(i + 1), + area: area || row.area || "", + amount: amount || row.amount || "", + }, + }); + } + toast.success(`${noOrderRows.length}건의 품목에 수주번호 ${orderNo} 할당 완료`); + } + } catch (err) { + console.error("엑셀 업로드 후처리 실패:", err); + } + fetchMasterOrders(); + }; + + return ( +
+ {/* 검색 필터 */} + + + {/* 통계 바 */} +
+
+ 총 금액 + {stats.totalAmount.toLocaleString()}원 +
+
+ 총 수량 + {stats.totalQty.toLocaleString()}개 +
+
+ + {/* 좌우 분할 */} +
+ + {/* 좌측: 수주 목록 */} + +
+
+
+ 수주 목록 + {totalCount}건 +
+
+ + + +
+
+ openEditModal(row.order_no)} + tableName={MASTER_TABLE} + emptyMessage="등록된 수주가 없습니다" + /> +
+
+ + + + {/* 우측: 품목 상세 */} + +
+
+
+ 품목 상세 + {selectedOrderNo && ( + {selectedOrderNo} + )} + {detailItems.length > 0 && ( + ({detailItems.length}건) + )} +
+
+ + + +
+
+ {!selectedOrderNo ? ( +
+ 좌측에서 수주를 선택하세요 +
+ ) : ( + + )} +
+
+
+
+ + {/* 등록/수정 모달 */} + + + + + } + > +
+ {/* 기본 정보 */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + setMasterForm((p) => ({ ...p, order_date: v }))} placeholder="수주일" /> +
+
+
+
+ + setMasterForm((p) => ({ ...p, due_date: v }))} placeholder="납기일" /> +
+
+ + +
+
+ + setMasterForm((p) => ({ ...p, memo: e.target.value }))} + placeholder="메모" className="h-9" /> +
+
+ + {/* 품목 리피터 */} +
+
+ 수주 품목 ({modalDetailRows.length}건) +
+ + +
+
+
+ + + + + + No + 구분 + 품명 + 규격 + 가로 + 세로 + 두께 + 면적(㎡) + 단위 + 수량 + 단가 + 금액 + 납기일 + + + + {modalDetailRows.length === 0 ? ( + 품목을 추가해주세요 + ) : modalDetailRows.map((row, idx) => ( + { e.dataTransfer.setData("text/plain", String(idx)); e.currentTarget.classList.add("opacity-50"); }} + onDragEnd={(e) => { e.currentTarget.classList.remove("opacity-50"); }} + onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add("border-t-2", "border-primary"); }} + onDragLeave={(e) => { e.currentTarget.classList.remove("border-t-2", "border-primary"); }} + onDrop={(e) => { + e.preventDefault(); + e.currentTarget.classList.remove("border-t-2", "border-primary"); + const fromIdx = parseInt(e.dataTransfer.getData("text/plain")); + if (!isNaN(fromIdx) && fromIdx !== idx) { + setModalDetailRows((prev) => { + const next = [...prev]; + const [moved] = next.splice(fromIdx, 1); + next.splice(idx, 0, moved); + return next; + }); + } + }} + > + + + + + + + {idx + 1} + {/* 구분: 품목검색 → 읽기전용, 행추가 → Select */} + + {row._fromItemInfo ? ( + {row._divisionLabel || "-"} + ) : ( + + )} + + {/* 품명: 품목검색 → 읽기전용, 행추가 → 입력 */} + + {row._fromItemInfo ? ( + {row.part_name || "-"} + ) : ( + updateDetailRow(idx, "part_name", e.target.value)} + className="h-8 text-sm" placeholder="품명" /> + )} + + {/* 규격: 품목검색 → 읽기전용, 행추가 → 입력 */} + + {row._fromItemInfo ? ( + {row.spec || "-"} + ) : ( + updateDetailRow(idx, "spec", e.target.value)} + className="h-8 text-sm" placeholder="규격" /> + )} + + + updateDetailRow(idx, "width", parseNumber(e.target.value))} + className="h-8 text-sm text-right" placeholder="mm" /> + + + updateDetailRow(idx, "height", parseNumber(e.target.value))} + className="h-8 text-sm text-right" placeholder="mm" /> + + + updateDetailRow(idx, "thickness", e.target.value)} + className="h-8 text-sm text-right" placeholder="mm" /> + + + {row.area || "-"} + + {/* 단위: 품목검색 → 읽기전용, 행추가 → 입력 */} + + {row._fromItemInfo ? ( + {row.unit || "-"} + ) : ( + updateDetailRow(idx, "unit", e.target.value)} + className="h-8 text-sm" placeholder="㎡" /> + )} + + + updateDetailRow(idx, "qty", parseNumber(e.target.value))} + className="h-8 text-sm text-right" /> + + + updateDetailRow(idx, "unit_price", parseNumber(e.target.value))} + className="h-8 text-sm text-right" /> + + + {row.amount ? Number(row.amount).toLocaleString() : ""} + + + updateDetailRow(idx, "due_date", v)} placeholder="납기일" /> + + + ))} + {/* 합계 행 */} + {modalDetailRows.length > 0 && ( + + 합계 + + {modalDetailRows.reduce((s, r) => s + (parseFloat(r.qty) || 0), 0).toLocaleString()} + + + + {modalDetailRows.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0).toLocaleString()}원 + + + + )} + +
+
+
+
+ + {/* 품목 선택 모달 */} + + e.preventDefault()}> + + 품목 선택 + 수주에 추가할 품목을 선택하세요. 품목이 없으면 "행 추가"로 직접 입력도 가능합니다. + +
+ setItemSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchItems()} + className="h-9 flex-1" /> + +
+
+ + + + + 0 && itemCheckedIds.size === itemSearchResults.length} + onChange={(e) => { + if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id))); + else setItemCheckedIds(new Set()); + }} /> + + 품목코드 + 품명 + 규격 + 단위 + + + + {itemSearchResults.length === 0 ? ( + 검색 결과가 없습니다 + ) : itemSearchResults.map((item) => ( + setItemCheckedIds((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); else next.add(item.id); + return next; + })}> + + {item.item_number} + {item.item_name} + {item.size} + {item.unit} + + ))} + +
+
+ +
+ {itemCheckedIds.size}개 선택됨 +
+ + +
+
+
+
+
+
+ + {/* 엑셀 업로드 — 단일 테이블 모드 + 커스텀 후처리 */} + + + {/* 테이블 설정 */} + + + {ConfirmDialogComponent} +
+ ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 1472dbce..fdda4fb3 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -92,8 +92,32 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/crawlingList": dynamic(() => import("@/app/(main)/admin/automaticMng/crawlingList/page"), { ssr: false, loading: LoadingFallback }), - // 회사별 커스텀 페이지는 레지스트리에서 제거 — 회사코드 prefix로 자동 import 처리 - // (design, sales, production, logistics, equipment, outsourcing, master-data) + // === COMPANY_7 (탑씰) === + "/COMPANY_7/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_7/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_7/master-data/department/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/sales/order": dynamic(() => import("@/app/(main)/COMPANY_7/sales/order/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/sales/customer": dynamic(() => import("@/app/(main)/COMPANY_7/sales/customer/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/sales/sales-item": dynamic(() => import("@/app/(main)/COMPANY_7/sales/sales-item/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_7/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_7/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_7/sales/claim/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_7/production/process-info/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_7/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_7/production/plan-management/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_7/equipment/info/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/outsourcing/subcontractor": dynamic(() => import("@/app/(main)/COMPANY_7/outsourcing/subcontractor/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/design/project": dynamic(() => import("@/app/(main)/COMPANY_7/design/project/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/design/change-management": dynamic(() => import("@/app/(main)/COMPANY_7/design/change-management/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/design/my-work": dynamic(() => import("@/app/(main)/COMPANY_7/design/my-work/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/design/design-request": dynamic(() => import("@/app/(main)/COMPANY_7/design/design-request/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_7/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_7/design/task-management/page"), { ssr: false, loading: LoadingFallback }), + // === COMPANY_9 (제일그라스) === + "/COMPANY_9/sales/order": dynamic(() => import("@/app/(main)/COMPANY_9/sales/order/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }), "/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }), @@ -145,6 +169,34 @@ const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { "/admin/automaticMng/batchmngList/create": () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"), "/admin/systemMng/dataflow/node-editorList": () => import("@/app/(main)/admin/systemMng/dataflow/page"), "/admin/standards/new": () => import("@/app/(main)/admin/standards/new/page"), + + // === 회사별 커스텀 페이지 (resolvedUrl로 매칭) === + // COMPANY_7 (탑씰) + "/COMPANY_7/master-data/item-info": () => import("@/app/(main)/COMPANY_7/master-data/item-info/page"), + "/COMPANY_7/master-data/department": () => import("@/app/(main)/COMPANY_7/master-data/department/page"), + "/COMPANY_7/sales/order": () => import("@/app/(main)/COMPANY_7/sales/order/page"), + "/COMPANY_7/sales/customer": () => import("@/app/(main)/COMPANY_7/sales/customer/page"), + "/COMPANY_7/sales/sales-item": () => import("@/app/(main)/COMPANY_7/sales/sales-item/page"), + "/COMPANY_7/sales/shipping-order": () => import("@/app/(main)/COMPANY_7/sales/shipping-order/page"), + "/COMPANY_7/sales/shipping-plan": () => import("@/app/(main)/COMPANY_7/sales/shipping-plan/page"), + "/COMPANY_7/sales/claim": () => import("@/app/(main)/COMPANY_7/sales/claim/page"), + "/COMPANY_7/production/process-info": () => import("@/app/(main)/COMPANY_7/production/process-info/page"), + "/COMPANY_7/production/work-instruction": () => import("@/app/(main)/COMPANY_7/production/work-instruction/page"), + "/COMPANY_7/production/plan-management": () => import("@/app/(main)/COMPANY_7/production/plan-management/page"), + "/COMPANY_7/equipment/info": () => import("@/app/(main)/COMPANY_7/equipment/info/page"), + "/COMPANY_7/logistics/material-status": () => import("@/app/(main)/COMPANY_7/logistics/material-status/page"), + "/COMPANY_7/logistics/outbound": () => import("@/app/(main)/COMPANY_7/logistics/outbound/page"), + "/COMPANY_7/logistics/receiving": () => import("@/app/(main)/COMPANY_7/logistics/receiving/page"), + "/COMPANY_7/logistics/packaging": () => import("@/app/(main)/COMPANY_7/logistics/packaging/page"), + "/COMPANY_7/outsourcing/subcontractor": () => import("@/app/(main)/COMPANY_7/outsourcing/subcontractor/page"), + "/COMPANY_7/outsourcing/subcontractor-item": () => import("@/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page"), + "/COMPANY_7/design/project": () => import("@/app/(main)/COMPANY_7/design/project/page"), + "/COMPANY_7/design/change-management": () => import("@/app/(main)/COMPANY_7/design/change-management/page"), + "/COMPANY_7/design/my-work": () => import("@/app/(main)/COMPANY_7/design/my-work/page"), + "/COMPANY_7/design/design-request": () => import("@/app/(main)/COMPANY_7/design/design-request/page"), + "/COMPANY_7/design/task-management": () => import("@/app/(main)/COMPANY_7/design/task-management/page"), + // COMPANY_9 (제일그라스) + "/COMPANY_9/sales/order": () => import("@/app/(main)/COMPANY_9/sales/order/page"), }; const DYNAMIC_ADMIN_PATTERNS: Array<{ @@ -329,13 +381,13 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) { return ; } - // URL 직접 입력: 레지스트리 매칭 (admin 페이지 등 공통 페이지) + // URL 직접 입력: 레지스트리 매칭 (resolvedUrl 우선, cleanUrl 폴백) const PageComponent = useMemo(() => { - return ADMIN_PAGE_REGISTRY[cleanUrl] || null; - }, [cleanUrl]); + return ADMIN_PAGE_REGISTRY[resolvedUrl] || ADMIN_PAGE_REGISTRY[cleanUrl] || null; + }, [resolvedUrl, cleanUrl]); if (PageComponent) { - console.log("[AdminPageRenderer] → 레지스트리 매칭:", cleanUrl); + console.log("[AdminPageRenderer] → 레지스트리 매칭:", resolvedUrl || cleanUrl); return ; }