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/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 fdf5be40..686dc471 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"; // 카테고리 값 관리 @@ -341,6 +342,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); // 권한 그룹 관리 @@ -431,6 +433,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/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/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/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)/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 98% rename from frontend/app/(main)/production/plan-management/page.tsx rename to frontend/app/(main)/COMPANY_7/production/plan-management/page.tsx index 51f7af14..a49555b6 100644 --- a/frontend/app/(main)/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)/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/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/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..fdda4fb3 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 = () => (
@@ -89,24 +90,35 @@ 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 }), - "/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 }), + // === 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 }), - // 영업 관리 (커스텀 페이지) - "/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 }), @@ -157,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<{ @@ -291,10 +331,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+)$/); @@ -317,13 +381,13 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) { return ; } - // URL 직접 입력: 레지스트리 매칭 + // 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 ; } @@ -339,6 +403,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 ; } 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 })} + /> + )} +
+
+
+ )} + {/* 탭 기능 토글 */}