diff --git a/.omc/project-memory.json b/.omc/project-memory.json index 9a424223..80e41159 100644 --- a/.omc/project-memory.json +++ b/.omc/project-memory.json @@ -1,7 +1,7 @@ { "version": "1.0.0", - "lastScanned": 1772609393905, - "projectRoot": "/Users/johngreen/Dev/vexplor", + "lastScanned": 1774313213052, + "projectRoot": "/Users/kimjuseok/ERP-node", "techStack": { "languages": [ { @@ -13,7 +13,13 @@ ] } ], - "frameworks": [], + "frameworks": [ + { + "name": "playwright", + "version": "1.58.2", + "category": "testing" + } + ], "packageManager": "npm", "runtime": null }, @@ -28,16 +34,14 @@ "namingStyle": null, "importStyle": null, "testPattern": null, - "fileOrganization": "type-based" + "fileOrganization": null }, "structure": { "isMonorepo": false, "workspaces": [], "mainDirectories": [ "docs", - "lib", - "scripts", - "src" + "scripts" ], "gitBranches": { "defaultBranch": "main", @@ -46,37 +50,39 @@ }, "customNotes": [], "directoryMap": { - "WebContent": { - "path": "WebContent", + "_local": { + "path": "_local", + "purpose": null, + "fileCount": 1, + "lastAccessed": 1774313213033, + "keyFiles": [ + "pipeline-progress.json" + ] + }, + "ai-assistant": { + "path": "ai-assistant", "purpose": null, "fileCount": 5, - "lastAccessed": 1772609393856, + "lastAccessed": 1774313213036, "keyFiles": [ - "init.jsp", - "init_jqGrid.jsp", - "init_no_login.jsp", - "init_toastGrid.jsp", - "viewImage.jsp" + "Dockerfile.win", + "README.md", + "package-lock.json", + "package.json" ] }, "backend": { "path": "backend", "purpose": null, - "fileCount": 6, - "lastAccessed": 1772609393857, - "keyFiles": [ - "Dockerfile", - "Dockerfile.mac", - "build.gradle", - "gradlew", - "gradlew.bat" - ] + "fileCount": 0, + "lastAccessed": 1774313213038, + "keyFiles": [] }, "backend-node": { "path": "backend-node", "purpose": null, - "fileCount": 14, - "lastAccessed": 1772609393872, + "fileCount": 17, + "lastAccessed": 1774313213039, "keyFiles": [ "API_연동_가이드.md", "API_키_정리.md", @@ -85,70 +91,88 @@ "README.md" ] }, + "backup": { + "path": "backup", + "purpose": null, + "fileCount": 6, + "lastAccessed": 1774313213040, + "keyFiles": [ + "Dockerfile", + "README.md", + "backup.py", + "docker-compose.backup.yml" + ] + }, "db": { "path": "db", "purpose": null, - "fileCount": 2, - "lastAccessed": 1772609393873, + "fileCount": 14, + "lastAccessed": 1774313213041, "keyFiles": [ "00-create-roles.sh", - "migrate_company13_export.sh" + "check_category_values.sql", + "check_numbering_rules.sql", + "cleanup_duplicate_screens_daejin.sql", + "company7_screen_backup.sql" ] }, "deploy": { "path": "deploy", "purpose": null, "fileCount": 0, - "lastAccessed": 1772609393873, + "lastAccessed": 1774313213041, "keyFiles": [] }, + "digitalTwin": { + "path": "digitalTwin", + "purpose": null, + "fileCount": 4, + "lastAccessed": 1774313213041, + "keyFiles": [ + "architecture-v4.md", + "fleet-management-plan.md", + "디지털트윈 아키텍쳐_v3.png", + "디지털트윈 아키텍쳐_v4.png" + ] + }, "docker": { "path": "docker", "purpose": null, "fileCount": 0, - "lastAccessed": 1772609393873, + "lastAccessed": 1774313213042, "keyFiles": [] }, "docs": { "path": "docs", "purpose": "Documentation", - "fileCount": 23, - "lastAccessed": 1772609393873, + "fileCount": 35, + "lastAccessed": 1774313213042, "keyFiles": [ "AI_화면생성_시스템_설계서.md", + "BOM_개발_현황.md", "DB_ARCHITECTURE_ANALYSIS.md", "DB_STRUCTURE_DIAGRAM.html", - "DB_WORKFLOW_ANALYSIS.md", - "KUBERNETES_DEPLOYMENT_GUIDE.md" + "DB_WORKFLOW_ANALYSIS.md" ] }, "frontend": { "path": "frontend", "purpose": null, - "fileCount": 14, - "lastAccessed": 1772609393873, + "fileCount": 17, + "lastAccessed": 1774313213043, "keyFiles": [ "MODAL_REPEATER_TABLE_DEBUG.md", "README.md", + "approval-box-result.png", "components.json", - "eslint.config.mjs", - "middleware.ts" - ] - }, - "hooks": { - "path": "hooks", - "purpose": null, - "fileCount": 1, - "lastAccessed": 1772609393879, - "keyFiles": [ - "useScreenStandards.ts" + "eslint.config.mjs" ] }, "k8s": { "path": "k8s", "purpose": null, "fileCount": 7, - "lastAccessed": 1772609393882, + "lastAccessed": 1774313213043, "keyFiles": [ "local-path-provisioner.yaml", "namespace.yaml", @@ -157,18 +181,11 @@ "vexplor-frontend-deployment.yaml" ] }, - "lib": { - "path": "lib", - "purpose": "Library code", - "fileCount": 0, - "lastAccessed": 1772609393883, - "keyFiles": [] - }, "mcp-agent-orchestrator": { "path": "mcp-agent-orchestrator", "purpose": null, "fileCount": 4, - "lastAccessed": 1772609393883, + "lastAccessed": 1774313213043, "keyFiles": [ "README.md", "package-lock.json", @@ -176,91 +193,68 @@ "tsconfig.json" ] }, - "popdocs": { - "path": "popdocs", + "mcp-task-queue": { + "path": "mcp-task-queue", "purpose": null, - "fileCount": 12, - "lastAccessed": 1772609393884, + "fileCount": 4, + "lastAccessed": 1774313213043, "keyFiles": [ - "ARCHITECTURE.md", - "CHANGELOG.md", - "FILES.md", - "INDEX.md", - "PLAN.md" + "package-lock.json", + "package.json", + "tsconfig.json" ] }, + "mcp-task-server": { + "path": "mcp-task-server", + "purpose": null, + "fileCount": 0, + "lastAccessed": 1774313213043, + "keyFiles": [] + }, "scripts": { "path": "scripts", "purpose": "Build/utility scripts", - "fileCount": 2, - "lastAccessed": 1772609393884, + "fileCount": 11, + "lastAccessed": 1774313213044, "keyFiles": [ "add-modal-ids.py", - "remove-logs.js" + "analyze-company-info-layout.js", + "browser-test-admin-switch-button.js", + "browser-test-customer-crud.js", + "browser-test-customer-via-menu.js" ] }, - "src": { - "path": "src", - "purpose": "Source code", - "fileCount": 0, - "lastAccessed": 1772609393884, - "keyFiles": [] + "test-output": { + "path": "test-output", + "purpose": null, + "fileCount": 2, + "lastAccessed": 1774313213044, + "keyFiles": [ + "screen-149-field-type-verification-guide.md", + "unified-field-type-config-panel-test-guide.md" + ] }, - "tomcat-conf": { - "path": "tomcat-conf", + "test-results": { + "path": "test-results", "purpose": null, "fileCount": 1, - "lastAccessed": 1772609393884, - "keyFiles": [ - "context.xml" - ] - }, - "backend/build": { - "path": "backend/build", - "purpose": "Build output", - "fileCount": 0, - "lastAccessed": 1772609393884, + "lastAccessed": 1774313213044, "keyFiles": [] }, - "backend/src": { - "path": "backend/src", + "ai-assistant/src": { + "path": "ai-assistant/src", "purpose": "Source code", - "fileCount": 0, - "lastAccessed": 1772609393884, - "keyFiles": [] - }, - "backend-node/data": { - "path": "backend-node/data", - "purpose": "Data files", - "fileCount": 0, - "lastAccessed": 1772609393884, - "keyFiles": [] - }, - "db/migrations": { - "path": "db/migrations", - "purpose": "Database migrations", - "fileCount": 16, - "lastAccessed": 1772609393884, - "keyFiles": [ - "046_MIGRATION_FIX.md", - "046_QUICK_FIX.md", - "README_1003.md" - ] - }, - "db/scripts": { - "path": "db/scripts", - "purpose": "Build/utility scripts", "fileCount": 1, - "lastAccessed": 1772609393884, + "lastAccessed": 1774313213045, "keyFiles": [ - "README_cleanup.md" + "app.js" ] }, "frontend/app": { "path": "frontend/app", "purpose": "Application code", "fileCount": 5, - "lastAccessed": 1772609393885, + "lastAccessed": 1774313213046, "keyFiles": [ "favicon.ico", "globals.css", @@ -271,7 +265,7 @@ "path": "frontend/components", "purpose": "UI components", "fileCount": 1, - "lastAccessed": 1772609393885, + "lastAccessed": 1774313213046, "keyFiles": [ "GlobalFileViewer.tsx" ] @@ -280,49 +274,174 @@ "path": "mcp-agent-orchestrator/src", "purpose": "Source code", "fileCount": 1, - "lastAccessed": 1772609393885, + "lastAccessed": 1774313213047, "keyFiles": [ "index.ts" ] }, - "src/controllers": { - "path": "src/controllers", - "purpose": "Controllers", - "fileCount": 1, - "lastAccessed": 1772609393885, - "keyFiles": [ - "dataflowDiagramController.ts" - ] - }, - "src/routes": { - "path": "src/routes", - "purpose": "Route handlers", - "fileCount": 1, - "lastAccessed": 1772609393885, - "keyFiles": [ - "dataflowDiagramRoutes.ts" - ] - }, - "src/services": { - "path": "src/services", - "purpose": "Business logic services", - "fileCount": 1, - "lastAccessed": 1772609393885, - "keyFiles": [ - "dataflowDiagramService.ts" - ] - }, - "src/utils": { - "path": "src/utils", - "purpose": "Utility functions", + "mcp-task-queue/data": { + "path": "mcp-task-queue/data", + "purpose": "Data files", "fileCount": 2, - "lastAccessed": 1772609393885, + "lastAccessed": 1774313213047, "keyFiles": [ - "databaseValidator.ts", - "queryBuilder.ts" + "knowledge.json", + "tasks.json" ] + }, + "mcp-task-queue/dist": { + "path": "mcp-task-queue/dist", + "purpose": "Distribution/build output", + "fileCount": 28, + "lastAccessed": 1774313213048, + "keyFiles": [ + "agent-runner.d.ts", + "agent-runner.d.ts.map", + "agent-runner.js" + ] + }, + "mcp-task-queue/node_modules": { + "path": "mcp-task-queue/node_modules", + "purpose": "Dependencies", + "fileCount": 1, + "lastAccessed": 1774313213049, + "keyFiles": [] + }, + "mcp-task-queue/src": { + "path": "mcp-task-queue/src", + "purpose": "Source code", + "fileCount": 7, + "lastAccessed": 1774313213049, + "keyFiles": [ + "agent-runner.ts", + "index.ts", + "knowledge-store.ts" + ] + }, + "mcp-task-server/data": { + "path": "mcp-task-server/data", + "purpose": "Data files", + "fileCount": 0, + "lastAccessed": 1774313213049, + "keyFiles": [] + }, + "mcp-task-server/dist": { + "path": "mcp-task-server/dist", + "purpose": "Distribution/build output", + "fileCount": 6, + "lastAccessed": 1774313213050, + "keyFiles": [ + "index.d.ts", + "index.js", + "taskStore.d.ts" + ] + }, + "mcp-task-server/node_modules": { + "path": "mcp-task-server/node_modules", + "purpose": "Dependencies", + "fileCount": 1, + "lastAccessed": 1774313213050, + "keyFiles": [] + }, + "mcp-task-server/src": { + "path": "mcp-task-server/src", + "purpose": "Source code", + "fileCount": 0, + "lastAccessed": 1774313213052, + "keyFiles": [] } }, - "hotPaths": [], + "hotPaths": [ + { + "path": "frontend/app/(main)/sales/order/page.tsx", + "accessCount": 19, + "lastAccessed": 1774408850812, + "type": "file" + }, + { + "path": "frontend/app/(main)/sales/shipping-plan/page.tsx", + "accessCount": 4, + "lastAccessed": 1774313720455, + "type": "file" + }, + { + "path": "frontend/components/common/DataGrid.tsx", + "accessCount": 4, + "lastAccessed": 1774408732451, + "type": "file" + }, + { + "path": "frontend/components/common/DynamicSearchFilter.tsx", + "accessCount": 3, + "lastAccessed": 1774408732309, + "type": "file" + }, + { + "path": "frontend/app/(main)/production/plan-management/page.tsx", + "accessCount": 2, + "lastAccessed": 1774313461313, + "type": "file" + }, + { + "path": "frontend/app/(main)", + "accessCount": 2, + "lastAccessed": 1774313529384, + "type": "directory" + }, + { + "path": "frontend/lib/api/shipping.ts", + "accessCount": 2, + "lastAccessed": 1774313725308, + "type": "file" + }, + { + "path": ".claude/plans/lively-wishing-yeti.md", + "accessCount": 2, + "lastAccessed": 1774313824670, + "type": "file" + }, + { + "path": "frontend/app/(main)/sales/shipping-order/page.tsx", + "accessCount": 1, + "lastAccessed": 1774313447495, + "type": "file" + }, + { + "path": "frontend/app/(main)/sales/claim/page.tsx", + "accessCount": 1, + "lastAccessed": 1774313450420, + "type": "file" + }, + { + "path": "frontend/app/(main)/production/process-info/page.tsx", + "accessCount": 1, + "lastAccessed": 1774313450623, + "type": "file" + }, + { + "path": "frontend/components/common/ExcelUploadModal.tsx", + "accessCount": 1, + "lastAccessed": 1774313454238, + "type": "file" + }, + { + "path": "frontend/app/(main)/master-data/item-info/page.tsx", + "accessCount": 1, + "lastAccessed": 1774313528166, + "type": "file" + }, + { + "path": "frontend/components/common/ShippingPlanModal.tsx", + "accessCount": 1, + "lastAccessed": 1774313925751, + "type": "file" + }, + { + "path": "frontend/components/common/TableSettingsModal.tsx", + "accessCount": 1, + "lastAccessed": 1774409034693, + "type": "file" + } + ], "userDirectives": [] } \ No newline at end of file diff --git a/.omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json b/.omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json new file mode 100644 index 00000000..5d45e30d --- /dev/null +++ b/.omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json @@ -0,0 +1,8 @@ +{ + "session_id": "d2bc3862-569e-4904-a3f9-6b20e3f14c43", + "ended_at": "2026-03-24T01:15:06.127Z", + "reason": "other", + "agents_spawned": 1, + "agents_completed": 1, + "modes_used": [] +} \ No newline at end of file diff --git a/.omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json b/.omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json new file mode 100644 index 00000000..123b9291 --- /dev/null +++ b/.omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json @@ -0,0 +1,8 @@ +{ + "session_id": "d6a10e69-4ebc-48f9-b451-c1d0587badc8", + "ended_at": "2026-03-24T01:15:07.644Z", + "reason": "other", + "agents_spawned": 0, + "agents_completed": 0, + "modes_used": [] +} \ No newline at end of file diff --git a/.omc/state/hud-state.json b/.omc/state/hud-state.json deleted file mode 100644 index 5fbc9b8f..00000000 --- a/.omc/state/hud-state.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "timestamp": "2026-03-04T07:29:57.315Z", - "backgroundTasks": [], - "sessionStartTimestamp": "2026-03-04T07:29:53.176Z", - "sessionId": "591d357c-df9d-4bbc-8dfa-1b98a9184e23" -} \ No newline at end of file diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json deleted file mode 100644 index d5a8e668..00000000 --- a/.omc/state/hud-stdin-cache.json +++ /dev/null @@ -1 +0,0 @@ -{"session_id":"591d357c-df9d-4bbc-8dfa-1b98a9184e23","transcript_path":"/Users/johngreen/.claude/projects/-Users-johngreen-Dev-vexplor/591d357c-df9d-4bbc-8dfa-1b98a9184e23.jsonl","cwd":"/Users/johngreen/Dev/vexplor","model":{"id":"claude-opus-4-6","display_name":"Opus 4.6"},"workspace":{"current_dir":"/Users/johngreen/Dev/vexplor","project_dir":"/Users/johngreen/Dev/vexplor","added_dirs":[]},"version":"2.1.66","output_style":{"name":"default"},"cost":{"total_cost_usd":0.516748,"total_duration_ms":65256,"total_api_duration_ms":28107,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":604,"total_output_tokens":838,"context_window_size":200000,"current_usage":{"input_tokens":1,"output_tokens":277,"cache_creation_input_tokens":1836,"cache_read_input_tokens":55498},"used_percentage":29,"remaining_percentage":71},"exceeds_200k_tokens":false} \ No newline at end of file diff --git a/.omc/state/idle-notif-cooldown.json b/.omc/state/idle-notif-cooldown.json index 84ff7ebe..176c69ac 100644 --- a/.omc/state/idle-notif-cooldown.json +++ b/.omc/state/idle-notif-cooldown.json @@ -1,3 +1,3 @@ { - "lastSentAt": "2026-03-04T07:30:30.883Z" + "lastSentAt": "2026-03-24T02:36:44.477Z" } \ No newline at end of file diff --git a/.omc/state/mission-state.json b/.omc/state/mission-state.json new file mode 100644 index 00000000..f23e7222 --- /dev/null +++ b/.omc/state/mission-state.json @@ -0,0 +1,53 @@ +{ + "updatedAt": "2026-03-24T00:51:37.962Z", + "missions": [ + { + "id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none", + "source": "session", + "name": "none", + "objective": "Session mission", + "createdAt": "2026-03-24T00:50:40.568Z", + "updatedAt": "2026-03-24T00:51:37.962Z", + "status": "done", + "workerCount": 1, + "taskCounts": { + "total": 1, + "pending": 0, + "blocked": 0, + "inProgress": 0, + "completed": 1, + "failed": 0 + }, + "agents": [ + { + "name": "Explore:a9237b1", + "role": "Explore", + "ownership": "a9237b1b6af985371", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-24T00:51:37.962Z" + } + ], + "timeline": [ + { + "id": "session-start:a9237b1b6af985371:2026-03-24T00:50:40.568Z", + "at": "2026-03-24T00:50:40.568Z", + "kind": "update", + "agent": "Explore:a9237b1", + "detail": "started Explore:a9237b1", + "sourceKey": "session-start:a9237b1b6af985371" + }, + { + "id": "session-stop:a9237b1b6af985371:2026-03-24T00:51:37.962Z", + "at": "2026-03-24T00:51:37.962Z", + "kind": "completion", + "agent": "Explore:a9237b1", + "detail": "completed", + "sourceKey": "session-stop:a9237b1b6af985371" + } + ] + } + ] +} \ No newline at end of file diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 53ed7216..37d337a4 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -151,6 +151,7 @@ import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN) import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황 import receivingRoutes from "./routes/receivingRoutes"; // 입고관리 +import outboundRoutes from "./routes/outboundRoutes"; // 출고관리 import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 @@ -367,6 +368,7 @@ app.use("/api/sales-report", salesReportRoutes); // 영업 리포트 app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형) app.use("/api/design", designRoutes); // 설계 모듈 app.use("/api/receiving", receivingRoutes); // 입고관리 +app.use("/api/outbound", outboundRoutes); // 출고관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index dc8cf064..d7aa247d 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -2504,7 +2504,9 @@ export const changeUserStatus = async ( // 필수 파라미터 검증 if (!userId || !status) { res.status(400).json({ + success: false, result: false, + message: "사용자 ID와 상태는 필수입니다.", msg: "사용자 ID와 상태는 필수입니다.", }); return; @@ -2513,7 +2515,9 @@ export const changeUserStatus = async ( // 상태 값 검증 if (!["active", "inactive"].includes(status)) { res.status(400).json({ + success: false, result: false, + message: "유효하지 않은 상태값입니다. (active, inactive만 허용)", msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)", }); return; @@ -2528,7 +2532,9 @@ export const changeUserStatus = async ( if (!currentUser) { res.status(404).json({ + success: false, result: false, + message: "사용자를 찾을 수 없습니다.", msg: "사용자를 찾을 수 없습니다.", }); return; @@ -2549,6 +2555,12 @@ export const changeUserStatus = async ( if (updateResult.length > 0) { // 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략 + // inactive로 변경 시 기존 JWT 토큰 무효화 + if (status === "inactive") { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateUserTokens(userId); + } + logger.info("사용자 상태 변경 성공", { userId, oldStatus: currentUser.status, @@ -2571,12 +2583,16 @@ export const changeUserStatus = async ( }); res.json({ + success: true, result: true, + message: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`, msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`, }); } else { res.status(400).json({ + success: false, result: false, + message: "사용자 상태 변경에 실패했습니다.", msg: "사용자 상태 변경에 실패했습니다.", }); } @@ -2587,7 +2603,9 @@ export const changeUserStatus = async ( status: req.body.status, }); res.status(500).json({ + success: false, result: false, + message: "시스템 오류가 발생했습니다.", msg: "시스템 오류가 발생했습니다.", }); } @@ -2627,12 +2645,214 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { } } + // 추가 유효성 검증 + + // 1. email 형식 검증 (값이 있는 경우만) + if (userData.email && userData.email.trim() !== "") { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(userData.email.trim())) { + res.status(400).json({ + success: false, + message: "이메일 형식이 올바르지 않습니다.", + error: { + code: "INVALID_EMAIL_FORMAT", + details: `Invalid email format: ${userData.email}`, + }, + }); + return; + } + } + + // 2. companyCode 존재 확인 (값이 있는 경우만) + if (userData.companyCode && userData.companyCode.trim() !== "") { + const companyExists = await queryOne<{ company_code: string }>( + `SELECT company_code FROM company_mng WHERE company_code = $1`, + [userData.companyCode.trim()] + ); + if (!companyExists) { + res.status(400).json({ + success: false, + message: `존재하지 않는 회사 코드입니다: ${userData.companyCode}`, + error: { + code: "INVALID_COMPANY_CODE", + details: `Company code not found: ${userData.companyCode}`, + }, + }); + return; + } + } + + // 3. userType 유효값 검증 (값이 있는 경우만) + if (userData.userType && userData.userType.trim() !== "") { + const validUserTypes = ["SUPER_ADMIN", "COMPANY_ADMIN", "USER", "GUEST", "PARTNER"]; + if (!validUserTypes.includes(userData.userType.trim())) { + res.status(400).json({ + success: false, + message: `유효하지 않은 사용자 유형입니다: ${userData.userType}. 허용값: ${validUserTypes.join(", ")}`, + error: { + code: "INVALID_USER_TYPE", + details: `Invalid userType: ${userData.userType}. Allowed: ${validUserTypes.join(", ")}`, + }, + }); + return; + } + } + + // 4. 비밀번호 최소 길이 검증 (신규 등록 시) + if (!isUpdate && userData.userPassword && userData.userPassword.length < 4) { + res.status(400).json({ + success: false, + message: "비밀번호는 최소 4자 이상이어야 합니다.", + error: { + code: "PASSWORD_TOO_SHORT", + details: "Password must be at least 4 characters long", + }, + }); + return; + } + // 비밀번호 암호화 (비밀번호가 제공된 경우에만) let encryptedPassword = null; if (userData.userPassword) { encryptedPassword = await EncryptUtil.encrypt(userData.userPassword); } + // PUT(수정) 요청 시 company_code / dept_code 변경 감지 + if (isUpdate) { + const existingUser = await queryOne<{ company_code: string; dept_code: string }>( + `SELECT company_code, dept_code FROM user_info WHERE user_id = $1`, + [userData.userId] + ); + + // company_code 변경 감지 → 이전 회사 권한 그룹 제거 + if ( + userData.companyCode && + existingUser && + existingUser.company_code && + existingUser.company_code !== userData.companyCode + ) { + const oldCompanyCode = existingUser.company_code; + logger.info("사용자 회사 코드 변경 감지 - 이전 회사 권한 그룹 제거", { + userId: userData.userId, + oldCompanyCode, + newCompanyCode: userData.companyCode, + }); + + // 이전 회사의 권한 그룹에서 해당 사용자 제거 + await query( + `DELETE FROM authority_sub_user + WHERE user_id = $1 + AND master_objid IN ( + SELECT objid FROM authority_master WHERE company_code = $2 + )`, + [userData.userId, oldCompanyCode] + ); + } + + // dept_code 변경 감지 → 결재 템플릿/진행중 결재라인 경고 로그 + const newDeptCode = userData.deptCode || null; + const oldDeptCode = existingUser?.dept_code || null; + if (existingUser && oldDeptCode && newDeptCode && oldDeptCode !== newDeptCode) { + logger.warn("사용자 부서 변경 감지 - 결재라인 영향 확인 시작", { + userId: userData.userId, + userName: userData.userName, + oldDeptCode, + newDeptCode, + }); + + try { + // 1) 결재선 템플릿 스텝에서 해당 사용자가 결재자로 등록된 건 조회 + const templateSteps = await query<{ + template_id: number; + step_order: number; + approver_label: string | null; + approver_dept_code: string | null; + }>( + `SELECT s.template_id, s.step_order, s.approver_label, s.approver_dept_code + FROM approval_line_template_steps s + WHERE s.approver_user_id = $1`, + [userData.userId] + ); + + if (templateSteps && templateSteps.length > 0) { + logger.warn( + `[결재라인 경고] 부서 변경된 사용자(${userData.userId})가 결재선 템플릿 ${templateSteps.length}건에 결재자로 등록되어 있습니다. 수동 확인이 필요합니다.`, + { + userId: userData.userId, + oldDeptCode, + newDeptCode, + affectedTemplates: templateSteps.map((s) => ({ + templateId: s.template_id, + stepOrder: s.step_order, + label: s.approver_label, + currentDeptInStep: s.approver_dept_code, + })), + } + ); + } + + // 2) 진행중인 결재 요청에서 해당 사용자가 대기중 결재자인 건 조회 + const pendingLines = await query<{ + request_id: number; + step_order: number; + approver_dept: string | null; + status: string; + }>( + `SELECT l.request_id, l.step_order, l.approver_dept, l.status + FROM approval_lines l + JOIN approval_requests r ON r.request_id = l.request_id + WHERE l.approver_id = $1 + AND l.status = 'pending' + AND r.status IN ('in_progress', 'pending')`, + [userData.userId] + ); + + if (pendingLines && pendingLines.length > 0) { + logger.warn( + `[결재라인 경고] 부서 변경된 사용자(${userData.userId})에게 대기중인 결재 ${pendingLines.length}건이 있습니다. 수동 확인이 필요합니다.`, + { + userId: userData.userId, + oldDeptCode, + newDeptCode, + pendingApprovals: pendingLines.map((l) => ({ + requestId: l.request_id, + stepOrder: l.step_order, + currentDeptInLine: l.approver_dept, + })), + } + ); + } + + // 감사 로그 기록 + auditLogService.log({ + companyCode: userData.companyCode || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DEPT_CHANGE_WARNING", + resourceType: "USER", + resourceId: userData.userId, + resourceName: userData.userName, + summary: `사용자 "${userData.userName}"의 부서 변경 (${oldDeptCode} → ${newDeptCode}). 결재 템플릿 ${templateSteps?.length || 0}건, 대기중 결재 ${pendingLines?.length || 0}건 영향 가능`, + changes: { + before: { deptCode: oldDeptCode }, + after: { + deptCode: newDeptCode, + affectedTemplateCount: templateSteps?.length || 0, + pendingApprovalCount: pendingLines?.length || 0, + }, + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + } catch (approvalCheckError) { + // 결재 테이블이 없는 환경에서도 사용자 저장은 계속 진행 + logger.warn("결재라인 영향 확인 중 오류 (사용자 저장은 계속 진행)", { + error: approvalCheckError instanceof Error ? approvalCheckError.message : approvalCheckError, + }); + } + } + } + // Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT) const updatePasswordClause = encryptedPassword ? "user_password = $4," : ""; @@ -2688,6 +2908,12 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { savedUser.regdate && new Date(savedUser.regdate).getTime() < Date.now() - 1000; + // 기존 사용자의 비밀번호 변경 시 JWT 토큰 무효화 + if (encryptedPassword && isExistingUser) { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateUserTokens(userData.userId); + } + logger.info( isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", { @@ -3534,6 +3760,10 @@ export const resetUserPassword = async ( if (updateResult.length > 0) { // 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략 + // 비밀번호 변경 후 기존 JWT 토큰 무효화 + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateUserTokens(userId); + logger.info("비밀번호 초기화 성공", { userId, updatedBy: req.user?.userId, @@ -4153,6 +4383,140 @@ export const saveUserWithDept = async ( * GET /api/admin/users/:userId/with-dept * 사원 + 부서 정보 조회 API (수정 모달용) */ +/** + * DELETE /api/admin/users/:userId + * 사용자 삭제 API (soft delete) + * status = 'deleted', end_date = now() 설정 + * authority_sub_user 멤버십 제거, JWT 토큰 무효화 + */ +export const deleteUser = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { userId } = req.params; + + // 1. userId 파라미터 검증 + if (!userId) { + res.status(400).json({ + success: false, + result: false, + message: "사용자 ID는 필수입니다.", + }); + return; + } + + // 2. 자기 자신 삭제 방지 + if (req.user?.userId === userId) { + res.status(400).json({ + success: false, + result: false, + message: "자기 자신은 삭제할 수 없습니다.", + }); + return; + } + + // 3. 사용자 존재 여부 확인 + const currentUser = await queryOne( + `SELECT user_id, user_name, status, company_code FROM user_info WHERE user_id = $1`, + [userId] + ); + + if (!currentUser) { + res.status(404).json({ + success: false, + result: false, + message: "사용자를 찾을 수 없습니다.", + }); + return; + } + + // 이미 삭제된 사용자 체크 + if (currentUser.status === "deleted") { + res.status(400).json({ + success: false, + result: false, + message: "이미 삭제된 사용자입니다.", + }); + return; + } + + // 4. soft delete: status = 'deleted', end_date = now() + const updateResult = await query( + `UPDATE user_info + SET status = 'deleted', end_date = NOW() + WHERE user_id = $1 + RETURNING *`, + [userId] + ); + + if (updateResult.length === 0) { + res.status(500).json({ + success: false, + result: false, + message: "사용자 삭제에 실패했습니다.", + }); + return; + } + + // 5. authority_sub_user에서 해당 사용자 멤버십 제거 + await query( + `DELETE FROM authority_sub_user WHERE user_id = $1`, + [userId] + ); + + // 6. JWT 토큰 무효화 + try { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateUserTokens(userId); + } catch (tokenError) { + logger.warn("토큰 무효화 중 오류 (삭제는 정상 처리됨)", { userId, error: tokenError }); + } + + logger.info("사용자 삭제(soft delete) 성공", { + userId, + userName: currentUser.user_name, + deletedBy: req.user?.userId, + }); + + // 7. 감사 로그 기록 + auditLogService.log({ + companyCode: currentUser.company_code || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DELETE", + resourceType: "USER", + resourceId: userId, + resourceName: currentUser.user_name, + summary: `사용자 "${currentUser.user_name}" (${userId}) 삭제 처리`, + changes: { + before: { status: currentUser.status }, + after: { status: "deleted" }, + fields: ["status", "end_date"], + }, + ipAddress: getClientIp(req), + requestPath: req.originalUrl, + }); + + // 8. 응답 + res.json({ + success: true, + result: true, + message: `사용자 "${currentUser.user_name}" (${userId})이(가) 삭제되었습니다.`, + }); + } catch (error: any) { + logger.error("사용자 삭제 중 오류 발생", { + error: error.message, + userId: req.params.userId, + }); + res.status(500).json({ + success: false, + result: false, + message: "시스템 오류가 발생했습니다.", + }); + } +}; + export const getUserWithDept = async ( req: AuthenticatedRequest, res: Response diff --git a/backend-node/src/controllers/entityJoinController.ts b/backend-node/src/controllers/entityJoinController.ts index de9ee95f..766c6a02 100644 --- a/backend-node/src/controllers/entityJoinController.ts +++ b/backend-node/src/controllers/entityJoinController.ts @@ -561,6 +561,34 @@ export class EntityJoinController { }); } } + /** + * 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용) + * GET /api/table-management/tables/:tableName/column-values/:columnName + */ + async getColumnUniqueValues(req: Request, res: Response): Promise { + try { + const { tableName, columnName } = req.params; + const companyCode = (req as any).user?.companyCode; + + const data = await tableManagementService.getColumnDistinctValues( + tableName, + columnName, + companyCode + ); + + res.status(200).json({ + success: true, + data, + }); + } catch (error) { + logger.error(`컬럼 고유값 조회 실패: ${req.params.tableName}.${req.params.columnName}`, error); + res.status(500).json({ + success: false, + message: "컬럼 고유값 조회 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } } export const entityJoinController = new EntityJoinController(); diff --git a/backend-node/src/controllers/multilangController.ts b/backend-node/src/controllers/multilangController.ts index f14fc3b5..62451708 100644 --- a/backend-node/src/controllers/multilangController.ts +++ b/backend-node/src/controllers/multilangController.ts @@ -191,18 +191,30 @@ export const getLangKeys = async ( ): Promise => { try { const { companyCode, menuCode, keyType, searchText, categoryId } = req.query; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 키 목록 조회 요청", { query: req.query, user: req.user, }); + // company_code 필터링: 비관리자는 자기 회사 + 공통(*) 키만 조회 가능 + let effectiveCompanyCode = companyCode as string; + if (userCompanyCode !== "*") { + // 비관리자가 다른 회사의 데이터를 요청하면 자기 회사로 제한 + if (companyCode && companyCode !== userCompanyCode && companyCode !== "*") { + effectiveCompanyCode = userCompanyCode || ""; + } + } + const multiLangService = new MultiLangService(); const langKeys = await multiLangService.getLangKeys({ - companyCode: companyCode as string, + companyCode: effectiveCompanyCode, menuCode: menuCode as string, keyType: keyType as string, searchText: searchText as string, categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined, + // 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환 + userCompanyCode: userCompanyCode, }); const response: ApiResponse = { @@ -235,9 +247,24 @@ export const getLangTexts = async ( ): Promise => { try { const { keyId } = req.params; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user }); const multiLangService = new MultiLangService(); + + // 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증 + if (userCompanyCode !== "*") { + const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId)); + if (keyOwner && keyOwner !== "*" && keyOwner !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 다국어 키에 접근할 권한이 없습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + } + const langTexts = await multiLangService.getLangTexts(parseInt(keyId)); const response: ApiResponse = { @@ -270,6 +297,7 @@ export const createLangKey = async ( ): Promise => { try { const keyData: CreateLangKeyRequest = req.body; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 키 생성 요청", { keyData, user: req.user }); // 필수 입력값 검증 @@ -285,6 +313,26 @@ export const createLangKey = async ( return; } + // 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능 + if (keyData.companyCode === "*" && userCompanyCode !== "*") { + res.status(403).json({ + success: false, + message: "공통 키는 최고 관리자만 생성할 수 있습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + + // 비관리자: 자기 회사 키만 생성 가능 + if (userCompanyCode !== "*" && keyData.companyCode !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 키를 생성할 권한이 없습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + const multiLangService = new MultiLangService(); const keyId = await multiLangService.createLangKey({ ...keyData, @@ -323,10 +371,33 @@ export const updateLangKey = async ( try { const { keyId } = req.params; const keyData: UpdateLangKeyRequest = req.body; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user }); const multiLangService = new MultiLangService(); + + // 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키는 수정 불가) + if (userCompanyCode !== "*") { + const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId)); + if (!keyOwner) { + res.status(404).json({ + success: false, + message: "다국어 키를 찾을 수 없습니다.", + error: { code: "KEY_NOT_FOUND" }, + }); + return; + } + if (keyOwner !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 다국어 키를 수정할 권한이 없습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + } + await multiLangService.updateLangKey(parseInt(keyId), { ...keyData, updatedBy: req.user?.userId || "system", @@ -362,9 +433,32 @@ export const deleteLangKey = async ( ): Promise => { try { const { keyId } = req.params; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 키 삭제 요청", { keyId, user: req.user }); const multiLangService = new MultiLangService(); + + // 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키 삭제 불가) + if (userCompanyCode !== "*") { + const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId)); + if (!keyOwner) { + res.status(404).json({ + success: false, + message: "다국어 키를 찾을 수 없습니다.", + error: { code: "KEY_NOT_FOUND" }, + }); + return; + } + if (keyOwner !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 다국어 키를 삭제할 권한이 없습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + } + await multiLangService.deleteLangKey(parseInt(keyId)); const response: ApiResponse = { @@ -397,9 +491,32 @@ export const toggleLangKey = async ( ): Promise => { try { const { keyId } = req.params; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user }); const multiLangService = new MultiLangService(); + + // 비관리자: 해당 키가 자기 회사 키인지 검증 + if (userCompanyCode !== "*") { + const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId)); + if (!keyOwner) { + res.status(404).json({ + success: false, + message: "다국어 키를 찾을 수 없습니다.", + error: { code: "KEY_NOT_FOUND" }, + }); + return; + } + if (keyOwner !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 다국어 키를 변경할 권한이 없습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + } + const result = await multiLangService.toggleLangKey(parseInt(keyId)); const response: ApiResponse = { @@ -433,6 +550,7 @@ export const saveLangTexts = async ( try { const { keyId } = req.params; const textData: SaveLangTextsRequest = req.body; + const userCompanyCode = req.user?.companyCode; logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user }); @@ -454,6 +572,28 @@ export const saveLangTexts = async ( } const multiLangService = new MultiLangService(); + + // 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증 + if (userCompanyCode !== "*") { + const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId)); + if (!keyOwner) { + res.status(404).json({ + success: false, + message: "다국어 키를 찾을 수 없습니다.", + error: { code: "KEY_NOT_FOUND" }, + }); + return; + } + if (keyOwner !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "다른 회사의 다국어 텍스트를 수정할 권한이 없습니다.", + error: { code: "PERMISSION_DENIED" }, + }); + return; + } + } + await multiLangService.saveLangTexts(parseInt(keyId), { texts: textData.texts.map((text) => ({ ...text, diff --git a/backend-node/src/controllers/outboundController.ts b/backend-node/src/controllers/outboundController.ts new file mode 100644 index 00000000..08506f66 --- /dev/null +++ b/backend-node/src/controllers/outboundController.ts @@ -0,0 +1,509 @@ +/** + * 출고관리 컨트롤러 + * + * 출고유형별 소스 테이블: + * - 판매출고 → shipment_instruction + shipment_instruction_detail (출하지시) + * - 반품출고 → purchase_order_mng (발주/입고) + * - 기타출고 → item_info (품목) + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// 출고 목록 조회 +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { + outbound_type, + outbound_status, + search_keyword, + date_from, + date_to, + } = req.query; + + const conditions: string[] = []; + const params: any[] = []; + let paramIdx = 1; + + if (companyCode === "*") { + // 최고 관리자: 전체 조회 + } else { + conditions.push(`om.company_code = $${paramIdx}`); + params.push(companyCode); + paramIdx++; + } + + if (outbound_type && outbound_type !== "all") { + conditions.push(`om.outbound_type = $${paramIdx}`); + params.push(outbound_type); + paramIdx++; + } + + if (outbound_status && outbound_status !== "all") { + conditions.push(`om.outbound_status = $${paramIdx}`); + params.push(outbound_status); + paramIdx++; + } + + if (search_keyword) { + conditions.push( + `(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})` + ); + params.push(`%${search_keyword}%`); + paramIdx++; + } + + if (date_from) { + conditions.push(`om.outbound_date >= $${paramIdx}`); + params.push(date_from); + paramIdx++; + } + if (date_to) { + conditions.push(`om.outbound_date <= $${paramIdx}`); + params.push(date_to); + paramIdx++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT + om.*, + wh.warehouse_name + FROM outbound_mng om + LEFT JOIN warehouse_info wh + ON om.warehouse_code = wh.warehouse_code + AND om.company_code = wh.company_code + ${whereClause} + ORDER BY om.created_date DESC + `; + + const pool = getPool(); + const result = await pool.query(query, params); + + logger.info("출고 목록 조회", { + companyCode, + rowCount: result.rowCount, + }); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("출고 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 출고 등록 (다건) +export async function create(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { items, outbound_number, outbound_date, warehouse_code, location_code, manager_id, memo } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + return res.status(400).json({ success: false, message: "출고 품목이 없습니다." }); + } + + await client.query("BEGIN"); + + const insertedRows: any[] = []; + + for (const item of items) { + const result = await client.query( + `INSERT INTO outbound_mng ( + company_code, outbound_number, outbound_type, outbound_date, + reference_number, customer_code, customer_name, + item_code, item_name, specification, material, unit, + outbound_qty, unit_price, total_amount, + lot_number, warehouse_code, location_code, + outbound_status, manager_id, memo, + source_type, sales_order_id, shipment_plan_id, item_info_id, + destination_code, delivery_destination, delivery_address, + created_date, created_by, writer, status + ) VALUES ( + $1, $2, $3, $4, + $5, $6, $7, + $8, $9, $10, $11, $12, + $13, $14, $15, + $16, $17, $18, + $19, $20, $21, + $22, $23, $24, $25, + $26, $27, $28, + NOW(), $29, $29, '출고' + ) RETURNING *`, + [ + companyCode, + outbound_number || item.outbound_number, + item.outbound_type, + outbound_date || item.outbound_date, + item.reference_number || null, + item.customer_code || null, + item.customer_name || null, + item.item_code || item.item_number || null, + item.item_name || null, + item.spec || item.specification || null, + item.material || null, + item.unit || "EA", + item.outbound_qty || 0, + item.unit_price || 0, + item.total_amount || 0, + item.lot_number || null, + warehouse_code || item.warehouse_code || null, + location_code || item.location_code || null, + item.outbound_status || "대기", + manager_id || item.manager_id || null, + memo || item.memo || null, + item.source_type || null, + item.sales_order_id || null, + item.shipment_plan_id || null, + item.item_info_id || null, + item.destination_code || null, + item.delivery_destination || null, + item.delivery_address || null, + userId, + ] + ); + + insertedRows.push(result.rows[0]); + + // 재고 업데이트 (inventory_stock): 출고 수량 차감 + const itemCode = item.item_code || item.item_number || null; + const whCode = warehouse_code || item.warehouse_code || null; + const locCode = location_code || item.location_code || null; + const outQty = Number(item.outbound_qty) || 0; + if (itemCode && outQty > 0) { + const existingStock = await client.query( + `SELECT id FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code, '') = COALESCE($3, '') + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, itemCode, whCode || '', locCode || ''] + ); + + if (existingStock.rows.length > 0) { + await client.query( + `UPDATE inventory_stock + SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text), + last_out_date = NOW(), + updated_date = NOW() + WHERE id = $2`, + [outQty, existingStock.rows[0].id] + ); + } else { + // 재고 레코드가 없으면 0으로 생성 (마이너스 방지) + await client.query( + `INSERT INTO inventory_stock ( + company_code, item_code, warehouse_code, location_code, + current_qty, safety_qty, last_out_date, + created_date, updated_date, writer + ) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`, + [companyCode, itemCode, whCode, locCode, userId] + ); + } + } + + // 판매출고인 경우 출하지시의 ship_qty 업데이트 + if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") { + await client.query( + `UPDATE shipment_instruction_detail + SET ship_qty = COALESCE(ship_qty, 0) + $1, + updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [item.outbound_qty || 0, item.source_id, companyCode] + ); + } + } + + await client.query("COMMIT"); + + logger.info("출고 등록 완료", { + companyCode, + userId, + count: insertedRows.length, + outbound_number, + }); + + return res.json({ + success: true, + data: insertedRows, + message: `${insertedRows.length}건 출고 등록 완료`, + }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("출고 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +// 출고 수정 +export async function update(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + const { + outbound_date, outbound_qty, unit_price, total_amount, + lot_number, warehouse_code, location_code, + outbound_status, manager_id: mgr, memo, + } = req.body; + + const pool = getPool(); + const result = await pool.query( + `UPDATE outbound_mng SET + outbound_date = COALESCE($1, outbound_date), + outbound_qty = COALESCE($2, outbound_qty), + unit_price = COALESCE($3, unit_price), + total_amount = COALESCE($4, total_amount), + lot_number = COALESCE($5, lot_number), + warehouse_code = COALESCE($6, warehouse_code), + location_code = COALESCE($7, location_code), + outbound_status = COALESCE($8, outbound_status), + manager_id = COALESCE($9, manager_id), + memo = COALESCE($10, memo), + updated_date = NOW(), + updated_by = $11 + WHERE id = $12 AND company_code = $13 + RETURNING *`, + [ + outbound_date, outbound_qty, unit_price, total_amount, + lot_number, warehouse_code, location_code, + outbound_status, mgr, memo, + userId, id, companyCode, + ] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "출고 데이터를 찾을 수 없습니다." }); + } + + logger.info("출고 수정", { companyCode, userId, id }); + + return res.json({ success: true, data: result.rows[0] }); + } catch (error: any) { + logger.error("출고 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 출고 삭제 +export async function deleteOutbound(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + const pool = getPool(); + + const result = await pool.query( + `DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`, + [id, companyCode] + ); + + if (result.rowCount === 0) { + return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." }); + } + + logger.info("출고 삭제", { companyCode, id }); + + return res.json({ success: true, message: "삭제 완료" }); + } catch (error: any) { + logger.error("출고 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 판매출고용: 출하지시 데이터 조회 +export async function getShipmentInstructions(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + + const conditions: string[] = ["si.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (keyword) { + conditions.push( + `(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})` + ); + params.push(`%${keyword}%`); + paramIdx++; + } + + const pool = getPool(); + const result = await pool.query( + `SELECT + sid.id AS detail_id, + si.id AS instruction_id, + si.instruction_no, + si.instruction_date, + si.partner_id, + si.status AS instruction_status, + sid.item_code, + sid.item_name, + sid.spec, + sid.material, + COALESCE(sid.plan_qty, 0) AS plan_qty, + COALESCE(sid.ship_qty, 0) AS ship_qty, + COALESCE(sid.order_qty, 0) AS order_qty, + GREATEST(COALESCE(sid.plan_qty, 0) - COALESCE(sid.ship_qty, 0), 0) AS remain_qty, + sid.source_type + FROM shipment_instruction si + JOIN shipment_instruction_detail sid + ON si.id = sid.instruction_id + AND si.company_code = sid.company_code + WHERE ${conditions.join(" AND ")} + AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0) + ORDER BY si.instruction_date DESC, si.instruction_no`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("출하지시 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 반품출고용: 발주(입고) 데이터 조회 +export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + // 입고된 것만 (반품 대상) + conditions.push( + `COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0` + ); + + if (keyword) { + conditions.push( + `(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})` + ); + params.push(`%${keyword}%`); + paramIdx++; + } + + const pool = getPool(); + const result = await pool.query( + `SELECT + id, purchase_no, order_date, supplier_code, supplier_name, + item_code, item_name, spec, material, + COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty, + COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty, + COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price, + status, due_date + FROM purchase_order_mng + WHERE ${conditions.join(" AND ")} + ORDER BY order_date DESC, purchase_no`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("발주 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 기타출고용: 품목 데이터 조회 +export async function getItems(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (keyword) { + conditions.push( + `(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})` + ); + params.push(`%${keyword}%`); + paramIdx++; + } + + const pool = getPool(); + const result = await pool.query( + `SELECT + id, item_number, item_name, size AS spec, material, unit, + COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price + FROM item_info + WHERE ${conditions.join(" AND ")} + ORDER BY item_name`, + params + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("품목 데이터 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 출고번호 자동생성 +export async function generateNumber(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + const today = new Date(); + const yyyy = today.getFullYear(); + const prefix = `OUT-${yyyy}-`; + + const result = await pool.query( + `SELECT outbound_number FROM outbound_mng + WHERE company_code = $1 AND outbound_number LIKE $2 + ORDER BY outbound_number DESC LIMIT 1`, + [companyCode, `${prefix}%`] + ); + + let seq = 1; + if (result.rows.length > 0) { + const lastNo = result.rows[0].outbound_number; + const lastSeq = parseInt(lastNo.replace(prefix, ""), 10); + if (!isNaN(lastSeq)) seq = lastSeq + 1; + } + + const newNumber = `${prefix}${String(seq).padStart(4, "0")}`; + + return res.json({ success: true, data: newNumber }); + } catch (error: any) { + logger.error("출고번호 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// 창고 목록 조회 +export async function getWarehouses(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const pool = getPool(); + + const result = await pool.query( + `SELECT warehouse_code, warehouse_name, warehouse_type + FROM warehouse_info + WHERE company_code = $1 AND status != '삭제' + ORDER BY warehouse_name`, + [companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("창고 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/packagingController.ts b/backend-node/src/controllers/packagingController.ts index c804963f..4974c80f 100644 --- a/backend-node/src/controllers/packagingController.ts +++ b/backend-node/src/controllers/packagingController.ts @@ -173,7 +173,11 @@ export async function getPkgUnitItems( const pool = getPool(); const result = await pool.query( - `SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`, + `SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit + FROM pkg_unit_item pui + LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code + WHERE pui.pkg_code=$1 AND pui.company_code=$2 + ORDER BY pui.created_date DESC`, [pkgCode, companyCode] ); @@ -410,7 +414,11 @@ export async function getLoadingUnitPkgs( const pool = getPool(); const result = await pool.query( - `SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`, + `SELECT lup.*, pu.pkg_name, pu.pkg_type + FROM loading_unit_pkg lup + LEFT JOIN pkg_unit pu ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code + WHERE lup.loading_code=$1 AND lup.company_code=$2 + ORDER BY lup.created_date DESC`, [loadingCode, companyCode] ); @@ -476,3 +484,112 @@ export async function deleteLoadingUnitPkg( res.status(500).json({ success: false, message: error.message }); } } + +// ────────────────────────────────────────────── +// 품목정보 연동 (division별 item_info 조회) +// ────────────────────────────────────────────── + +export async function getItemsByDivision( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { divisionLabel } = req.params; + const { keyword } = req.query; + const pool = getPool(); + + // division 카테고리에서 해당 라벨의 코드 찾기 + const catResult = await pool.query( + `SELECT value_code FROM category_values + WHERE table_name = 'item_info' AND column_name = 'division' + AND value_label = $1 AND company_code = $2 + LIMIT 1`, + [divisionLabel, companyCode] + ); + + if (catResult.rows.length === 0) { + res.json({ success: true, data: [] }); + return; + } + + const divisionCode = catResult.rows[0].value_code; + + const conditions: string[] = ["company_code = $1", `$2 = ANY(string_to_array(division, ','))`]; + const params: any[] = [companyCode, divisionCode]; + let paramIdx = 3; + + if (keyword) { + conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`); + params.push(`%${keyword}%`); + paramIdx++; + } + + const result = await pool.query( + `SELECT id, item_number, item_name, size, material, unit, division + FROM item_info + WHERE ${conditions.join(" AND ")} + ORDER BY item_name`, + params + ); + + logger.info(`품목 조회 (division=${divisionLabel})`, { companyCode, count: result.rowCount }); + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("품목 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +// 일반 품목 조회 (포장재/적재함 제외, 매칭용) +export async function getGeneralItems( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const companyCode = req.user!.companyCode; + const { keyword } = req.query; + const pool = getPool(); + + // 포장재/적재함 division 코드 조회 + const catResult = await pool.query( + `SELECT value_code FROM category_values + WHERE table_name = 'item_info' AND column_name = 'division' + AND value_label IN ('포장재', '적재함') AND company_code = $1`, + [companyCode] + ); + const excludeCodes = catResult.rows.map((r: any) => r.value_code); + + const conditions: string[] = ["company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (excludeCodes.length > 0) { + // 다중 값(콤마 구분) 지원: 포장재/적재함 코드가 포함된 품목 제외 + const excludeConditions = excludeCodes.map((_: any, i: number) => `$${paramIdx + i} = ANY(string_to_array(division, ','))`); + conditions.push(`(division IS NULL OR division = '' OR NOT (${excludeConditions.join(" OR ")}))`); + params.push(...excludeCodes); + paramIdx += excludeCodes.length; + } + + if (keyword) { + conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`); + params.push(`%${keyword}%`); + paramIdx++; + } + + const result = await pool.query( + `SELECT id, item_number, item_name, size AS spec, material, unit, division + FROM item_info + WHERE ${conditions.join(" AND ")} + ORDER BY item_name + LIMIT 200`, + params + ); + + res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("일반 품목 조회 실패", { error: error.message }); + 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 a8d99fb1..3b64928b 100644 --- a/backend-node/src/controllers/processInfoController.ts +++ b/backend-node/src/controllers/processInfoController.ts @@ -148,9 +148,9 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo const { processCode } = req.params; const result = await pool.query( - `SELECT pe.*, ei.equipment_name + `SELECT pe.*, em.equipment_name FROM process_equipment pe - LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code + LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code WHERE pe.process_code = $1 AND pe.company_code = $2 ORDER BY pe.equipment_code`, [processCode, companyCode] @@ -214,7 +214,7 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response) const params = companyCode === "*" ? [] : [companyCode]; const result = await pool.query( - `SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`, + `SELECT objid AS id, equipment_code, equipment_name FROM equipment_mng ${condition} ORDER BY equipment_code`, params ); diff --git a/backend-node/src/controllers/productionController.ts b/backend-node/src/controllers/productionController.ts index 582188d6..73aeb53f 100644 --- a/backend-node/src/controllers/productionController.ts +++ b/backend-node/src/controllers/productionController.ts @@ -40,6 +40,28 @@ export async function getStockShortage(req: AuthenticatedRequest, res: Response) } } +// ─── 생산계획 목록 조회 ─── + +export async function getPlans(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { productType, status, startDate, endDate, itemCode } = req.query; + + const data = await productionService.getPlans(companyCode, { + productType: productType as string, + status: status as string, + startDate: startDate as string, + endDate: endDate as string, + itemCode: itemCode as string, + }); + + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("생산계획 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ─── 생산계획 상세 조회 ─── export async function getPlanById(req: AuthenticatedRequest, res: Response) { diff --git a/backend-node/src/controllers/receivingController.ts b/backend-node/src/controllers/receivingController.ts index 132fcb3a..f0b6358b 100644 --- a/backend-node/src/controllers/receivingController.ts +++ b/backend-node/src/controllers/receivingController.ts @@ -170,6 +170,42 @@ export async function create(req: AuthenticatedRequest, res: Response) { insertedRows.push(result.rows[0]); + // 재고 업데이트 (inventory_stock): 입고 수량 증가 + const itemCode = item.item_number || null; + const whCode = warehouse_code || item.warehouse_code || null; + const locCode = location_code || item.location_code || null; + const inQty = Number(item.inbound_qty) || 0; + if (itemCode && inQty > 0) { + const existingStock = await client.query( + `SELECT id FROM inventory_stock + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(warehouse_code, '') = COALESCE($3, '') + AND COALESCE(location_code, '') = COALESCE($4, '') + LIMIT 1`, + [companyCode, itemCode, whCode || '', locCode || ''] + ); + + if (existingStock.rows.length > 0) { + await client.query( + `UPDATE inventory_stock + SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text), + last_in_date = NOW(), + updated_date = NOW() + WHERE id = $2`, + [inQty, existingStock.rows[0].id] + ); + } else { + await client.query( + `INSERT INTO inventory_stock ( + company_code, item_code, warehouse_code, location_code, + current_qty, safety_qty, last_in_date, + created_date, updated_date, writer + ) VALUES ($1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`, + [companyCode, itemCode, whCode, locCode, String(inQty), userId] + ); + } + } + // 구매입고인 경우 발주의 received_qty 업데이트 if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") { await client.query( diff --git a/backend-node/src/controllers/roleController.ts b/backend-node/src/controllers/roleController.ts index 06f72f31..29bc4b0e 100644 --- a/backend-node/src/controllers/roleController.ts +++ b/backend-node/src/controllers/roleController.ts @@ -472,6 +472,10 @@ export const addRoleMembers = async ( req.user?.userId || "SYSTEM" ); + // 권한 변경된 사용자들의 JWT 토큰 무효화 + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateMultipleUserTokens(userIds); + const response: ApiResponse = { success: true, message: "권한 그룹 멤버 추가 성공", @@ -568,6 +572,13 @@ export const updateRoleMembers = async ( ); } + // 권한 변경된 사용자들의 JWT 토큰 무효화 + const allAffectedUsers = [...new Set([...toAdd, ...toRemove])]; + if (allAffectedUsers.length > 0) { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateMultipleUserTokens(allAffectedUsers); + } + logger.info("권한 그룹 멤버 일괄 업데이트 성공", { masterObjid, added: toAdd.length, @@ -646,6 +657,10 @@ export const removeRoleMembers = async ( req.user?.userId || "SYSTEM" ); + // 권한 변경된 사용자들의 JWT 토큰 무효화 + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateMultipleUserTokens(userIds); + const response: ApiResponse = { success: true, message: "권한 그룹 멤버 제거 성공", @@ -777,6 +792,18 @@ export const setMenuPermissions = async ( req.user?.userId || "SYSTEM" ); + // 해당 권한 그룹의 모든 멤버 JWT 토큰 무효화 + try { + const members = await RoleService.getRoleMembers(authObjid); + const memberIds = members.map((m: any) => m.userId); + if (memberIds.length > 0) { + const { TokenInvalidationService } = require("../services/tokenInvalidationService"); + await TokenInvalidationService.invalidateMultipleUserTokens(memberIds); + } + } catch (invalidateError) { + logger.warn("메뉴 권한 변경 후 토큰 무효화 실패 (권한 설정은 성공)", { invalidateError }); + } + const response: ApiResponse = { success: true, message: "메뉴 권한 설정 성공", diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index 938988b5..8dfe28b3 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -5,6 +5,7 @@ import { Request, Response, NextFunction } from "express"; import { JwtUtils } from "../utils/jwtUtils"; import { AuthenticatedRequest, PersonBean } from "../types/auth"; import { logger } from "../utils/logger"; +import { TokenInvalidationService } from "../services/tokenInvalidationService"; // AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export export { AuthenticatedRequest } from "../types/auth"; @@ -22,11 +23,11 @@ declare global { * JWT 토큰 검증 미들웨어 * 기존 세션 방식과 동일한 효과를 제공 */ -export const authenticateToken = ( +export const authenticateToken = async ( req: AuthenticatedRequest, res: Response, next: NextFunction -): void => { +): Promise => { try { // Authorization 헤더에서 토큰 추출 const authHeader = req.get("Authorization"); @@ -46,6 +47,25 @@ export const authenticateToken = ( // JWT 토큰 검증 및 사용자 정보 추출 const userInfo: PersonBean = JwtUtils.verifyToken(token); + // token_version 검증 (JWT payload vs DB) + const decoded = JwtUtils.decodeToken(token); + const tokenVersion = decoded?.tokenVersion; + + // tokenVersion이 undefined면 구버전 토큰이므로 통과 (하위 호환) + if (tokenVersion !== undefined) { + const dbVersion = await TokenInvalidationService.getUserTokenVersion(userInfo.userId); + if (tokenVersion !== dbVersion) { + res.status(401).json({ + success: false, + error: { + code: "TOKEN_INVALIDATED", + details: "보안 정책에 의해 재로그인이 필요합니다.", + }, + }); + return; + } + } + // 요청 객체에 사용자 정보 설정 (기존 PersonBean과 동일) req.user = userInfo; @@ -173,11 +193,11 @@ export const requireUserOrAdmin = (targetUserId: string) => { * 토큰 갱신 미들웨어 * 토큰이 곧 만료될 경우 자동으로 갱신 */ -export const refreshTokenIfNeeded = ( +export const refreshTokenIfNeeded = async ( req: AuthenticatedRequest, res: Response, next: NextFunction -): void => { +): Promise => { try { const authHeader = req.get("Authorization"); const token = authHeader && authHeader.split(" ")[1]; @@ -191,6 +211,16 @@ export const refreshTokenIfNeeded = ( // 1시간(3600초) 이내에 만료되는 경우 갱신 if (timeUntilExpiry > 0 && timeUntilExpiry < 3600) { + // 갱신 전 token_version 검증 + if (decoded.tokenVersion !== undefined) { + const dbVersion = await TokenInvalidationService.getUserTokenVersion(decoded.userId); + if (decoded.tokenVersion !== dbVersion) { + // 무효화된 토큰은 갱신하지 않음 + next(); + return; + } + } + const newToken = JwtUtils.refreshToken(token); // 새로운 토큰을 응답 헤더에 포함 diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index a0779d50..d0ddbd6c 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -21,6 +21,7 @@ import { saveUser, // 사용자 등록/수정 saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!) getUserWithDept, // 사원 + 부서 조회 (NEW!) + deleteUser, // 사용자 삭제 (soft delete) getCompanyList, getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 getCompanyByCode, // 회사 단건 조회 @@ -62,6 +63,7 @@ router.put("/users/:userId", saveUser); // 사용자 수정 (REST API) router.put("/profile", updateProfile); // 프로필 수정 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화 +router.delete("/users/:userId", deleteUser); // 사용자 삭제 (soft delete) // 부서 관리 API router.get("/departments", getDepartmentList); // 부서 목록 조회 diff --git a/backend-node/src/routes/entityJoinRoutes.ts b/backend-node/src/routes/entityJoinRoutes.ts index 0e023770..89c1ccd8 100644 --- a/backend-node/src/routes/entityJoinRoutes.ts +++ b/backend-node/src/routes/entityJoinRoutes.ts @@ -55,6 +55,15 @@ router.get( entityJoinController.getTableDataWithJoins.bind(entityJoinController) ); +/** + * 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용) + * GET /api/table-management/tables/:tableName/column-values/:columnName + */ +router.get( + "/tables/:tableName/column-values/:columnName", + entityJoinController.getColumnUniqueValues.bind(entityJoinController) +); + // ======================================== // 🎯 Entity 조인 설정 관리 // ======================================== diff --git a/backend-node/src/routes/outboundRoutes.ts b/backend-node/src/routes/outboundRoutes.ts new file mode 100644 index 00000000..f81f722b --- /dev/null +++ b/backend-node/src/routes/outboundRoutes.ts @@ -0,0 +1,40 @@ +/** + * 출고관리 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as outboundController from "../controllers/outboundController"; + +const router = Router(); + +router.use(authenticateToken); + +// 출고 목록 조회 +router.get("/list", outboundController.getList); + +// 출고번호 자동생성 +router.get("/generate-number", outboundController.generateNumber); + +// 창고 목록 조회 +router.get("/warehouses", outboundController.getWarehouses); + +// 소스 데이터: 출하지시 (판매출고) +router.get("/source/shipment-instructions", outboundController.getShipmentInstructions); + +// 소스 데이터: 발주 (반품출고) +router.get("/source/purchase-orders", outboundController.getPurchaseOrders); + +// 소스 데이터: 품목 (기타출고) +router.get("/source/items", outboundController.getItems); + +// 출고 등록 +router.post("/", outboundController.create); + +// 출고 수정 +router.put("/:id", outboundController.update); + +// 출고 삭제 +router.delete("/:id", outboundController.deleteOutbound); + +export default router; diff --git a/backend-node/src/routes/packagingRoutes.ts b/backend-node/src/routes/packagingRoutes.ts index db921caa..6c3122ad 100644 --- a/backend-node/src/routes/packagingRoutes.ts +++ b/backend-node/src/routes/packagingRoutes.ts @@ -5,6 +5,7 @@ import { getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem, getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit, getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg, + getItemsByDivision, getGeneralItems, } from "../controllers/packagingController"; const router = Router(); @@ -33,4 +34,8 @@ router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs); router.post("/loading-unit-pkgs", createLoadingUnitPkg); router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg); +// 품목정보 연동 (division별) +router.get("/items/general", getGeneralItems); +router.get("/items/:divisionLabel", getItemsByDivision); + export default router; diff --git a/backend-node/src/routes/productionRoutes.ts b/backend-node/src/routes/productionRoutes.ts index 572674aa..a7505913 100644 --- a/backend-node/src/routes/productionRoutes.ts +++ b/backend-node/src/routes/productionRoutes.ts @@ -16,6 +16,9 @@ router.get("/order-summary", productionController.getOrderSummary); // 안전재고 부족분 조회 router.get("/stock-shortage", productionController.getStockShortage); +// 생산계획 목록 조회 +router.get("/plans", productionController.getPlans); + // 생산계획 CRUD router.get("/plan/:id", productionController.getPlanById); router.put("/plan/:id", productionController.updatePlan); diff --git a/backend-node/src/services/auditLogService.ts b/backend-node/src/services/auditLogService.ts index 82c2566e..d62d1d71 100644 --- a/backend-node/src/services/auditLogService.ts +++ b/backend-node/src/services/auditLogService.ts @@ -24,7 +24,8 @@ export type AuditAction = | "STATUS_CHANGE" | "BATCH_CREATE" | "BATCH_UPDATE" - | "BATCH_DELETE"; + | "BATCH_DELETE" + | "DEPT_CHANGE_WARNING"; export type AuditResourceType = | "MENU" diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index 5bbf3089..c83c5874 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -134,12 +134,14 @@ export class AuthService { company_code: string | null; locale: string | null; photo: Buffer | null; + token_version: number | null; }>( `SELECT sabun, user_id, user_name, user_name_eng, user_name_cn, dept_code, dept_name, position_code, position_name, email, tel, cell_phone, user_type, user_type_name, - partner_objid, company_code, locale, photo + partner_objid, company_code, locale, photo, + COALESCE(token_version, 0) as token_version FROM user_info WHERE user_id = $1`, [userId] @@ -210,6 +212,7 @@ export class AuthService { ? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}` : undefined, locale: userInfo.locale || "KR", + tokenVersion: userInfo.token_version ?? 0, // 권한 레벨 정보 추가 (3단계 체계) isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN", isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*", diff --git a/backend-node/src/services/multilangService.ts b/backend-node/src/services/multilangService.ts index fc765d89..7ca92932 100644 --- a/backend-node/src/services/multilangService.ts +++ b/backend-node/src/services/multilangService.ts @@ -673,6 +673,22 @@ export class MultiLangService { } } + /** + * 키의 소유 회사 코드 조회 (권한 검증용) + */ + async getKeyCompanyCode(keyId: number): Promise { + try { + const result = await queryOne<{ company_code: string }>( + `SELECT company_code FROM multi_lang_key_master WHERE key_id = $1`, + [keyId] + ); + return result?.company_code || null; + } catch (error) { + logger.error("키 소유 회사 코드 조회 실패:", error); + return null; + } + } + /** * 다국어 키 목록 조회 */ @@ -688,6 +704,10 @@ export class MultiLangService { if (params.companyCode) { whereConditions.push(`company_code = $${paramIndex++}`); values.push(params.companyCode); + } else if (params.userCompanyCode && params.userCompanyCode !== "*") { + // 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환 + whereConditions.push(`company_code IN ($${paramIndex++}, '*')`); + values.push(params.userCompanyCode); } // 메뉴 코드 필터 diff --git a/backend-node/src/services/productionPlanService.ts b/backend-node/src/services/productionPlanService.ts index f6b080a0..0481922c 100644 --- a/backend-node/src/services/productionPlanService.ts +++ b/backend-node/src/services/productionPlanService.ts @@ -35,6 +35,33 @@ export async function getOrderSummary( const whereClause = conditions.join(" AND "); + // item_info에 lead_time 컬럼이 존재하는지 확인 + const leadTimeColCheck = await pool.query(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'item_info' AND column_name = 'lead_time' + ) AS has_lead_time + `); + const hasLeadTime = leadTimeColCheck.rows[0]?.has_lead_time === true; + + const itemLeadTimeCte = hasLeadTime + ? `item_lead_time AS ( + SELECT + item_number, + id AS item_id, + COALESCE(lead_time::int, 0) AS lead_time + FROM item_info + WHERE company_code = $1 + ),` + : `item_lead_time AS ( + SELECT + item_number, + id AS item_id, + 0 AS lead_time + FROM item_info + WHERE company_code = $1 + ),`; + const query = ` WITH order_summary AS ( SELECT @@ -49,6 +76,7 @@ export async function getOrderSummary( WHERE ${whereClause} GROUP BY so.part_code, so.part_name ), + ${itemLeadTimeCte} stock_info AS ( SELECT item_code, @@ -85,10 +113,12 @@ export async function getOrderSummary( os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0) - COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0), 0 - ) AS required_plan_qty + ) AS required_plan_qty, + COALESCE(ilt.lead_time, 0) AS lead_time FROM order_summary os LEFT JOIN stock_info si ON os.item_code = si.item_code LEFT JOIN plan_info pi ON os.item_code = pi.item_code + LEFT JOIN item_lead_time ilt ON (os.item_code = ilt.item_number OR os.item_code = ilt.item_id) ${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""} ORDER BY os.item_code; `; @@ -155,6 +185,80 @@ export async function getStockShortage(companyCode: string) { return result.rows; } +// ─── 생산계획 목록 조회 ─── + +export async function getPlans( + companyCode: string, + options?: { + productType?: string; + status?: string; + startDate?: string; + endDate?: string; + itemCode?: string; + } +) { + const pool = getPool(); + const conditions: string[] = ["p.company_code = $1"]; + const params: any[] = [companyCode]; + let paramIdx = 2; + + if (companyCode !== "*") { + // 일반 회사: 자사 데이터만 + } else { + // 최고관리자: 전체 데이터 (company_code 조건 제거) + conditions.length = 0; + } + + if (options?.productType) { + conditions.push(`COALESCE(p.product_type, '완제품') = $${paramIdx}`); + params.push(options.productType); + paramIdx++; + } + if (options?.status && options.status !== "all") { + conditions.push(`p.status = $${paramIdx}`); + params.push(options.status); + paramIdx++; + } + if (options?.startDate) { + conditions.push(`p.end_date >= $${paramIdx}::date`); + params.push(options.startDate); + paramIdx++; + } + if (options?.endDate) { + conditions.push(`p.start_date <= $${paramIdx}::date`); + params.push(options.endDate); + paramIdx++; + } + if (options?.itemCode) { + conditions.push(`(p.item_code ILIKE $${paramIdx} OR p.item_name ILIKE $${paramIdx})`); + params.push(`%${options.itemCode}%`); + paramIdx++; + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT + p.id, p.company_code, p.plan_no, p.plan_date, + p.item_code, p.item_name, p.product_type, + p.plan_qty, p.completed_qty, p.progress_rate, + p.start_date, p.end_date, p.due_date, + p.equipment_id, p.equipment_code, p.equipment_name, + p.status, p.priority, p.work_shift, + p.work_order_no, p.manager_name, + p.order_no, p.parent_plan_id, p.remarks, + p.hourly_capacity, p.daily_capacity, p.lead_time, + p.created_date, p.updated_date + FROM production_plan_mng p + ${whereClause} + ORDER BY p.start_date ASC, p.item_code ASC + `; + + const result = await pool.query(query, params); + logger.info("생산계획 목록 조회", { companyCode, count: result.rowCount }); + return result.rows; +} + // ─── 생산계획 CRUD ─── export async function getPlanById(companyCode: string, planId: number) { @@ -267,49 +371,81 @@ export async function previewSchedule( const deletedSchedules: any[] = []; const keptSchedules: any[] = []; - for (const item of items) { - if (options.recalculate_unstarted) { - // 삭제 대상(planned) 상세 조회 + // 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행 + if (options.recalculate_unstarted) { + const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))]; + for (const itemCode of uniqueItemCodes) { const deleteResult = await pool.query( `SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status FROM production_plan_mng WHERE company_code = $1 AND item_code = $2 AND COALESCE(product_type, '완제품') = $3 AND status = 'planned'`, - [companyCode, item.item_code, productType] + [companyCode, itemCode, productType] ); deletedSchedules.push(...deleteResult.rows); - // 유지 대상(진행중 등) 상세 조회 const keptResult = await pool.query( `SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty FROM production_plan_mng WHERE company_code = $1 AND item_code = $2 AND COALESCE(product_type, '완제품') = $3 AND status NOT IN ('planned', 'completed', 'cancelled')`, - [companyCode, item.item_code, productType] + [companyCode, itemCode, productType] ); keptSchedules.push(...keptResult.rows); } + } + for (const item of items) { const dailyCapacity = item.daily_capacity || 800; - const requiredQty = item.required_qty; + 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)); + } + } + } + if (requiredQty <= 0) continue; - const productionDays = Math.ceil(requiredQty / dailyCapacity); - + // 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산 const dueDate = new Date(item.earliest_due_date); - const endDate = new Date(dueDate); - endDate.setDate(endDate.getDate() - safetyLeadTime); - const startDate = new Date(endDate); - startDate.setDate(startDate.getDate() - productionDays); + let startDate: Date; + let endDate: Date; + + if (itemLeadTime > 0) { + // 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임 + endDate = new Date(dueDate); + startDate = new Date(dueDate); + startDate.setDate(startDate.getDate() - itemLeadTime); + } else { + // 리드타임이 없으면 기존 로직 (생산능력 기반) + const productionDays = Math.ceil(requiredQty / dailyCapacity); + endDate = new Date(dueDate); + endDate.setDate(endDate.getDate() - safetyLeadTime); + startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - productionDays); + } const today = new Date(); today.setHours(0, 0, 0, 0); if (startDate < today) { + const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); startDate.setTime(today.getTime()); endDate.setTime(startDate.getTime()); - endDate.setDate(endDate.getDate() + productionDays); + endDate.setDate(endDate.getDate() + duration); } // 해당 품목의 수주 건수 확인 @@ -326,10 +462,11 @@ export async function previewSchedule( required_qty: requiredQty, daily_capacity: dailyCapacity, hourly_capacity: item.hourly_capacity || 100, - production_days: productionDays, + production_days: itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity), start_date: startDate.toISOString().split("T")[0], end_date: endDate.toISOString().split("T")[0], due_date: item.earliest_due_date, + lead_time: itemLeadTime, order_count: orderCount, status: "planned", }); @@ -343,7 +480,7 @@ export async function previewSchedule( }; logger.info("자동 스케줄 미리보기", { companyCode, summary }); - return { summary, previews, deletedSchedules, keptSchedules }; + return { summary, schedules: previews, deletedSchedules, keptSchedules }; } export async function generateSchedule( @@ -363,10 +500,22 @@ export async function generateSchedule( let deletedCount = 0; let keptCount = 0; const newSchedules: any[] = []; + const deletedQtyByItem = new Map(); + + // 같은 item_code에 대한 삭제는 한 번만 수행 + if (options.recalculate_unstarted) { + const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))]; + for (const itemCode of uniqueItemCodes) { + const deletedQtyResult = await client.query( + `SELECT COALESCE(SUM(COALESCE(plan_qty::numeric, 0)), 0) AS deleted_qty + FROM production_plan_mng + WHERE company_code = $1 AND item_code = $2 + AND COALESCE(product_type, '완제품') = $3 + AND status = 'planned'`, + [companyCode, itemCode, productType] + ); + deletedQtyByItem.set(itemCode, parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0); - for (const item of items) { - // 기존 미진행(planned) 스케줄 처리 - if (options.recalculate_unstarted) { const deleteResult = await client.query( `DELETE FROM production_plan_mng WHERE company_code = $1 @@ -374,7 +523,7 @@ export async function generateSchedule( AND COALESCE(product_type, '완제품') = $3 AND status = 'planned' RETURNING id`, - [companyCode, item.item_code, productType] + [companyCode, itemCode, productType] ); deletedCount += deleteResult.rowCount || 0; @@ -384,32 +533,58 @@ export async function generateSchedule( AND item_code = $2 AND COALESCE(product_type, '완제품') = $3 AND status NOT IN ('planned', 'completed', 'cancelled')`, - [companyCode, item.item_code, productType] + [companyCode, itemCode, productType] ); keptCount += parseInt(keptResult.rows[0].cnt, 10); } + } - // 생산일수 계산 + for (const item of items) { + // 필요 수량 계산 (삭제된 planned 수량을 비율로 분배) const dailyCapacity = item.daily_capacity || 800; - const requiredQty = item.required_qty; + 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)); + } + } + } if (requiredQty <= 0) continue; - const productionDays = Math.ceil(requiredQty / dailyCapacity); - - // 시작일 = 납기일 - 생산일수 - 안전리드타임 + // 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산 const dueDate = new Date(item.earliest_due_date); - const endDate = new Date(dueDate); - endDate.setDate(endDate.getDate() - safetyLeadTime); - const startDate = new Date(endDate); - startDate.setDate(startDate.getDate() - productionDays); + let startDate: Date; + let endDate: Date; + + if (itemLeadTime > 0) { + // 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임 + endDate = new Date(dueDate); + startDate = new Date(dueDate); + startDate.setDate(startDate.getDate() - itemLeadTime); + } else { + // 리드타임이 없으면 기존 로직 (생산능력 기반) + const productionDays = Math.ceil(requiredQty / dailyCapacity); + endDate = new Date(dueDate); + endDate.setDate(endDate.getDate() - safetyLeadTime); + startDate = new Date(endDate); + startDate.setDate(startDate.getDate() - productionDays); + } // 시작일이 오늘보다 이전이면 오늘로 조정 const today = new Date(); today.setHours(0, 0, 0, 0); if (startDate < today) { + const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); startDate.setTime(today.getTime()); endDate.setTime(startDate.getTime()); - endDate.setDate(endDate.getDate() + productionDays); + endDate.setDate(endDate.getDate() + duration); } // 계획번호 생성 (YYYYMMDD-NNNN 형식) @@ -576,13 +751,24 @@ async function getBomChildItems( companyCode: string, itemCode: string ) { + // item_info에 lead_time 컬럼 존재 여부 확인 + const colCheck = await client.query(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'item_info' AND column_name = 'lead_time' + ) AS has_lead_time + `); + const hasLeadTime = colCheck.rows[0]?.has_lead_time === true; + const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time::int, 0)" : "0"; + const bomQuery = ` SELECT bd.child_item_id, ii.item_name AS child_item_name, ii.item_number AS child_item_code, bd.quantity AS bom_qty, - bd.unit + bd.unit, + ${leadTimeCol} AS child_lead_time FROM bom b JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code @@ -641,9 +827,12 @@ export async function previewSemiSchedule( if (requiredQty <= 0) continue; + // 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산 + const childLeadTime = parseInt(bomItem.child_lead_time) || 1; const semiDueDate = plan.start_date; + const semiEndDate = new Date(plan.start_date); const semiStartDate = new Date(plan.start_date); - semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); + semiStartDate.setDate(semiStartDate.getDate() - childLeadTime); previews.push({ parent_plan_id: plan.id, @@ -653,13 +842,14 @@ export async function previewSemiSchedule( item_name: bomItem.child_item_name || bomItem.child_item_id, plan_qty: requiredQty, bom_qty: parseFloat(bomItem.bom_qty) || 1, + lead_time: childLeadTime, start_date: semiStartDate.toISOString().split("T")[0], end_date: typeof semiDueDate === "string" ? semiDueDate.split("T")[0] - : new Date(semiDueDate).toISOString().split("T")[0], + : semiEndDate.toISOString().split("T")[0], due_date: typeof semiDueDate === "string" ? semiDueDate.split("T")[0] - : new Date(semiDueDate).toISOString().split("T")[0], + : semiEndDate.toISOString().split("T")[0], product_type: "반제품", status: "planned", }); @@ -683,7 +873,7 @@ export async function previewSemiSchedule( parent_count: plansResult.rowCount, }; - return { summary, previews, deletedSchedules, keptSchedules }; + return { summary, schedules: previews, deletedSchedules, keptSchedules }; } // ─── 반제품 계획 자동 생성 ─── @@ -740,10 +930,12 @@ export async function generateSemiSchedule( if (requiredQty <= 0) continue; + // 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산 + const childLeadTime = parseInt(bomItem.child_lead_time) || 1; const semiDueDate = plan.start_date; const semiEndDate = plan.start_date; const semiStartDate = new Date(plan.start_date); - semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1)); + semiStartDate.setDate(semiStartDate.getDate() - childLeadTime); // plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품) const planNoResult = await client.query( diff --git a/backend-node/src/services/roleService.ts b/backend-node/src/services/roleService.ts index abf19f40..2696dfce 100644 --- a/backend-node/src/services/roleService.ts +++ b/backend-node/src/services/roleService.ts @@ -1,4 +1,4 @@ -import { query } from "../database/db"; +import { query, transaction } from "../database/db"; import { logger } from "../utils/logger"; /** @@ -145,10 +145,19 @@ export class RoleService { writer: string; }): Promise { try { + // 동일 회사 내 같은 이름의 권한 그룹 중복 체크 + const dupCheck = await query<{ count: string }>( + `SELECT COUNT(*) AS count FROM authority_master WHERE company_code = $1 AND auth_name = $2`, + [data.companyCode, data.authName] + ); + if (dupCheck.length > 0 && parseInt(dupCheck[0].count, 10) > 0) { + throw new Error(`동일 회사 내에 이미 같은 이름의 권한 그룹이 존재합니다: ${data.authName}`); + } + const sql = ` INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate) VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW()) - RETURNING objid, auth_name AS "authName", auth_code AS "authCode", + RETURNING objid, auth_name AS "authName", auth_code AS "authCode", company_code AS "companyCode", status, writer, regdate `; @@ -460,35 +469,37 @@ export class RoleService { writer: string ): Promise { try { - // 기존 권한 삭제 - await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [ - authObjid, - ]); - - // 새로운 권한 삽입 - if (permissions.length > 0) { - const values = permissions - .map( - (_, index) => - `(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())` - ) - .join(", "); - - const params = permissions.flatMap((p) => [ - p.menuObjid, - p.createYn, - p.readYn, - p.updateYn, - p.deleteYn, + await transaction(async (client) => { + // 기존 권한 삭제 + await client.query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [ + authObjid, ]); - const sql = ` - INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate) - VALUES ${values} - `; + // 새로운 권한 삽입 + if (permissions.length > 0) { + const values = permissions + .map( + (_, index) => + `(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())` + ) + .join(", "); - await query(sql, [authObjid, ...params, writer]); - } + const params = permissions.flatMap((p) => [ + p.menuObjid, + p.createYn, + p.readYn, + p.updateYn, + p.deleteYn, + ]); + + const sql = ` + INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate) + VALUES ${values} + `; + + await client.query(sql, [authObjid, ...params, writer]); + } + }); logger.info("메뉴 권한 설정 성공", { authObjid, diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index a8b12605..6fc07d39 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -211,7 +211,8 @@ class TableCategoryValueService { created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy", - updated_by AS "updatedBy" + updated_by AS "updatedBy", + path FROM category_values WHERE table_name = $1 AND column_name = $2 @@ -1441,7 +1442,7 @@ class TableCategoryValueService { // is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함 if (companyCode === "*") { query = ` - SELECT DISTINCT value_code, value_label + SELECT DISTINCT value_code, value_label, path FROM category_values WHERE value_code IN (${placeholders1}) `; @@ -1449,7 +1450,7 @@ class TableCategoryValueService { } else { const companyIdx = n + 1; query = ` - SELECT DISTINCT value_code, value_label + SELECT DISTINCT value_code, value_label, path FROM category_values WHERE value_code IN (${placeholders1}) AND (company_code = $${companyIdx} OR company_code = '*') @@ -1460,10 +1461,15 @@ class TableCategoryValueService { const result = await pool.query(query, params); // { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선) + // path가 있고 '/'를 포함하면(depth>1) 전체 경로를 ' > ' 구분자로 표시 const labels: Record = {}; for (const row of result.rows) { if (!labels[row.value_code]) { - labels[row.value_code] = row.value_label; + if (row.path && row.path.includes('/')) { + labels[row.value_code] = row.path.replace(/\//g, ' > '); + } else { + labels[row.value_code] = row.value_label; + } } } diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 82b66438..7f5c5f2e 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1575,7 +1575,7 @@ export class TableManagementService { switch (operator) { case "equals": return { - whereClause: `${columnName}::text = $${paramIndex}`, + whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`, values: [actualValue], paramCount: 1, }; @@ -1859,10 +1859,10 @@ export class TableManagementService { }; } - // select 필터(equals)인 경우 정확한 코드값 매칭만 수행 + // select 필터(equals)인 경우 — 다중 값(콤마 구분) 지원 if (operator === "equals") { return { - whereClause: `${columnName}::text = $${paramIndex}`, + whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`, values: [String(value)], paramCount: 1, }; @@ -2717,6 +2717,43 @@ export class TableManagementService { logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`); } + // entity 컬럼의 display_column 자동 채우기 (예: supplier_code → supplier_name) + try { + const companyCode = data.company_code || "*"; + const entityColsResult = await query( + `SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column + FROM table_type_columns + WHERE table_name = $1 AND input_type = 'entity' + AND reference_table IS NOT NULL AND reference_table != '' + AND display_column IS NOT NULL AND display_column != '' + AND company_code IN ($2, '*') + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + [tableName, companyCode] + ); + + for (const ec of entityColsResult) { + const srcVal = data[ec.column_name]; + const displayCol = ec.display_column; + // display_column이 테이블에 존재하고, 값이 비어있거나 없으면 자동 조회 + if (srcVal && columnTypeMap.has(displayCol) && (!data[displayCol] || data[displayCol] === "")) { + try { + const refResult = await query( + `SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`, + [srcVal, companyCode] + ); + if (refResult.length > 0 && refResult[0][displayCol]) { + data[displayCol] = refResult[0][displayCol]; + logger.info(`Entity auto-fill: ${tableName}.${displayCol} = ${data[displayCol]} (from ${ec.reference_table})`); + } + } catch (refErr: any) { + logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`); + } + } + } + } catch (entityErr: any) { + logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`); + } + // 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시) const skippedColumns: string[] = []; const existingColumns = Object.keys(data).filter((col) => { @@ -2868,6 +2905,42 @@ export class TableManagementService { logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`); } + // entity 컬럼의 display_column 자동 채우기 (수정 시) + try { + const companyCode = updatedData.company_code || originalData.company_code || "*"; + const entityColsResult = await query( + `SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column + FROM table_type_columns + WHERE table_name = $1 AND input_type = 'entity' + AND reference_table IS NOT NULL AND reference_table != '' + AND display_column IS NOT NULL AND display_column != '' + AND company_code IN ($2, '*') + ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`, + [tableName, companyCode] + ); + + for (const ec of entityColsResult) { + const srcVal = updatedData[ec.column_name]; + const displayCol = ec.display_column; + if (srcVal && columnTypeMap.has(displayCol) && (!updatedData[displayCol] || updatedData[displayCol] === "")) { + try { + const refResult = await query( + `SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`, + [srcVal, companyCode] + ); + if (refResult.length > 0 && refResult[0][displayCol]) { + updatedData[displayCol] = refResult[0][displayCol]; + logger.info(`Entity auto-fill (edit): ${tableName}.${displayCol} = ${updatedData[displayCol]}`); + } + } catch (refErr: any) { + logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`); + } + } + } + } catch (entityErr: any) { + logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`); + } + // SET 절 생성 (수정할 데이터) - 먼저 생성 // 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외) const setConditions: string[] = []; @@ -3357,16 +3430,20 @@ export class TableManagementService { const safeColumn = `main."${columnName}"`; switch (operator) { - case "equals": + case "equals": { + const safeVal = String(value).replace(/'/g, "''"); filterConditions.push( - `${safeColumn} = '${String(value).replace(/'/g, "''")}'` + `('${safeVal}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal}')` ); break; - case "not_equals": + } + case "not_equals": { + const safeVal2 = String(value).replace(/'/g, "''"); filterConditions.push( - `${safeColumn} != '${String(value).replace(/'/g, "''")}'` + `NOT ('${safeVal2}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal2}')` ); break; + } case "in": { const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : []; if (inArr.length > 0) { @@ -3408,6 +3485,31 @@ export class TableManagementService { case "is_not_null": filterConditions.push(`${safeColumn} IS NOT NULL`); break; + case "not_contains": + filterConditions.push( + `${safeColumn}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'` + ); + break; + case "greater_than": + filterConditions.push( + `(${safeColumn})::numeric > ${parseFloat(String(value))}` + ); + break; + case "less_than": + filterConditions.push( + `(${safeColumn})::numeric < ${parseFloat(String(value))}` + ); + break; + case "greater_or_equal": + filterConditions.push( + `(${safeColumn})::numeric >= ${parseFloat(String(value))}` + ); + break; + case "less_or_equal": + filterConditions.push( + `(${safeColumn})::numeric <= ${parseFloat(String(value))}` + ); + break; } } @@ -3424,6 +3526,89 @@ export class TableManagementService { } } + // 🆕 filterGroups 처리 (런타임 필터 빌더 - 그룹별 AND/OR 지원) + if ( + options.dataFilter && + options.dataFilter.filterGroups && + options.dataFilter.filterGroups.length > 0 + ) { + const groupConditions: string[] = []; + + for (const group of options.dataFilter.filterGroups) { + if (!group.conditions || group.conditions.length === 0) continue; + + const conditions: string[] = []; + + for (const condition of group.conditions) { + const { columnName, operator, value } = condition; + if (!columnName) continue; + + const safeCol = `main."${columnName}"`; + + switch (operator) { + case "equals": + conditions.push(`${safeCol}::text = '${String(value).replace(/'/g, "''")}'`); + break; + case "not_equals": + conditions.push(`${safeCol}::text != '${String(value).replace(/'/g, "''")}'`); + break; + case "contains": + conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}%'`); + break; + case "not_contains": + conditions.push(`${safeCol}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`); + break; + case "starts_with": + conditions.push(`${safeCol}::text LIKE '${String(value).replace(/'/g, "''")}%'`); + break; + case "ends_with": + conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}'`); + break; + case "greater_than": + conditions.push(`(${safeCol})::numeric > ${parseFloat(String(value))}`); + break; + case "less_than": + conditions.push(`(${safeCol})::numeric < ${parseFloat(String(value))}`); + break; + case "greater_or_equal": + conditions.push(`(${safeCol})::numeric >= ${parseFloat(String(value))}`); + break; + case "less_or_equal": + conditions.push(`(${safeCol})::numeric <= ${parseFloat(String(value))}`); + break; + case "is_null": + conditions.push(`(${safeCol} IS NULL OR ${safeCol}::text = '')`); + break; + case "is_not_null": + conditions.push(`(${safeCol} IS NOT NULL AND ${safeCol}::text != '')`); + break; + case "in": { + const inArr = Array.isArray(value) ? value : [String(value)]; + if (inArr.length > 0) { + const vals = inArr.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(", "); + conditions.push(`${safeCol}::text IN (${vals})`); + } + break; + } + } + } + + if (conditions.length > 0) { + const logic = group.logic === "OR" ? " OR " : " AND "; + groupConditions.push(`(${conditions.join(logic)})`); + } + } + + if (groupConditions.length > 0) { + const groupWhere = groupConditions.join(" AND "); + whereClause = whereClause + ? `${whereClause} AND ${groupWhere}` + : groupWhere; + + logger.info(`🔍 필터 그룹 적용 (Entity 조인): ${groupWhere}`); + } + } + // 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외) if (options.excludeFilter && options.excludeFilter.enabled) { const { @@ -5387,4 +5572,40 @@ export class TableManagementService { return []; } } + + /** + * 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용) + */ + async getColumnDistinctValues( + tableName: string, + columnName: string, + companyCode?: string + ): Promise<{ value: string; label: string }[]> { + try { + // 테이블명/컬럼명 안전성 검증 (영문, 숫자, 언더스코어만 허용) + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) { + logger.warn(`잘못된 테이블/컬럼명: ${tableName}.${columnName}`); + return []; + } + + let sql = `SELECT DISTINCT "${columnName}"::text as value FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND "${columnName}"::text != ''`; + const params: any[] = []; + + if (companyCode) { + params.push(companyCode); + sql += ` AND "company_code" = $${params.length}`; + } + + sql += ` ORDER BY value LIMIT 500`; + + const rows = await query<{ value: string }>(sql, params); + return rows.map((row) => ({ + value: row.value, + label: row.value, + })); + } catch (error) { + logger.error(`컬럼 고유값 조회 실패: ${tableName}.${columnName}`, error); + return []; + } + } } diff --git a/backend-node/src/services/tokenInvalidationService.ts b/backend-node/src/services/tokenInvalidationService.ts new file mode 100644 index 00000000..6bcddc13 --- /dev/null +++ b/backend-node/src/services/tokenInvalidationService.ts @@ -0,0 +1,75 @@ +// JWT 토큰 무효화 서비스 +// user_info.token_version 기반으로 기존 JWT 토큰을 무효화 + +import { query } from "../database/db"; +import { cache } from "../utils/cache"; +import { logger } from "../utils/logger"; + +const TOKEN_VERSION_CACHE_TTL = 2 * 60 * 1000; // 2분 캐시 + +export class TokenInvalidationService { + /** + * 캐시 키 생성 + */ + static cacheKey(userId: string): string { + return `token_version:${userId}`; + } + + /** + * 단일 사용자의 토큰 무효화 (token_version +1) + */ + static async invalidateUserTokens(userId: string): Promise { + try { + await query( + `UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id = $1`, + [userId] + ); + cache.delete(this.cacheKey(userId)); + logger.info(`토큰 무효화: ${userId}`); + } catch (error) { + logger.error(`토큰 무효화 실패: ${userId}`, { error }); + } + } + + /** + * 여러 사용자의 토큰 일괄 무효화 + */ + static async invalidateMultipleUserTokens(userIds: string[]): Promise { + if (userIds.length === 0) return; + try { + const placeholders = userIds.map((_, i) => `$${i + 1}`).join(", "); + await query( + `UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id IN (${placeholders})`, + userIds + ); + userIds.forEach((id) => cache.delete(this.cacheKey(id))); + logger.info(`토큰 일괄 무효화: ${userIds.length}명`); + } catch (error) { + logger.error(`토큰 일괄 무효화 실패`, { error, userIds }); + } + } + + /** + * 현재 token_version 조회 (캐시 사용) + */ + static async getUserTokenVersion(userId: string): Promise { + const cacheKey = this.cacheKey(userId); + const cached = cache.get(cacheKey); + if (cached !== null) { + return cached; + } + + try { + const result = await query<{ token_version: number | null }>( + `SELECT token_version FROM user_info WHERE user_id = $1`, + [userId] + ); + const version = result.length > 0 ? (result[0].token_version ?? 0) : 0; + cache.set(cacheKey, version, TOKEN_VERSION_CACHE_TTL); + return version; + } catch (error) { + logger.error(`token_version 조회 실패: ${userId}`, { error }); + return 0; + } + } +} diff --git a/backend-node/src/types/auth.ts b/backend-node/src/types/auth.ts index 6abd1e39..e360c01a 100644 --- a/backend-node/src/types/auth.ts +++ b/backend-node/src/types/auth.ts @@ -64,6 +64,7 @@ export interface PersonBean { companyName?: string; // 회사명 추가 photo?: string; locale?: string; + tokenVersion?: number; // JWT 토큰 무효화용 버전 // 권한 레벨 정보 (3단계 체계) isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN') isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN') @@ -98,6 +99,7 @@ export interface JwtPayload { companyName?: string; // 회사명 추가 userType?: string; userTypeName?: string; + tokenVersion?: number; // JWT 토큰 무효화용 버전 iat?: number; exp?: number; aud?: string; diff --git a/backend-node/src/types/multilang.ts b/backend-node/src/types/multilang.ts index c30fdfaa..026810ca 100644 --- a/backend-node/src/types/multilang.ts +++ b/backend-node/src/types/multilang.ts @@ -140,6 +140,7 @@ export interface GetLangKeysParams { includeOverrides?: boolean; page?: number; limit?: number; + userCompanyCode?: string; // 요청 사용자의 회사 코드 (비관리자 필터링용) } export interface GetUserTextParams { diff --git a/backend-node/src/utils/jwtUtils.ts b/backend-node/src/utils/jwtUtils.ts index 44f75cbc..aba3bf68 100644 --- a/backend-node/src/utils/jwtUtils.ts +++ b/backend-node/src/utils/jwtUtils.ts @@ -20,6 +20,7 @@ export class JwtUtils { companyName: userInfo.companyName, // 회사명 추가 userType: userInfo.userType, userTypeName: userInfo.userTypeName, + tokenVersion: userInfo.tokenVersion ?? 0, }; return jwt.sign(payload, config.jwt.secret, { diff --git a/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl b/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl new file mode 100644 index 00000000..eeffca86 --- /dev/null +++ b/frontend/.omc/state/agent-replay-037169c7-72ba-4843-8e9a-417ca1423715.jsonl @@ -0,0 +1,14 @@ +{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":110167} +{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":196548} +{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":253997} +{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":339528} +{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":380641} +{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":413980} +{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":401646} diff --git a/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl b/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl new file mode 100644 index 00000000..64204160 --- /dev/null +++ b/frontend/.omc/state/agent-replay-8145031e-d7ea-4aa3-94d7-ddaa69383b8a.jsonl @@ -0,0 +1,10 @@ +{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":59735} +{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":93607} +{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":136249} +{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":261624} +{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_start","parent_mode":"none"} +{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":139427} diff --git a/frontend/.omc/state/idle-notif-cooldown.json b/frontend/.omc/state/idle-notif-cooldown.json new file mode 100644 index 00000000..9b6eaa2a --- /dev/null +++ b/frontend/.omc/state/idle-notif-cooldown.json @@ -0,0 +1,3 @@ +{ + "lastSentAt": "2026-03-25T05:06:13.529Z" +} \ No newline at end of file diff --git a/frontend/.omc/state/last-tool-error.json b/frontend/.omc/state/last-tool-error.json new file mode 100644 index 00000000..cc6d2569 --- /dev/null +++ b/frontend/.omc/state/last-tool-error.json @@ -0,0 +1,7 @@ +{ + "tool_name": "Bash", + "tool_input_preview": "{\"command\":\"wc -l /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx\",\"description\":\"Get total line count of the file\"}", + "error": "Exit code 1\n(eval):1: no matches found: /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx", + "timestamp": "2026-03-25T05:00:38.410Z", + "retry_count": 1 +} \ No newline at end of file diff --git a/frontend/.omc/state/mission-state.json b/frontend/.omc/state/mission-state.json new file mode 100644 index 00000000..a46a9962 --- /dev/null +++ b/frontend/.omc/state/mission-state.json @@ -0,0 +1,281 @@ +{ + "updatedAt": "2026-03-25T05:06:35.487Z", + "missions": [ + { + "id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none", + "source": "session", + "name": "none", + "objective": "Session mission", + "createdAt": "2026-03-25T00:33:45.197Z", + "updatedAt": "2026-03-25T01:37:19.659Z", + "status": "done", + "workerCount": 5, + "taskCounts": { + "total": 5, + "pending": 0, + "blocked": 0, + "inProgress": 0, + "completed": 5, + "failed": 0 + }, + "agents": [ + { + "name": "Explore:ad233db", + "role": "Explore", + "ownership": "ad233db7fa6f059dd", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T00:34:44.932Z" + }, + { + "name": "Explore:a31a0f7", + "role": "Explore", + "ownership": "a31a0f729d328643f", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T00:35:24.588Z" + }, + { + "name": "executor:a9510b7", + "role": "executor", + "ownership": "a9510b7d8ec5a1ce7", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T00:42:01.730Z" + }, + { + "name": "executor:a1c1d18", + "role": "executor", + "ownership": "a1c1d186f0eb6dfc1", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T00:40:12.608Z" + }, + { + "name": "executor:a9a231d", + "role": "executor", + "ownership": "a9a231d40fd5a150b", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T01:37:19.659Z" + } + ], + "timeline": [ + { + "id": "session-stop:a1c1d186f0eb6dfc1:2026-03-25T00:40:12.608Z", + "at": "2026-03-25T00:40:12.608Z", + "kind": "completion", + "agent": "executor:a1c1d18", + "detail": "completed", + "sourceKey": "session-stop:a1c1d186f0eb6dfc1" + }, + { + "id": "session-stop:a9510b7d8ec5a1ce7:2026-03-25T00:42:01.730Z", + "at": "2026-03-25T00:42:01.730Z", + "kind": "completion", + "agent": "executor:a9510b7", + "detail": "completed", + "sourceKey": "session-stop:a9510b7d8ec5a1ce7" + }, + { + "id": "session-start:a9a231d40fd5a150b:2026-03-25T01:35:00.232Z", + "at": "2026-03-25T01:35:00.232Z", + "kind": "update", + "agent": "executor:a9a231d", + "detail": "started executor:a9a231d", + "sourceKey": "session-start:a9a231d40fd5a150b" + }, + { + "id": "session-stop:a9a231d40fd5a150b:2026-03-25T01:37:19.659Z", + "at": "2026-03-25T01:37:19.659Z", + "kind": "completion", + "agent": "executor:a9a231d", + "detail": "completed", + "sourceKey": "session-stop:a9a231d40fd5a150b" + } + ] + }, + { + "id": "session:037169c7-72ba-4843-8e9a-417ca1423715:none", + "source": "session", + "name": "none", + "objective": "Session mission", + "createdAt": "2026-03-25T04:59:24.101Z", + "updatedAt": "2026-03-25T05:06:35.487Z", + "status": "done", + "workerCount": 7, + "taskCounts": { + "total": 7, + "pending": 0, + "blocked": 0, + "inProgress": 0, + "completed": 7, + "failed": 0 + }, + "agents": [ + { + "name": "executor:a32b34c", + "role": "executor", + "ownership": "a32b34c341b854da5", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:06:18.081Z" + }, + { + "name": "executor:ad2c89c", + "role": "executor", + "ownership": "ad2c89cf14936ea42", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:02:45.524Z" + }, + { + "name": "executor:a2c140c", + "role": "executor", + "ownership": "a2c140c5a5adb0719", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:05:13.388Z" + }, + { + "name": "executor:a2e5213", + "role": "executor", + "ownership": "a2e52136ea8f04385", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:03:53.163Z" + }, + { + "name": "executor:a3735bf", + "role": "executor", + "ownership": "a3735bf51a74d6fc8", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:01:33.817Z" + }, + { + "name": "executor:a77742b", + "role": "executor", + "ownership": "a77742ba65fd2451c", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:06:09.324Z" + }, + { + "name": "executor:a4eb932", + "role": "executor", + "ownership": "a4eb932c438b898c0", + "status": "done", + "currentStep": null, + "latestUpdate": "completed", + "completedSummary": null, + "updatedAt": "2026-03-25T05:06:35.487Z" + } + ], + "timeline": [ + { + "id": "session-start:a3735bf51a74d6fc8:2026-03-25T04:59:43.650Z", + "at": "2026-03-25T04:59:43.650Z", + "kind": "update", + "agent": "executor:a3735bf", + "detail": "started executor:a3735bf", + "sourceKey": "session-start:a3735bf51a74d6fc8" + }, + { + "id": "session-start:a77742ba65fd2451c:2026-03-25T04:59:48.683Z", + "at": "2026-03-25T04:59:48.683Z", + "kind": "update", + "agent": "executor:a77742b", + "detail": "started executor:a77742b", + "sourceKey": "session-start:a77742ba65fd2451c" + }, + { + "id": "session-start:a4eb932c438b898c0:2026-03-25T04:59:53.841Z", + "at": "2026-03-25T04:59:53.841Z", + "kind": "update", + "agent": "executor:a4eb932", + "detail": "started executor:a4eb932", + "sourceKey": "session-start:a4eb932c438b898c0" + }, + { + "id": "session-stop:a3735bf51a74d6fc8:2026-03-25T05:01:33.817Z", + "at": "2026-03-25T05:01:33.817Z", + "kind": "completion", + "agent": "executor:a3735bf", + "detail": "completed", + "sourceKey": "session-stop:a3735bf51a74d6fc8" + }, + { + "id": "session-stop:ad2c89cf14936ea42:2026-03-25T05:02:45.524Z", + "at": "2026-03-25T05:02:45.524Z", + "kind": "completion", + "agent": "executor:ad2c89c", + "detail": "completed", + "sourceKey": "session-stop:ad2c89cf14936ea42" + }, + { + "id": "session-stop:a2e52136ea8f04385:2026-03-25T05:03:53.163Z", + "at": "2026-03-25T05:03:53.163Z", + "kind": "completion", + "agent": "executor:a2e5213", + "detail": "completed", + "sourceKey": "session-stop:a2e52136ea8f04385" + }, + { + "id": "session-stop:a2c140c5a5adb0719:2026-03-25T05:05:13.388Z", + "at": "2026-03-25T05:05:13.388Z", + "kind": "completion", + "agent": "executor:a2c140c", + "detail": "completed", + "sourceKey": "session-stop:a2c140c5a5adb0719" + }, + { + "id": "session-stop:a77742ba65fd2451c:2026-03-25T05:06:09.324Z", + "at": "2026-03-25T05:06:09.324Z", + "kind": "completion", + "agent": "executor:a77742b", + "detail": "completed", + "sourceKey": "session-stop:a77742ba65fd2451c" + }, + { + "id": "session-stop:a32b34c341b854da5:2026-03-25T05:06:18.081Z", + "at": "2026-03-25T05:06:18.081Z", + "kind": "completion", + "agent": "executor:a32b34c", + "detail": "completed", + "sourceKey": "session-stop:a32b34c341b854da5" + }, + { + "id": "session-stop:a4eb932c438b898c0:2026-03-25T05:06:35.487Z", + "at": "2026-03-25T05:06:35.487Z", + "kind": "completion", + "agent": "executor:a4eb932", + "detail": "completed", + "sourceKey": "session-stop:a4eb932c438b898c0" + } + ] + } + ] +} \ No newline at end of file diff --git a/frontend/.omc/state/subagent-tracking.json b/frontend/.omc/state/subagent-tracking.json new file mode 100644 index 00000000..355a60d1 --- /dev/null +++ b/frontend/.omc/state/subagent-tracking.json @@ -0,0 +1,116 @@ +{ + "agents": [ + { + "agent_id": "ad233db7fa6f059dd", + "agent_type": "Explore", + "started_at": "2026-03-25T00:33:45.197Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T00:34:44.932Z", + "duration_ms": 59735 + }, + { + "agent_id": "a31a0f729d328643f", + "agent_type": "Explore", + "started_at": "2026-03-25T00:33:50.981Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T00:35:24.588Z", + "duration_ms": 93607 + }, + { + "agent_id": "a9510b7d8ec5a1ce7", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T00:37:40.106Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T00:42:01.730Z", + "duration_ms": 261624 + }, + { + "agent_id": "a1c1d186f0eb6dfc1", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T00:37:56.359Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T00:40:12.608Z", + "duration_ms": 136249 + }, + { + "agent_id": "a9a231d40fd5a150b", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T01:35:00.232Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T01:37:19.659Z", + "duration_ms": 139427 + }, + { + "agent_id": "a32b34c341b854da5", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:24.101Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:06:18.081Z", + "duration_ms": 413980 + }, + { + "agent_id": "ad2c89cf14936ea42", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:28.976Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:02:45.524Z", + "duration_ms": 196548 + }, + { + "agent_id": "a2c140c5a5adb0719", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:33.860Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:05:13.388Z", + "duration_ms": 339528 + }, + { + "agent_id": "a2e52136ea8f04385", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:39.166Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:03:53.163Z", + "duration_ms": 253997 + }, + { + "agent_id": "a3735bf51a74d6fc8", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:43.650Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:01:33.817Z", + "duration_ms": 110167 + }, + { + "agent_id": "a77742ba65fd2451c", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:48.683Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:06:09.324Z", + "duration_ms": 380641 + }, + { + "agent_id": "a4eb932c438b898c0", + "agent_type": "oh-my-claudecode:executor", + "started_at": "2026-03-25T04:59:53.841Z", + "parent_mode": "none", + "status": "completed", + "completed_at": "2026-03-25T05:06:35.487Z", + "duration_ms": 401646 + } + ], + "total_spawned": 12, + "total_completed": 12, + "total_failed": 0, + "last_updated": "2026-03-25T05:06:35.589Z" +} \ No newline at end of file diff --git a/frontend/app/(main)/equipment/info/page.tsx b/frontend/app/(main)/equipment/info/page.tsx new file mode 100644 index 00000000..9bb5fede --- /dev/null +++ b/frontend/app/(main)/equipment/info/page.tsx @@ -0,0 +1,752 @@ +"use client"; + +/** + * 설비정보 — 하드코딩 페이지 + * + * 좌측: 설비 목록 (equipment_mng) + * 우측: 탭 (기본정보 / 점검항목 / 소모품) + * 점검항목 복사 기능 포함 + */ + +import React, { useState, useEffect, useCallback } 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 { Checkbox } from "@/components/ui/checkbox"; +import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; +import { + Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, + Wrench, ClipboardCheck, Package, Copy, Info, Settings2, +} 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 { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; +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 { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel"; +import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal"; +import { ImageUpload } from "@/components/common/ImageUpload"; + +const EQUIP_TABLE = "equipment_mng"; +const INSPECTION_TABLE = "equipment_inspection_item"; +const CONSUMABLE_TABLE = "equipment_consumable"; + +const LEFT_COLUMNS: DataGridColumn[] = [ + { key: "equipment_code", label: "설비코드", width: "w-[110px]" }, + { key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]" }, + { key: "equipment_type", label: "설비유형", width: "w-[90px]" }, + { key: "manufacturer", label: "제조사", width: "w-[100px]" }, + { key: "installation_location", label: "설치장소", width: "w-[100px]" }, + { key: "operation_status", label: "가동상태", width: "w-[80px]" }, +]; + +const INSPECTION_COLUMNS: DataGridColumn[] = [ + { key: "inspection_item", label: "점검항목", minWidth: "min-w-[120px]", editable: true }, + { key: "inspection_cycle", label: "점검주기", width: "w-[80px]" }, + { key: "inspection_method", label: "점검방법", width: "w-[80px]" }, + { key: "lower_limit", label: "하한치", width: "w-[70px]", editable: true }, + { key: "upper_limit", label: "상한치", width: "w-[70px]", editable: true }, + { key: "unit", label: "단위", width: "w-[60px]", editable: true }, + { key: "inspection_content", label: "점검내용", minWidth: "min-w-[150px]", editable: true }, +]; + +const CONSUMABLE_COLUMNS: DataGridColumn[] = [ + { key: "image_path", label: "이미지", width: "w-[50px]", renderType: "image", sortable: false, filterable: false }, + { key: "consumable_name", label: "소모품명", minWidth: "min-w-[120px]", editable: true }, + { key: "replacement_cycle", label: "교체주기", width: "w-[90px]", editable: true }, + { key: "unit", label: "단위", width: "w-[60px]", editable: true }, + { key: "specification", label: "규격", width: "w-[100px]", editable: true }, + { key: "manufacturer", label: "제조사", width: "w-[100px]", editable: true }, +]; + +export default function EquipmentInfoPage() { + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + // 좌측 + const [equipments, setEquipments] = useState([]); + const [equipLoading, setEquipLoading] = useState(false); + const [equipCount, setEquipCount] = useState(0); + const [searchFilters, setSearchFilters] = useState([]); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); + const [selectedEquipId, setSelectedEquipId] = useState(null); + + // 우측 탭 + const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info"); + const [inspections, setInspections] = useState([]); + const [inspectionLoading, setInspectionLoading] = useState(false); + const [consumables, setConsumables] = useState([]); + const [consumableLoading, setConsumableLoading] = useState(false); + + // 카테고리 + const [catOptions, setCatOptions] = useState>({}); + + // 모달 + const [equipModalOpen, setEquipModalOpen] = useState(false); + const [equipEditMode, setEquipEditMode] = useState(false); + const [equipForm, setEquipForm] = useState>({}); + const [saving, setSaving] = useState(false); + + // 기본정보 탭 편집 폼 + const [infoForm, setInfoForm] = useState>({}); + const [infoSaving, setInfoSaving] = useState(false); + + const [inspectionModalOpen, setInspectionModalOpen] = useState(false); + const [inspectionForm, setInspectionForm] = useState>({}); + + const [consumableModalOpen, setConsumableModalOpen] = useState(false); + const [consumableForm, setConsumableForm] = useState>({}); + const [consumableItemOptions, setConsumableItemOptions] = useState([]); + + // 점검항목 복사 + const [copyModalOpen, setCopyModalOpen] = useState(false); + const [copySourceEquip, setCopySourceEquip] = useState(""); + const [copyItems, setCopyItems] = useState([]); + const [copyChecked, setCopyChecked] = useState>(new Set()); + const [copyLoading, setCopyLoading] = useState(false); + + // 엑셀 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + const [excelChainConfig, setExcelChainConfig] = useState(null); + const [excelDetecting, setExcelDetecting] = useState(false); + + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("equipment-info"); + if (saved) applyTableSettings(saved); + }, []); + + // 카테고리 로드 + useEffect(() => { + const load = 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, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + // equipment_mng 카테고리 + for (const col of ["equipment_type", "operation_status"]) { + try { + const res = await apiClient.get(`/table-categories/${EQUIP_TABLE}/${col}/values`); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + // inspection 카테고리 + for (const col of ["inspection_cycle", "inspection_method"]) { + try { + const res = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/${col}/values`); + if (res.data?.success) optMap[col] = flatten(res.data.data || []); + } catch { /* skip */ } + } + setCatOptions(optMap); + }; + load(); + }, []); + + const resolve = (col: string, code: string) => { + if (!code) return ""; + return catOptions[col]?.find((o) => o.code === code)?.label || code; + }; + + // 설비 조회 + const fetchEquipments = useCallback(async () => { + setEquipLoading(true); + try { + const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, { + page: 1, size: 500, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const raw = res.data?.data?.data || res.data?.data?.rows || []; + setEquipments(raw.map((r: any) => ({ + ...r, + equipment_type: resolve("equipment_type", r.equipment_type), + operation_status: resolve("operation_status", r.operation_status), + }))); + setEquipCount(res.data?.data?.total || raw.length); + } catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); } + }, [searchFilters, catOptions]); + + useEffect(() => { fetchEquipments(); }, [fetchEquipments]); + + const selectedEquip = equipments.find((e) => e.id === selectedEquipId); + + // 기본정보 탭 폼 초기화 (설비 선택 변경 시) + useEffect(() => { + if (selectedEquip) setInfoForm({ ...selectedEquip }); + else setInfoForm({}); + }, [selectedEquipId]); // eslint-disable-line react-hooks/exhaustive-deps + + // 기본정보 저장 + const handleInfoSave = async () => { + if (!infoForm.id) return; + setInfoSaving(true); + try { + const { id, created_date, updated_date, writer, company_code, ...fields } = infoForm; + await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields }); + toast.success("저장되었습니다."); + fetchEquipments(); + } catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } + finally { setInfoSaving(false); } + }; + + // 우측: 점검항목 조회 + useEffect(() => { + if (!selectedEquip?.equipment_code) { setInspections([]); return; } + const fetch = async () => { + setInspectionLoading(true); + try { + const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] }, + autoFilter: true, + }); + const raw = res.data?.data?.data || res.data?.data?.rows || []; + setInspections(raw.map((r: any) => ({ + ...r, + inspection_cycle: resolve("inspection_cycle", r.inspection_cycle), + inspection_method: resolve("inspection_method", r.inspection_method), + }))); + } catch { setInspections([]); } finally { setInspectionLoading(false); } + }; + fetch(); + }, [selectedEquip?.equipment_code, catOptions]); + + // 우측: 소모품 조회 + useEffect(() => { + if (!selectedEquip?.equipment_code) { setConsumables([]); return; } + const fetch = async () => { + setConsumableLoading(true); + try { + const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] }, + autoFilter: true, + }); + setConsumables(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setConsumables([]); } finally { setConsumableLoading(false); } + }; + fetch(); + }, [selectedEquip?.equipment_code]); + + // 새로고침 헬퍼 + const refreshRight = () => { + const eid = selectedEquipId; + setSelectedEquipId(null); + setTimeout(() => setSelectedEquipId(eid), 50); + }; + + // 설비 등록/수정 + const openEquipRegister = () => { setEquipForm({}); setEquipEditMode(false); setEquipModalOpen(true); }; + const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); }; + + const handleEquipSave = async () => { + if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; } + setSaving(true); + try { + const { id, created_date, updated_date, writer, company_code, ...fields } = equipForm; + if (equipEditMode && id) { + await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields }); + toast.success("수정되었습니다."); + } else { + await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, fields); + toast.success("등록되었습니다."); + } + setEquipModalOpen(false); fetchEquipments(); + } catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); } + }; + + const handleEquipDelete = async () => { + if (!selectedEquipId) return; + const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] }); + toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments(); + } catch { toast.error("삭제 실패"); } + }; + + // 점검항목 추가 + const handleInspectionSave = async () => { + if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; } + setSaving(true); + try { + await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { + ...inspectionForm, equipment_code: selectedEquip?.equipment_code, + }); + toast.success("추가되었습니다."); setInspectionModalOpen(false); refreshRight(); + } catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); } + }; + + // 소모품 추가 + // 소모품 품목 로드 (item_info에서 type 또는 division 라벨이 "소모품"인 것) + const loadConsumableItems = async () => { + try { + const flatten = (vals: any[]): any[] => { + const r: any[] = []; + for (const v of vals) { r.push(v); if (v.children?.length) r.push(...flatten(v.children)); } + return r; + }; + + // type과 division 카테고리 모두에서 "소모품" 코드 찾기 + const [typeRes, divRes] = await Promise.all([ + apiClient.get(`/table-categories/item_info/type/values`), + apiClient.get(`/table-categories/item_info/division/values`), + ]); + const consumableType = flatten(typeRes.data?.data || []).find((t: any) => t.valueLabel === "소모품"); + const consumableDiv = flatten(divRes.data?.data || []).find((t: any) => t.valueLabel === "소모품"); + + if (!consumableType && !consumableDiv) { setConsumableItemOptions([]); return; } + + // 두 필터 결과를 합산 (중복 제거) + const filters: any[] = []; + if (consumableType) filters.push({ columnName: "type", operator: "equals", value: consumableType.valueCode }); + if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode }); + + const results = await Promise.all(filters.map((f) => + apiClient.post(`/table-management/tables/item_info/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [f] }, + autoFilter: true, + }) + )); + + const allItems = new Map(); + for (const res of results) { + const rows = res.data?.data?.data || res.data?.data?.rows || []; + for (const row of rows) allItems.set(row.id, row); + } + setConsumableItemOptions(Array.from(allItems.values())); + } catch { setConsumableItemOptions([]); } + }; + + const handleConsumableSave = async () => { + if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; } + setSaving(true); + try { + await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, { + ...consumableForm, equipment_code: selectedEquip?.equipment_code, + }); + toast.success("추가되었습니다."); setConsumableModalOpen(false); refreshRight(); + } catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); } + }; + + // 점검항목 복사: 소스 설비 선택 시 점검항목 로드 + const loadCopyItems = async (equipCode: string) => { + setCopySourceEquip(equipCode); + setCopyChecked(new Set()); + if (!equipCode) { setCopyItems([]); return; } + setCopyLoading(true); + try { + const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, { + page: 1, size: 500, + dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] }, + autoFilter: true, + }); + setCopyItems(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setCopyItems([]); } finally { setCopyLoading(false); } + }; + + const handleCopyApply = async () => { + const selected = copyItems.filter((i) => copyChecked.has(i.id)); + if (selected.length === 0) { toast.error("복사할 항목을 선택해주세요."); return; } + setSaving(true); + try { + for (const item of selected) { + const { id, created_date, updated_date, writer, company_code, equipment_code, ...fields } = item; + await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, { + ...fields, equipment_code: selectedEquip?.equipment_code, + }); + } + toast.success(`${selected.length}개 점검항목이 복사되었습니다.`); + setCopyModalOpen(false); refreshRight(); + } catch { toast.error("복사 실패"); } finally { setSaving(false); } + }; + + // 엑셀 + const handleExcelDownload = async () => { + if (equipments.length === 0) return; + await exportToExcel(equipments.map((e) => ({ + 설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type, + 제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location, + 도입일자: e.introduction_date, 가동상태: e.operation_status, + })), "설비정보.xlsx", "설비"); + toast.success("다운로드 완료"); + }; + + // 셀렉트 렌더링 헬퍼 + const catSelect = (key: string, value: string, onChange: (v: string) => void, placeholder: string) => ( + + ); + + return ( +
+ + + + +
+ } + /> + +
+ + {/* 좌측: 설비 목록 */} + +
+
+
+ 설비 목록 {equipCount}건 +
+
+ + + +
+
+ openEquipEdit()} + emptyMessage="등록된 설비가 없습니다" /> +
+
+ + + + {/* 우측: 탭 */} + +
+
+
+ {([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => ( + + ))} + {selectedEquip && {selectedEquip.equipment_name}} +
+
+ {rightTab === "inspection" && ( + <> + + + + )} + {rightTab === "consumable" && ( + + )} +
+
+ + {!selectedEquipId ? ( +
좌측에서 설비를 선택하세요
+ ) : rightTab === "info" ? ( +
+
+ +
+
+
+ + +
+
+ + setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" /> +
+
+ + {catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")} +
+
+ + setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" /> +
+
+ + setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" /> +
+
+ + setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" /> +
+
+ + setInfoForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" /> +
+
+ + {catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")} +
+
+ + setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" /> +
+
+ + setInfoForm((p) => ({ ...p, image_path: v }))} + tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" /> +
+
+
+ ) : rightTab === "inspection" ? ( + refreshRight()} /> + ) : ( + refreshRight()} /> + )} +
+
+
+
+ + {/* 설비 등록/수정 모달 */} + + }> +
+
+ setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} />
+
+ setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" />
+
+ {catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
+
+ {catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}
+
+ setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" />
+
+ setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" />
+
+ setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" />
+
+ setEquipForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" />
+
+ setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" />
+
+ setEquipForm((p) => ({ ...p, image_path: v }))} + tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" />
+
+
+ + {/* 점검항목 추가 모달 */} + + + 점검항목 추가{selectedEquip?.equipment_name}에 점검항목을 추가합니다. +
+
+ setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" />
+
+
+ {catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}
+
+ {catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}
+
+ setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" />
+
+ setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" />
+
+ setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" />
+
+
+ setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" />
+
+ + +
+
+ + {/* 소모품 추가 모달 */} + + + 소모품 추가{selectedEquip?.equipment_name}에 소모품을 추가합니다. +
+
+ {consumableItemOptions.length > 0 ? ( + + ) : ( +
+ setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))} + placeholder="소모품명 직접 입력" className="h-9" /> +

품목정보에 소모품 타입 품목을 등록하면 선택 가능합니다

+
+ )}
+
+ setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" />
+
+ setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" />
+
+ setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" />
+
+ setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" />
+
+ setConsumableForm((p) => ({ ...p, image_path: v }))} + tableName={CONSUMABLE_TABLE} columnName="image_path" />
+
+ + +
+
+ + {/* 점검항목 복사 모달 */} + + + 점검항목 복사 + 다른 설비의 점검항목을 선택하여 {selectedEquip?.equipment_name}에 복사합니다. +
+
+ + +
+
+ {copyLoading ? ( +
+ ) : copyItems.length === 0 ? ( +
{copySourceEquip ? "점검항목이 없습니다" : "설비를 선택하세요"}
+ ) : ( + + + + + 0 && copyChecked.size === copyItems.length} + onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} /> + + 점검항목점검주기 + 점검방법하한 + 상한단위 + + + + {copyItems.map((item) => ( + setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}> + + {item.inspection_item} + {resolve("inspection_cycle", item.inspection_cycle)} + {resolve("inspection_method", item.inspection_method)} + {item.lower_limit || "-"} + {item.upper_limit || "-"} + {item.unit || "-"} + + ))} + +
+ )} +
+
+ +
+ {copyChecked.size}개 선택됨 +
+ + +
+
+
+
+
+ + {/* 엑셀 업로드 (멀티테이블) */} + {excelChainConfig && ( + { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }} + config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} /> + )} + + + + {ConfirmDialogComponent} + + ); +} diff --git a/frontend/app/(main)/logistics/outbound/page.tsx b/frontend/app/(main)/logistics/outbound/page.tsx new file mode 100644 index 00000000..57ea6455 --- /dev/null +++ b/frontend/app/(main)/logistics/outbound/page.tsx @@ -0,0 +1,1195 @@ +"use client"; + +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 { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { + Search, + Plus, + Trash2, + RotateCcw, + Loader2, + PackageOpen, + X, + Save, + ChevronRight, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { + getOutboundList, + createOutbound, + deleteOutbound, + generateOutboundNumber, + getOutboundWarehouses, + getShipmentInstructionSources, + getPurchaseOrderSources, + getItemSources, + type OutboundItem, + type ShipmentInstructionSource, + type PurchaseOrderSource, + type ItemSource, + type WarehouseOption, +} from "@/lib/api/outbound"; + +// 출고유형 옵션 +const OUTBOUND_TYPES = [ + { value: "판매출고", label: "판매출고", color: "bg-blue-100 text-blue-800" }, + { value: "반품출고", label: "반품출고", color: "bg-pink-100 text-pink-800" }, + { value: "기타출고", label: "기타출고", color: "bg-gray-100 text-gray-800" }, +]; + +const OUTBOUND_STATUS_OPTIONS = [ + { value: "대기", label: "대기", color: "bg-amber-100 text-amber-800" }, + { value: "출고완료", label: "출고완료", color: "bg-emerald-100 text-emerald-800" }, + { value: "부분출고", label: "부분출고", color: "bg-amber-100 text-amber-800" }, + { value: "출고취소", label: "출고취소", color: "bg-red-100 text-red-800" }, +]; + +const getTypeColor = (type: string) => OUTBOUND_TYPES.find((t) => t.value === type)?.color || "bg-gray-100 text-gray-800"; +const getStatusColor = (status: string) => OUTBOUND_STATUS_OPTIONS.find((s) => s.value === status)?.color || "bg-gray-100 text-gray-800"; + +// 소스 테이블 한글명 매핑 +const SOURCE_TYPE_LABEL: Record = { + shipment_instruction_detail: "출하지시", + purchase_order_mng: "발주", + item_info: "품목", +}; + +// 선택된 소스 아이템 (등록 모달에서 사용) +interface SelectedSourceItem { + key: string; + outbound_type: string; + reference_number: string; + customer_code: string; + customer_name: string; + item_number: string; + item_name: string; + spec: string; + material: string; + unit: string; + outbound_qty: number; + unit_price: number; + total_amount: number; + source_type: string; + source_id: string; +} + +export default function OutboundPage() { + // 목록 데이터 + const [data, setData] = useState([]); + const [loading, setLoading] = useState(false); + const [checkedIds, setCheckedIds] = useState([]); + + // 검색 필터 + const [searchType, setSearchType] = useState("all"); + const [searchStatus, setSearchStatus] = useState("all"); + const [searchKeyword, setSearchKeyword] = useState(""); + const [searchDateFrom, setSearchDateFrom] = useState(""); + const [searchDateTo, setSearchDateTo] = useState(""); + + // 등록 모달 + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalOutboundType, setModalOutboundType] = useState("판매출고"); + const [modalOutboundNo, setModalOutboundNo] = useState(""); + const [modalOutboundDate, setModalOutboundDate] = useState(""); + const [modalWarehouse, setModalWarehouse] = useState(""); + const [modalLocation, setModalLocation] = useState(""); + const [modalManager, setModalManager] = useState(""); + const [modalMemo, setModalMemo] = useState(""); + const [selectedItems, setSelectedItems] = useState([]); + const [saving, setSaving] = useState(false); + + // 소스 데이터 + const [sourceKeyword, setSourceKeyword] = useState(""); + const [sourceLoading, setSourceLoading] = useState(false); + const [shipmentInstructions, setShipmentInstructions] = useState([]); + const [purchaseOrders, setPurchaseOrders] = useState([]); + const [items, setItems] = useState([]); + const [warehouses, setWarehouses] = useState([]); + + // 날짜 초기화 + useEffect(() => { + const today = new Date(); + const threeMonthsAgo = new Date(today); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); + setSearchDateTo(today.toISOString().split("T")[0]); + }, []); + + // 목록 조회 + const fetchList = useCallback(async () => { + setLoading(true); + try { + const res = await getOutboundList({ + outbound_type: searchType !== "all" ? searchType : undefined, + outbound_status: searchStatus !== "all" ? searchStatus : undefined, + search_keyword: searchKeyword || undefined, + date_from: searchDateFrom || undefined, + date_to: searchDateTo || undefined, + }); + if (res.success) setData(res.data); + } catch { + // ignore + } finally { + setLoading(false); + } + }, [searchType, searchStatus, searchKeyword, searchDateFrom, searchDateTo]); + + useEffect(() => { + fetchList(); + }, [fetchList]); + + // 창고 목록 로드 + useEffect(() => { + (async () => { + try { + const res = await getOutboundWarehouses(); + if (res.success) setWarehouses(res.data); + } catch { + // ignore + } + })(); + }, []); + + // 검색 초기화 + const handleReset = () => { + setSearchType("all"); + setSearchStatus("all"); + setSearchKeyword(""); + const today = new Date(); + const threeMonthsAgo = new Date(today); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]); + setSearchDateTo(today.toISOString().split("T")[0]); + }; + + // 체크박스 + const allChecked = data.length > 0 && checkedIds.length === data.length; + const toggleCheckAll = () => { + setCheckedIds(allChecked ? [] : data.map((d) => d.id)); + }; + const toggleCheck = (id: string) => { + setCheckedIds((prev) => + prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id] + ); + }; + + // 삭제 + const handleDelete = async () => { + if (checkedIds.length === 0) return; + if (!confirm(`선택한 ${checkedIds.length}건을 삭제하시겠습니까?`)) return; + for (const id of checkedIds) { + await deleteOutbound(id); + } + setCheckedIds([]); + fetchList(); + }; + + // --- 등록 모달 --- + + const loadSourceData = useCallback( + async (type: string, keyword?: string) => { + setSourceLoading(true); + try { + if (type === "판매출고") { + const res = await getShipmentInstructionSources(keyword || undefined); + if (res.success) setShipmentInstructions(res.data); + } else if (type === "반품출고") { + const res = await getPurchaseOrderSources(keyword || undefined); + if (res.success) setPurchaseOrders(res.data); + } else { + const res = await getItemSources(keyword || undefined); + if (res.success) setItems(res.data); + } + } catch { + // ignore + } finally { + setSourceLoading(false); + } + }, + [] + ); + + const openRegisterModal = async () => { + const defaultType = "판매출고"; + setModalOutboundType(defaultType); + setModalOutboundDate(new Date().toISOString().split("T")[0]); + setModalWarehouse(""); + setModalLocation(""); + setModalManager(""); + setModalMemo(""); + setSelectedItems([]); + setSourceKeyword(""); + setShipmentInstructions([]); + setPurchaseOrders([]); + setItems([]); + setIsModalOpen(true); + + try { + const [numRes] = await Promise.all([ + generateOutboundNumber(), + loadSourceData(defaultType), + ]); + if (numRes.success) setModalOutboundNo(numRes.data); + } catch { + setModalOutboundNo(""); + } + }; + + const searchSourceData = useCallback(async () => { + await loadSourceData(modalOutboundType, sourceKeyword || undefined); + }, [modalOutboundType, sourceKeyword, loadSourceData]); + + const handleOutboundTypeChange = useCallback( + (type: string) => { + setModalOutboundType(type); + setSourceKeyword(""); + setShipmentInstructions([]); + setPurchaseOrders([]); + setItems([]); + setSelectedItems([]); + loadSourceData(type); + }, + [loadSourceData] + ); + + // 출하지시 품목 추가 (판매출고) + const addShipmentInstruction = (si: ShipmentInstructionSource) => { + const key = `si-${si.detail_id}`; + if (selectedItems.some((s) => s.key === key)) return; + setSelectedItems((prev) => [ + ...prev, + { + key, + outbound_type: "판매출고", + reference_number: si.instruction_no, + customer_code: si.partner_id, + customer_name: si.partner_id, + item_number: si.item_code, + item_name: si.item_name, + spec: si.spec || "", + material: si.material || "", + unit: "EA", + outbound_qty: si.remain_qty, + unit_price: 0, + total_amount: 0, + source_type: "shipment_instruction_detail", + source_id: String(si.detail_id), + }, + ]); + }; + + // 발주 품목 추가 (반품출고) + const addPurchaseOrder = (po: PurchaseOrderSource) => { + const key = `po-${po.id}`; + if (selectedItems.some((s) => s.key === key)) return; + setSelectedItems((prev) => [ + ...prev, + { + key, + outbound_type: "반품출고", + reference_number: po.purchase_no, + customer_code: po.supplier_code, + customer_name: po.supplier_name, + item_number: po.item_code, + item_name: po.item_name, + spec: po.spec || "", + material: po.material || "", + unit: "EA", + outbound_qty: po.received_qty, + unit_price: po.unit_price, + total_amount: po.received_qty * po.unit_price, + source_type: "purchase_order_mng", + source_id: po.id, + }, + ]); + }; + + // 품목 추가 (기타출고) + const addItem = (item: ItemSource) => { + const key = `item-${item.id}`; + if (selectedItems.some((s) => s.key === key)) return; + setSelectedItems((prev) => [ + ...prev, + { + key, + outbound_type: "기타출고", + reference_number: item.item_number, + customer_code: "", + customer_name: "", + item_number: item.item_number, + item_name: item.item_name, + spec: item.spec || "", + material: item.material || "", + unit: item.unit || "EA", + outbound_qty: 0, + unit_price: item.standard_price, + total_amount: 0, + source_type: "item_info", + source_id: item.id, + }, + ]); + }; + + // 선택 품목 수량 변경 + const updateItemQty = (key: string, qty: number) => { + setSelectedItems((prev) => + prev.map((item) => + item.key === key + ? { ...item, outbound_qty: qty, total_amount: qty * item.unit_price } + : item + ) + ); + }; + + // 선택 품목 단가 변경 + const updateItemPrice = (key: string, price: number) => { + setSelectedItems((prev) => + prev.map((item) => + item.key === key + ? { ...item, unit_price: price, total_amount: item.outbound_qty * price } + : item + ) + ); + }; + + // 선택 품목 삭제 + const removeItem = (key: string) => { + setSelectedItems((prev) => prev.filter((item) => item.key !== key)); + }; + + // 저장 + const handleSave = async () => { + if (selectedItems.length === 0) { + alert("출고할 품목을 선택해주세요."); + return; + } + if (!modalOutboundDate) { + alert("출고일을 입력해주세요."); + return; + } + + const zeroQtyItems = selectedItems.filter((i) => !i.outbound_qty || i.outbound_qty <= 0); + if (zeroQtyItems.length > 0) { + alert("출고수량이 0인 품목이 있습니다. 수량을 입력해주세요."); + return; + } + + setSaving(true); + try { + const res = await createOutbound({ + outbound_number: modalOutboundNo, + outbound_date: modalOutboundDate, + warehouse_code: modalWarehouse || undefined, + location_code: modalLocation || undefined, + manager_id: modalManager || undefined, + memo: modalMemo || undefined, + items: selectedItems.map((item) => ({ + outbound_type: item.outbound_type, + reference_number: item.reference_number, + customer_code: item.customer_code, + customer_name: item.customer_name, + item_code: item.item_number, + item_name: item.item_name, + spec: item.spec, + material: item.material, + unit: item.unit, + outbound_qty: item.outbound_qty, + unit_price: item.unit_price, + total_amount: item.total_amount, + source_type: item.source_type, + source_id: item.source_id, + outbound_status: "출고완료", + })), + }); + + if (res.success) { + alert(res.message || "출고 등록 완료"); + setIsModalOpen(false); + fetchList(); + } + } catch { + alert("출고 등록 중 오류가 발생했습니다."); + } finally { + setSaving(false); + } + }; + + // 합계 계산 + const totalSummary = useMemo(() => { + return { + count: selectedItems.length, + qty: selectedItems.reduce((sum, i) => sum + (i.outbound_qty || 0), 0), + amount: selectedItems.reduce((sum, i) => sum + (i.total_amount || 0), 0), + }; + }, [selectedItems]); + + return ( +
+ {/* 검색 영역 */} +
+ + + + + setSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && fetchList()} + className="h-9 w-[240px] text-xs" + /> + +
+ setSearchDateFrom(e.target.value)} + className="h-9 w-[140px] text-xs" + /> + ~ + setSearchDateTo(e.target.value)} + className="h-9 w-[140px] text-xs" + /> +
+ + + + +
+ + +
+
+ + {/* 출고 목록 테이블 */} +
+
+
+ +

출고 목록

+ + 총 {data.length}건 + +
+
+ +
+ + + + + + + 출고번호 + 출고유형 + 출고일 + 참조번호 + 데이터출처 + 거래처 + 품목코드 + 품목명 + 규격 + 출고수량 + 단가 + 금액 + 창고 + 출고상태 + 비고 + + + + {loading ? ( + + + + + + ) : data.length === 0 ? ( + + +
+ +

등록된 출고 내역이 없습니다

+

+ '출고 등록' 버튼을 클릭하여 출고를 추가하세요 +

+
+
+
+ ) : ( + data.map((row) => ( + toggleCheck(row.id)} + > + e.stopPropagation()} + > + toggleCheck(row.id)} + /> + + + {row.outbound_number} + + + + {row.outbound_type || "-"} + + + + {row.outbound_date + ? new Date(row.outbound_date).toLocaleDateString("ko-KR") + : "-"} + + + {row.reference_number || "-"} + + + {row.source_type + ? SOURCE_TYPE_LABEL[row.source_type] || row.source_type + : "-"} + + + {row.customer_name || "-"} + + + {row.item_code || "-"} + + {row.item_name || "-"} + {row.specification || "-"} + + {Number(row.outbound_qty || 0).toLocaleString()} + + + {Number(row.unit_price || 0).toLocaleString()} + + + {Number(row.total_amount || 0).toLocaleString()} + + + {row.warehouse_name || row.warehouse_code || "-"} + + + + {row.outbound_status || "-"} + + + + {row.memo || "-"} + + + )) + )} +
+
+
+
+ + {/* 출고 등록 모달 */} + +
+ {selectedItems.length > 0 ? ( + <> + {totalSummary.count}건 | 수량 합계:{" "} + {totalSummary.qty.toLocaleString()} | 금액 합계:{" "} + {totalSummary.amount.toLocaleString()}원 + + ) : ( + "품목을 추가해주세요" + )} +
+
+ + +
+
+ } + > + + {/* 출고유형 선택 */} +
+ + + + {modalOutboundType === "판매출고" + ? "출하지시 데이터에서 출고 처리합니다." + : modalOutboundType === "반품출고" + ? "발주(입고) 데이터에서 반품 출고 처리합니다." + : "품목 데이터를 직접 선택하여 출고 처리합니다."} + +
+ + {/* 메인 콘텐츠 */} +
+ + {/* 좌측: 소스 데이터 */} + +
+
+ setSourceKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchSourceData()} + className="h-8 flex-1 text-xs" + /> + +
+ +
+

+ {modalOutboundType === "판매출고" + ? "미출고 출하지시 목록" + : modalOutboundType === "반품출고" + ? "입고된 발주 목록" + : "품목 목록"} +

+ + {sourceLoading ? ( +
+ +
+ ) : modalOutboundType === "판매출고" ? ( + s.key)} + /> + ) : modalOutboundType === "반품출고" ? ( + s.key)} + /> + ) : ( + s.key)} + /> + )} +
+
+
+ + + + {/* 우측: 출고 정보 + 선택 품목 */} + +
+
+

출고 정보

+
+
+ + +
+
+ + setModalOutboundDate(e.target.value)} + className="h-8 text-xs" + /> +
+
+ + +
+
+ + setModalLocation(e.target.value)} + placeholder="위치 입력" + className="h-8 text-xs" + /> +
+
+ + setModalManager(e.target.value)} + placeholder="담당자" + className="h-8 text-xs" + /> +
+
+ + setModalMemo(e.target.value)} + placeholder="메모" + className="h-8 text-xs" + /> +
+
+
+ +
+

+ 출고 처리 품목 ({selectedItems.length}건) +

+ + {selectedItems.length === 0 ? ( +
+ + 좌측에서 품목을 선택하여 추가해주세요 +
+ ) : ( + + + + No + 품목명 + 참조번호 + 수량 + 단가 + 금액 + + + + + {selectedItems.map((item, idx) => ( + + {idx + 1} + +
+ + {item.item_name} + + + {item.item_number} + {item.spec ? ` | ${item.spec}` : ""} + +
+
+ {item.reference_number} + + updateItemQty(item.key, Number(e.target.value) || 0)} + className="h-7 w-[70px] text-right text-xs" + min={0} + /> + + + updateItemPrice(item.key, Number(e.target.value) || 0)} + className="h-7 w-[70px] text-right text-xs" + min={0} + /> + + + {item.total_amount.toLocaleString()} + + + + +
+ ))} +
+
+ )} +
+
+
+
+
+ + + + ); +} + +// --- 소스 데이터 테이블 컴포넌트들 --- + +function SourceShipmentInstructionTable({ + data, + onAdd, + selectedKeys, +}: { + data: ShipmentInstructionSource[]; + onAdd: (si: ShipmentInstructionSource) => void; + selectedKeys: string[]; +}) { + if (data.length === 0) { + return ( +
+ 검색 버튼을 눌러 출하지시 데이터를 조회하세요 +
+ ); + } + + return ( + + + + + 출하지시번호 + 출하일 + 품목 + 계획수량 + 출고수량 + 미출고 + + + + {data.map((si) => { + const isSelected = selectedKeys.includes(`si-${si.detail_id}`); + return ( + !isSelected && onAdd(si)} + > + + {isSelected ? ( + 추가됨 + ) : ( + + )} + + {si.instruction_no} + + {si.instruction_date + ? new Date(si.instruction_date).toLocaleDateString("ko-KR") + : "-"} + + +
+ {si.item_name} + + {si.item_code} + {si.spec ? ` | ${si.spec}` : ""} + +
+
+ + {Number(si.plan_qty).toLocaleString()} + + + {Number(si.ship_qty).toLocaleString()} + + + {Number(si.remain_qty).toLocaleString()} + +
+ ); + })} +
+
+ ); +} + +function SourcePurchaseOrderTable({ + data, + onAdd, + selectedKeys, +}: { + data: PurchaseOrderSource[]; + onAdd: (po: PurchaseOrderSource) => void; + selectedKeys: string[]; +}) { + if (data.length === 0) { + return ( +
+ 검색 버튼을 눌러 발주 데이터를 조회하세요 +
+ ); + } + + return ( + + + + + 발주번호 + 공급처 + 품목 + 발주수량 + 입고수량 + + + + {data.map((po) => { + const isSelected = selectedKeys.includes(`po-${po.id}`); + return ( + !isSelected && onAdd(po)} + > + + {isSelected ? ( + 추가됨 + ) : ( + + )} + + {po.purchase_no} + {po.supplier_name} + +
+ {po.item_name} + + {po.item_code} + {po.spec ? ` | ${po.spec}` : ""} + +
+
+ + {Number(po.order_qty).toLocaleString()} + + + {Number(po.received_qty).toLocaleString()} + +
+ ); + })} +
+
+ ); +} + +function SourceItemTable({ + data, + onAdd, + selectedKeys, +}: { + data: ItemSource[]; + onAdd: (item: ItemSource) => void; + selectedKeys: string[]; +}) { + if (data.length === 0) { + return ( +
+ 검색 버튼을 눌러 품목 데이터를 조회하세요 +
+ ); + } + + return ( + + + + + 품목 + 규격 + 재질 + 단위 + 기준가 + + + + {data.map((item) => { + const isSelected = selectedKeys.includes(`item-${item.id}`); + return ( + !isSelected && onAdd(item)} + > + + {isSelected ? ( + 추가됨 + ) : ( + + )} + + +
+ {item.item_name} + + {item.item_number} + +
+
+ {item.spec || "-"} + {item.material || "-"} + {item.unit || "-"} + + {Number(item.standard_price).toLocaleString()} + +
+ ); + })} +
+
+ ); +} diff --git a/frontend/app/(main)/logistics/packaging/page.tsx b/frontend/app/(main)/logistics/packaging/page.tsx new file mode 100644 index 00000000..79059277 --- /dev/null +++ b/frontend/app/(main)/logistics/packaging/page.tsx @@ -0,0 +1,926 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { + ResizableHandle, ResizablePanel, ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandInput, CommandList, CommandEmpty, CommandItem } from "@/components/ui/command"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { + Search, Plus, Trash2, RotateCcw, Loader2, Package, Box, X, Save, Edit2, Download, ChevronsUpDown, Check, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { + getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit, + getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem, + getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit, + getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg, + getItemsByDivision, getGeneralItems, + type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg, +} from "@/lib/api/packaging"; + +// --- 코드 → 라벨 매핑 --- +const PKG_TYPE_LABEL: Record = { + BOX: "박스", PACK: "팩", CANBOARD: "캔보드", AIRCAP: "에어캡", + ZIPCOS: "집코스", CYLINDER: "원통형", POLYCARTON: "포리/카톤", +}; +const LOADING_TYPE_LABEL: Record = { + PALLET: "파렛트", WOOD_PALLET: "목재파렛트", PLASTIC_PALLET: "플라스틱파렛트", + ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함", + CAGE: "케이지", ETC: "기타", +}; +const STATUS_LABEL: Record = { ACTIVE: "사용", INACTIVE: "미사용" }; + +const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-emerald-100 text-emerald-800" : "bg-gray-100 text-gray-600"; +const fmtSize = (w: any, l: any, h: any) => { + const vals = [w, l, h].map(v => Number(v) || 0); + return vals.some(v => v > 0) ? vals.join("×") : "-"; +}; + +// 규격 문자열에서 치수 파싱 +function parseSpecDimensions(spec: string | null) { + if (!spec) return { w: 0, l: 0, h: 0 }; + const m3 = spec.match(/(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)/i); + if (m3) return { w: parseInt(m3[1]), l: parseInt(m3[2]), h: parseInt(m3[3]) }; + const m2 = spec.match(/(\d+)\s*[x×]\s*(\d+)/i); + if (m2) return { w: parseInt(m2[1]), l: parseInt(m2[2]), h: 0 }; + return { w: 0, l: 0, h: 0 }; +} + +export default function PackagingPage() { + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + const [activeTab, setActiveTab] = useState<"packing" | "loading">("packing"); + + // 검색 + const [searchKeyword, setSearchKeyword] = useState(""); + + // 포장재 데이터 + const [pkgUnits, setPkgUnits] = useState([]); + const [pkgLoading, setPkgLoading] = useState(false); + const [selectedPkg, setSelectedPkg] = useState(null); + const [pkgItems, setPkgItems] = useState([]); + const [pkgItemsLoading, setPkgItemsLoading] = useState(false); + + // 적재함 데이터 + const [loadingUnits, setLoadingUnits] = useState([]); + const [loadingLoading, setLoadingLoading] = useState(false); + const [selectedLoading, setSelectedLoading] = useState(null); + const [loadingPkgs, setLoadingPkgs] = useState([]); + const [loadingPkgsLoading, setLoadingPkgsLoading] = useState(false); + + // 모달 + const [pkgModalOpen, setPkgModalOpen] = useState(false); + const [pkgModalMode, setPkgModalMode] = useState<"create" | "edit">("create"); + const [pkgForm, setPkgForm] = useState>({}); + const [pkgItemOptions, setPkgItemOptions] = useState([]); + const [pkgItemPopoverOpen, setPkgItemPopoverOpen] = useState(false); + + const [loadModalOpen, setLoadModalOpen] = useState(false); + const [loadModalMode, setLoadModalMode] = useState<"create" | "edit">("create"); + const [loadForm, setLoadForm] = useState>({}); + const [loadItemOptions, setLoadItemOptions] = useState([]); + const [loadItemPopoverOpen, setLoadItemPopoverOpen] = useState(false); + + const [itemMatchModalOpen, setItemMatchModalOpen] = useState(false); + const [itemMatchKeyword, setItemMatchKeyword] = useState(""); + const [itemMatchResults, setItemMatchResults] = useState([]); + const [itemMatchSelected, setItemMatchSelected] = useState(null); + const [itemMatchQty, setItemMatchQty] = useState(1); + + const [pkgMatchModalOpen, setPkgMatchModalOpen] = useState(false); + const [pkgMatchQty, setPkgMatchQty] = useState(1); + const [pkgMatchMethod, setPkgMatchMethod] = useState(""); + const [pkgMatchSelected, setPkgMatchSelected] = useState(null); + const [pkgMatchSearchKw, setPkgMatchSearchKw] = useState(""); + + const [saving, setSaving] = useState(false); + + // --- 데이터 로드 --- + const fetchPkgUnits = useCallback(async () => { + setPkgLoading(true); + try { + const res = await getPkgUnits(); + if (res.success) setPkgUnits(res.data); + } catch { /* ignore */ } finally { setPkgLoading(false); } + }, []); + + const fetchLoadingUnits = useCallback(async () => { + setLoadingLoading(true); + try { + const res = await getLoadingUnits(); + if (res.success) setLoadingUnits(res.data); + } catch { /* ignore */ } finally { setLoadingLoading(false); } + }, []); + + useEffect(() => { fetchPkgUnits(); fetchLoadingUnits(); }, [fetchPkgUnits, fetchLoadingUnits]); + + // 포장재 선택 시 매칭 품목 로드 + const selectPkg = useCallback(async (pkg: PkgUnit) => { + setSelectedPkg(pkg); + setPkgItemsLoading(true); + try { + const res = await getPkgUnitItems(pkg.pkg_code); + if (res.success) setPkgItems(res.data); + } catch { setPkgItems([]); } finally { setPkgItemsLoading(false); } + }, []); + + // 적재함 선택 시 포장구성 로드 + const selectLoading = useCallback(async (lu: LoadingUnit) => { + setSelectedLoading(lu); + setLoadingPkgsLoading(true); + try { + const res = await getLoadingUnitPkgs(lu.loading_code); + if (res.success) setLoadingPkgs(res.data); + } catch { setLoadingPkgs([]); } finally { setLoadingPkgsLoading(false); } + }, []); + + // 검색 필터 적용 + const filteredPkgUnits = pkgUnits.filter((p) => { + if (!searchKeyword) return true; + const kw = searchKeyword.toLowerCase(); + return (p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw)); + }); + + const filteredLoadingUnits = loadingUnits.filter((l) => { + if (!searchKeyword) return true; + const kw = searchKeyword.toLowerCase(); + return (l.loading_code?.toLowerCase().includes(kw) || l.loading_name?.toLowerCase().includes(kw)); + }); + + // --- 포장재 등록/수정 모달 --- + const openPkgModal = async (mode: "create" | "edit") => { + setPkgModalMode(mode); + if (mode === "edit" && selectedPkg) { + setPkgForm({ ...selectedPkg }); + } else { + setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" }); + } + setPkgItemPopoverOpen(false); + try { + const res = await getItemsByDivision("포장재"); + if (res.success) setPkgItemOptions(res.data); + } catch { setPkgItemOptions([]); } + setPkgModalOpen(true); + }; + + const onPkgItemSelect = (item: ItemInfoForPkg) => { + setPkgItemPopoverOpen(false); + const dims = parseSpecDimensions(item.size); + setPkgForm((prev) => ({ + ...prev, + pkg_code: item.item_number, + pkg_name: item.item_name, + width_mm: dims.w || prev.width_mm, + length_mm: dims.l || prev.length_mm, + height_mm: dims.h || prev.height_mm, + })); + }; + + const savePkgUnit = async () => { + if (!pkgForm.pkg_code || !pkgForm.pkg_name) { toast.error("포장코드와 포장명은 필수입니다."); return; } + if (!pkgForm.pkg_type) { toast.error("포장유형을 선택해주세요."); return; } + setSaving(true); + try { + if (pkgModalMode === "create") { + const res = await createPkgUnit(pkgForm); + if (res.success) { toast.success("포장재 등록 완료"); setPkgModalOpen(false); fetchPkgUnits(); } + } else { + const res = await updatePkgUnit(pkgForm.id, pkgForm); + if (res.success) { toast.success("포장재 수정 완료"); setPkgModalOpen(false); fetchPkgUnits(); setSelectedPkg(res.data); } + } + } catch { toast.error("저장 실패"); } finally { setSaving(false); } + }; + + const handleDeletePkg = async (pkg: PkgUnit) => { + const ok = await confirm(`"${pkg.pkg_name}" 포장재를 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await deletePkgUnit(pkg.id); + toast.success("삭제 완료"); + setSelectedPkg(null); setPkgItems([]); + fetchPkgUnits(); + } catch { toast.error("삭제 실패"); } + }; + + // --- 적재함 등록/수정 모달 --- + const openLoadModal = async (mode: "create" | "edit") => { + setLoadModalMode(mode); + if (mode === "edit" && selectedLoading) { + setLoadForm({ ...selectedLoading }); + } else { + setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" }); + } + setLoadItemPopoverOpen(false); + try { + const res = await getItemsByDivision("적재함"); + if (res.success) setLoadItemOptions(res.data); + } catch { setLoadItemOptions([]); } + setLoadModalOpen(true); + }; + + const onLoadItemSelect = (item: ItemInfoForPkg) => { + setLoadItemPopoverOpen(false); + const dims = parseSpecDimensions(item.size); + setLoadForm((prev) => ({ + ...prev, + loading_code: item.item_number, + loading_name: item.item_name, + width_mm: dims.w || prev.width_mm, + length_mm: dims.l || prev.length_mm, + height_mm: dims.h || prev.height_mm, + })); + }; + + const saveLoadingUnit = async () => { + if (!loadForm.loading_code || !loadForm.loading_name) { toast.error("적재함코드와 적재함명은 필수입니다."); return; } + if (!loadForm.loading_type) { toast.error("적재유형을 선택해주세요."); return; } + setSaving(true); + try { + if (loadModalMode === "create") { + const res = await createLoadingUnit(loadForm); + if (res.success) { toast.success("적재함 등록 완료"); setLoadModalOpen(false); fetchLoadingUnits(); } + } else { + const res = await updateLoadingUnit(loadForm.id, loadForm); + if (res.success) { toast.success("적재함 수정 완료"); setLoadModalOpen(false); fetchLoadingUnits(); setSelectedLoading(res.data); } + } + } catch { toast.error("저장 실패"); } finally { setSaving(false); } + }; + + const handleDeleteLoading = async (lu: LoadingUnit) => { + const ok = await confirm(`"${lu.loading_name}" 적재함을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await deleteLoadingUnit(lu.id); + toast.success("삭제 완료"); + setSelectedLoading(null); setLoadingPkgs([]); + fetchLoadingUnits(); + } catch { toast.error("삭제 실패"); } + }; + + // --- 품목 추가 모달 (포장재 매칭) --- + const openItemMatchModal = async () => { + setItemMatchKeyword(""); setItemMatchSelected(null); setItemMatchQty(1); + setItemMatchModalOpen(true); + try { + const res = await getGeneralItems(); + if (res.success) setItemMatchResults(res.data); + } catch { setItemMatchResults([]); } + }; + + const searchItemsForMatch = async () => { + try { + const res = await getGeneralItems(itemMatchKeyword || undefined); + if (res.success) setItemMatchResults(res.data); + } catch { setItemMatchResults([]); } + }; + + const saveItemMatch = async () => { + if (!selectedPkg || !itemMatchSelected) { toast.error("품목을 선택해주세요."); return; } + if (itemMatchQty <= 0) { toast.error("포장수량을 입력해주세요."); return; } + setSaving(true); + try { + const res = await createPkgUnitItem({ + pkg_code: selectedPkg.pkg_code, + item_number: itemMatchSelected.item_number, + pkg_qty: itemMatchQty, + }); + if (res.success) { toast.success("품목 추가 완료"); setItemMatchModalOpen(false); selectPkg(selectedPkg); } + } catch { toast.error("추가 실패"); } finally { setSaving(false); } + }; + + const handleDeletePkgItem = async (item: PkgUnitItem) => { + const ok = await confirm("매칭 품목을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await deletePkgUnitItem(item.id); + toast.success("삭제 완료"); + if (selectedPkg) selectPkg(selectedPkg); + } catch { toast.error("삭제 실패"); } + }; + + // --- 포장단위 추가 모달 (적재함 구성) --- + const openPkgMatchModal = () => { + setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod(""); setPkgMatchSearchKw(""); + setPkgMatchModalOpen(true); + }; + + const savePkgMatch = async () => { + if (!selectedLoading || !pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; } + if (pkgMatchQty <= 0) { toast.error("최대적재수량을 입력해주세요."); return; } + setSaving(true); + try { + const res = await createLoadingUnitPkg({ + loading_code: selectedLoading.loading_code, + pkg_code: pkgMatchSelected.pkg_code, + max_load_qty: pkgMatchQty, + load_method: pkgMatchMethod || undefined, + }); + if (res.success) { toast.success("포장단위 추가 완료"); setPkgMatchModalOpen(false); selectLoading(selectedLoading); } + } catch { toast.error("추가 실패"); } finally { setSaving(false); } + }; + + const handleDeleteLoadPkg = async (lp: LoadingUnitPkg) => { + const ok = await confirm("적재 구성을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + await deleteLoadingUnitPkg(lp.id); + toast.success("삭제 완료"); + if (selectedLoading) selectLoading(selectedLoading); + } catch { toast.error("삭제 실패"); } + }; + + return ( +
+ {/* 검색 바 */} +
+ setSearchKeyword(e.target.value)} + className="h-9 w-[280px] text-xs" + /> + +
+ + {/* 탭 */} +
+ {([["packing", "포장재 관리", filteredPkgUnits.length] as const, ["loading", "적재함 관리", filteredLoadingUnits.length] as const]).map(([tab, label, count]) => ( + + ))} +
+ + {/* 탭 콘텐츠 */} +
+ {activeTab === "packing" ? ( + + {/* 좌측: 포장재 목록 */} + +
+
+ 포장재 목록 ({filteredPkgUnits.length}건) +
+ +
+
+
+ + + + 품목코드 + 포장명 + 유형 + 크기(mm) + 최대중량 + 상태 + + + + {pkgLoading ? ( + + ) : filteredPkgUnits.length === 0 ? ( + 등록된 포장재가 없습니다 + ) : filteredPkgUnits.map((p) => ( + selectPkg(p)} + > + {p.pkg_code} + {p.pkg_name} + {PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"} + {fmtSize(p.width_mm, p.length_mm, p.height_mm)} + {Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"} + + {STATUS_LABEL[p.status] || p.status} + + + ))} + +
+
+
+
+ + {/* 우측: 상세 */} + + {!selectedPkg ? ( +
+ +

좌측 목록에서 포장재를 선택하세요

+
+ ) : ( +
+ {/* 요약 헤더 */} +
+
+ +
+
{selectedPkg.pkg_name}
+
{selectedPkg.pkg_code} · {PKG_TYPE_LABEL[selectedPkg.pkg_type] || selectedPkg.pkg_type} · {fmtSize(selectedPkg.width_mm, selectedPkg.length_mm, selectedPkg.height_mm)}mm
+
+
+
+ + +
+
+ {/* 매칭 품목 */} +
+ 매칭 품목 ({pkgItems.length}건) + +
+
+ {pkgItemsLoading ? ( +
+ ) : pkgItems.length === 0 ? ( +
매칭된 품목이 없습니다
+ ) : ( + + + + 품목코드 + 품목명 + 규격 + 단위 + 포장수량 + + + + + {pkgItems.map((item) => ( + + {item.item_number} + {item.item_name || "-"} + {item.spec || "-"} + {item.unit || "EA"} + {Number(item.pkg_qty).toLocaleString()} + + + + + ))} + +
+ )} +
+
+ )} +
+
+ ) : ( + /* 적재함 관리 탭 */ + + +
+
+ 적재함 목록 ({filteredLoadingUnits.length}건) + +
+
+ + + + 품목코드 + 적재함명 + 유형 + 크기(mm) + 최대적재 + 상태 + + + + {loadingLoading ? ( + + ) : filteredLoadingUnits.length === 0 ? ( + 등록된 적재함이 없습니다 + ) : filteredLoadingUnits.map((l) => ( + selectLoading(l)} + > + {l.loading_code} + {l.loading_name} + {LOADING_TYPE_LABEL[l.loading_type] || l.loading_type || "-"} + {fmtSize(l.width_mm, l.length_mm, l.height_mm)} + {Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"} + + {STATUS_LABEL[l.status] || l.status} + + + ))} + +
+
+
+
+ + + {!selectedLoading ? ( +
+ +

좌측 목록에서 적재함을 선택하세요

+
+ ) : ( +
+
+
+ +
+
{selectedLoading.loading_name}
+
{selectedLoading.loading_code} · {LOADING_TYPE_LABEL[selectedLoading.loading_type] || selectedLoading.loading_type} · {fmtSize(selectedLoading.width_mm, selectedLoading.length_mm, selectedLoading.height_mm)}mm
+
+
+
+ + +
+
+
+ 적재 가능 포장단위 ({loadingPkgs.length}건) + +
+
+ {loadingPkgsLoading ? ( +
+ ) : loadingPkgs.length === 0 ? ( +
등록된 포장단위가 없습니다
+ ) : ( + + + + 포장코드 + 포장명 + 유형 + 최대수량 + 적재방향 + + + + + {loadingPkgs.map((lp) => ( + + {lp.pkg_code} + {lp.pkg_name || "-"} + {PKG_TYPE_LABEL[lp.pkg_type || ""] || lp.pkg_type || "-"} + {Number(lp.max_load_qty).toLocaleString()} + {lp.load_method || "-"} + + + + + ))} + +
+ )} +
+
+ )} +
+
+ )} +
+ + {/* 포장재 등록/수정 모달 */} + + + +
+ } + > +
+ {/* 품목정보 연결 */} + {pkgModalMode === "create" && ( +
+ + + + + + + { + const item = pkgItemOptions.find((i) => i.id === value); + if (!item) return 0; + const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase(); + return target.includes(search.toLowerCase()) ? 1 : 0; + }}> + + + 검색 결과가 없습니다 + {pkgItemOptions.map((item) => ( + onPkgItemSelect(item)} className="text-xs"> + + {item.item_name} + {item.item_number} + {item.size && {item.size}} + + ))} + + + + +
+ )} +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
setPkgForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" />
+
setPkgForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" />
+
setPkgForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" />
+
setPkgForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" />
+
setPkgForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" />
+
setPkgForm((p) => ({ ...p, volume_l: e.target.value }))} className="h-8 text-xs" step="0.1" />
+
+
+
setPkgForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" />
+
+ + + {/* 적재함 등록/수정 모달 */} + + + + + } + > +
+ {loadModalMode === "create" && ( +
+ + + + + + + { + const item = loadItemOptions.find((i) => i.id === value); + if (!item) return 0; + const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase(); + return target.includes(search.toLowerCase()) ? 1 : 0; + }}> + + + 검색 결과가 없습니다 + {loadItemOptions.map((item) => ( + onLoadItemSelect(item)} className="text-xs"> + + {item.item_name} + {item.item_number} + {item.size && {item.size}} + + ))} + + + + +
+ )} +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
setLoadForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" />
+
setLoadForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" />
+
setLoadForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" />
+
setLoadForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" />
+
setLoadForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" />
+
setLoadForm((p) => ({ ...p, max_stack: e.target.value }))} className="h-8 text-xs" />
+
+
+
setLoadForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" />
+
+
+ + {/* 품목 추가 모달 (포장재 매칭) */} + + + + 품목 추가 — {selectedPkg?.pkg_name} + 포장재에 매칭할 품목을 검색하여 추가합니다. + +
+ { + setItemMatchKeyword(e.target.value); + const kw = e.target.value; + clearTimeout((window as any).__itemMatchTimer); + (window as any).__itemMatchTimer = setTimeout(async () => { + try { + const res = await getGeneralItems(kw || undefined); + if (res.success) setItemMatchResults(res.data); + } catch { /* ignore */ } + }, 300); + }} + className="h-9 text-xs" /> +
+ + + + + 품목코드 + 품목명 + 규격 + 재질 + 단위 + + + + {itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).length === 0 ? ( + 검색 결과가 없습니다 + ) : itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).map((item) => ( + setItemMatchSelected(item)}> + {itemMatchSelected?.id === item.id ? "✓" : ""} + {item.item_number} + {item.item_name} + {item.spec || "-"} + {item.material || "-"} + {item.unit || "EA"} + + ))} + +
+
+
+
+ + +
+
+ + setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" /> +
+
+
+ + + + +
+
+ + {/* 포장단위 추가 모달 (적재함 구성) */} + + + + 포장단위 추가 — {selectedLoading?.loading_name} + 적재함에 적재할 포장단위를 선택합니다. + +
+ setPkgMatchSearchKw(e.target.value)} + className="h-9 text-xs" + /> +
+ + + + + 포장코드 + 포장명 + 유형 + 크기(mm) + 최대중량 + + + + {(() => { + const kw = pkgMatchSearchKw.toLowerCase(); + const filtered = pkgUnits.filter(p => + p.status === "ACTIVE" + && !loadingPkgs.some(lp => lp.pkg_code === p.pkg_code) + && (!kw || p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw)) + ); + return filtered.length === 0 ? ( + 추가 가능한 포장단위가 없습니다 + ) : filtered.map((p) => ( + setPkgMatchSelected(p)}> + {pkgMatchSelected?.id === p.id ? "✓" : ""} + {p.pkg_code} + {p.pkg_name} + {PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type} + {fmtSize(p.width_mm, p.length_mm, p.height_mm)} + {Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"} + + )); + })()} + +
+
+
+
+ + setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" /> +
+
+ + setPkgMatchMethod(e.target.value)} placeholder="수직/수평/혼합" className="h-9 text-xs" /> +
+
+
+ + + + +
+
+ + {ConfirmDialogComponent} + + ); +} diff --git a/frontend/app/(main)/logistics/receiving/page.tsx b/frontend/app/(main)/logistics/receiving/page.tsx index eba618d5..a5de8c9d 100644 --- a/frontend/app/(main)/logistics/receiving/page.tsx +++ b/frontend/app/(main)/logistics/receiving/page.tsx @@ -18,14 +18,7 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { Badge } from "@/components/ui/badge"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; @@ -693,14 +686,51 @@ export default function ReceivingPage() { {/* 입고 등록 모달 */} - - - - 입고 등록 - - 입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가하세요. - - + +
+ {selectedItems.length > 0 ? ( + <> + {totalSummary.count}건 | 수량 합계:{" "} + {totalSummary.qty.toLocaleString()} | 금액 합계:{" "} + {totalSummary.amount.toLocaleString()}원 + + ) : ( + "품목을 추가해주세요" + )} +
+
+ + +
+ + } + > {/* 입고유형 선택 */}
@@ -974,43 +1004,7 @@ export default function ReceivingPage() {
- {/* 푸터 */} - -
- {selectedItems.length > 0 ? ( - <> - {totalSummary.count}건 | 수량 합계:{" "} - {totalSummary.qty.toLocaleString()} | 금액 합계:{" "} - {totalSummary.amount.toLocaleString()}원 - - ) : ( - "품목을 추가해주세요" - )} -
-
- - -
-
-
-
+ ); } diff --git a/frontend/app/(main)/master-data/department/page.tsx b/frontend/app/(main)/master-data/department/page.tsx new file mode 100644 index 00000000..4e943810 --- /dev/null +++ b/frontend/app/(main)/master-data/department/page.tsx @@ -0,0 +1,498 @@ +"use client"; + +/** + * 부서관리 — 하드코딩 페이지 + * + * 좌측: 부서 목록 (dept_info) + * 우측: 선택한 부서의 인원 목록 (user_info) + * + * 모달: 부서 등록(dept_info), 사원 추가(user_info) + */ + +import React, { useState, useEffect, useCallback } 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 { 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, + Building2, Users, Settings2, +} 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 { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; +import { formatField, validateField, validateForm } from "@/lib/utils/validation"; + +const DEPT_TABLE = "dept_info"; +const USER_TABLE = "user_info"; + +const LEFT_COLUMNS: DataGridColumn[] = [ + { key: "dept_code", label: "부서코드", width: "w-[120px]" }, + { key: "dept_name", label: "부서명", minWidth: "min-w-[150px]" }, + { key: "parent_dept_code", label: "상위부서", width: "w-[100px]" }, + { key: "status", label: "상태", width: "w-[70px]" }, +]; + +const RIGHT_COLUMNS: DataGridColumn[] = [ + { key: "sabun", label: "사번", width: "w-[80px]" }, + { key: "user_name", label: "이름", width: "w-[90px]" }, + { key: "user_id", label: "사용자ID", width: "w-[100px]" }, + { key: "position_name", label: "직급", width: "w-[80px]" }, + { key: "cell_phone", label: "휴대폰", width: "w-[120px]" }, + { key: "email", label: "이메일", minWidth: "min-w-[150px]" }, +]; + +export default function DepartmentPage() { + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + // 좌측: 부서 + const [depts, setDepts] = useState([]); + const [deptLoading, setDeptLoading] = useState(false); + const [deptCount, setDeptCount] = useState(0); + const [searchFilters, setSearchFilters] = useState([]); + const [selectedDeptId, setSelectedDeptId] = useState(null); + const [tableSettingsOpen, setTableSettingsOpen] = useState(false); + const [filterConfig, setFilterConfig] = useState(); + + // 우측: 사원 + const [members, setMembers] = useState([]); + const [memberLoading, setMemberLoading] = useState(false); + + // 부서 모달 + const [deptModalOpen, setDeptModalOpen] = useState(false); + const [deptEditMode, setDeptEditMode] = useState(false); + const [deptForm, setDeptForm] = useState>({}); + const [saving, setSaving] = useState(false); + + // 사원 모달 + const [userModalOpen, setUserModalOpen] = useState(false); + const [userEditMode, setUserEditMode] = useState(false); + const [userForm, setUserForm] = useState>({}); + const [formErrors, setFormErrors] = useState>({}); + + // 엑셀 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + + const applyTableSettings = useCallback((settings: TableSettings) => { + setFilterConfig(settings.filters); + }, []); + + useEffect(() => { + const saved = loadTableSettings("department"); + if (saved) applyTableSettings(saved); + }, []); + + // 부서 조회 + const fetchDepts = useCallback(async () => { + setDeptLoading(true); + try { + const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value })); + const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, { + page: 1, size: 500, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const raw = res.data?.data?.data || res.data?.data?.rows || []; + // dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑 + const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code })); + setDepts(data); + setDeptCount(res.data?.data?.total || data.length); + } catch (err) { + console.error("부서 조회 실패:", err); + toast.error("부서 목록을 불러오는데 실패했습니다."); + } finally { + setDeptLoading(false); + } + }, [searchFilters]); + + useEffect(() => { fetchDepts(); }, [fetchDepts]); + + // 선택된 부서 + const selectedDept = depts.find((d) => d.id === selectedDeptId); + const selectedDeptCode = selectedDept?.dept_code || null; + + // 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서) + const fetchMembers = useCallback(async () => { + setMemberLoading(true); + try { + const filters = selectedDeptCode + ? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] + : []; + const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, { + page: 1, size: 500, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + setMembers(res.data?.data?.data || res.data?.data?.rows || []); + } catch { setMembers([]); } finally { setMemberLoading(false); } + }, [selectedDeptCode]); + + useEffect(() => { fetchMembers(); }, [fetchMembers]); + + // 부서 등록 + const openDeptRegister = () => { + setDeptForm({}); + setDeptEditMode(false); + setDeptModalOpen(true); + }; + + const openDeptEdit = () => { + if (!selectedDept) return; + setDeptForm({ ...selectedDept }); + setDeptEditMode(true); + setDeptModalOpen(true); + }; + + const handleDeptSave = async () => { + if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; } + setSaving(true); + try { + if (deptEditMode && deptForm.dept_code) { + await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, { + originalData: { dept_code: deptForm.dept_code }, + updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null }, + }); + toast.success("수정되었습니다."); + } else { + await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, { + dept_code: deptForm.dept_code || "", + dept_name: deptForm.dept_name, + parent_dept_code: deptForm.parent_dept_code || null, + }); + toast.success("등록되었습니다."); + } + setDeptModalOpen(false); + fetchDepts(); + } catch (err: any) { + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 부서 삭제 + const handleDeptDelete = async () => { + if (!selectedDeptCode) return; + const ok = await confirm("부서를 삭제하시겠습니까?", { + description: "해당 부서에 소속된 사원 정보는 유지됩니다.", + variant: "destructive", confirmText: "삭제", + }); + if (!ok) return; + try { + await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, { + data: [{ dept_code: selectedDeptCode }], + }); + toast.success("삭제되었습니다."); + setSelectedDeptId(null); + fetchDepts(); + } catch { toast.error("삭제에 실패했습니다."); } + }; + + // 사원 추가 + const openUserModal = (editData?: any) => { + if (editData) { + setUserEditMode(true); + setUserForm({ ...editData, user_password: "" }); + } else { + setUserEditMode(false); + setUserForm({ dept_code: selectedDeptCode || "", user_password: "" }); + } + setFormErrors({}); + setUserModalOpen(true); + }; + + const handleUserFormChange = (field: string, value: string) => { + const formatted = formatField(field, value); + setUserForm((prev) => ({ ...prev, [field]: formatted })); + const error = validateField(field, formatted); + setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; }); + }; + + const handleUserSave = async () => { + if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; } + if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; } + const errors = validateForm(userForm, ["cell_phone", "email"]); + setFormErrors(errors); + if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; } + + setSaving(true); + try { + // 비밀번호 미입력 시 기본값 (신규만) + const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined); + + await apiClient.post("/admin/users/with-dept", { + userInfo: { + user_id: userForm.user_id, + user_name: userForm.user_name, + user_name_eng: userForm.user_name_eng || undefined, + user_password: password || undefined, + email: userForm.email || undefined, + tel: userForm.tel || undefined, + cell_phone: userForm.cell_phone || undefined, + sabun: userForm.sabun || undefined, + position_name: userForm.position_name || undefined, + dept_code: userForm.dept_code || undefined, + dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined, + status: userForm.status || "active", + }, + mainDept: userForm.dept_code ? { + dept_code: userForm.dept_code, + dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name, + position_name: userForm.position_name || undefined, + } : undefined, + isUpdate: userEditMode, + }); + toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다."); + setUserModalOpen(false); + fetchMembers(); + } catch (err: any) { + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 엑셀 다운로드 + const handleExcelDownload = async () => { + if (depts.length === 0) return; + const data = depts.map((d) => ({ + 부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status, + })); + await exportToExcel(data, "부서관리.xlsx", "부서"); + toast.success("다운로드 완료"); + }; + + return ( +
+ {/* 검색 */} + + + + +
+ } + /> + + {/* 분할 패널 */} +
+ + {/* 좌측: 부서 */} + +
+
+
+ 부서 + {deptCount}건 +
+
+ + + +
+
+ { + setSelectedDeptId((prev) => (prev === id ? null : id)); + }} + onRowDoubleClick={() => openDeptEdit()} + emptyMessage="등록된 부서가 없습니다" + /> +
+
+ + + + {/* 우측: 사원 */} + +
+
+
+ + {selectedDept ? "부서 인원" : "전체 사원"} + {selectedDept && {selectedDept.dept_name}} + {members.length > 0 && {members.length}명} +
+ +
+ openUserModal(row)} + /> +
+
+
+
+ + {/* 부서 등록/수정 모달 */} + + + + {deptEditMode ? "부서 수정" : "부서 등록"} + {deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."} + +
+
+ + setDeptForm((p) => ({ ...p, dept_code: e.target.value }))} + placeholder="부서코드" className="h-9" disabled={deptEditMode} /> +
+
+ + setDeptForm((p) => ({ ...p, dept_name: e.target.value }))} + placeholder="부서명" className="h-9" /> +
+
+ + +
+
+ + + + +
+
+ + {/* 사원 추가 모달 */} + + + + {userEditMode ? "사원 정보 수정" : "사원 추가"} + {userEditMode ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."} + +
+
+ + setUserForm((p) => ({ ...p, user_id: e.target.value }))} + placeholder="사용자 ID" className="h-9" disabled={userEditMode} /> +
+
+ + setUserForm((p) => ({ ...p, user_name: e.target.value }))} + placeholder="이름" className="h-9" /> +
+
+ + setUserForm((p) => ({ ...p, sabun: e.target.value }))} + placeholder="사번" className="h-9" /> +
+
+ + setUserForm((p) => ({ ...p, user_password: e.target.value }))} + placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" /> +
+
+ + setUserForm((p) => ({ ...p, position_name: e.target.value }))} + placeholder="직급" className="h-9" /> +
+
+ + +
+
+ + handleUserFormChange("cell_phone", e.target.value)} + placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} /> + {formErrors.cell_phone &&

{formErrors.cell_phone}

} +
+
+ + handleUserFormChange("email", e.target.value)} + placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} /> + {formErrors.email &&

{formErrors.email}

} +
+
+ + setUserForm((p) => ({ ...p, regdate: v }))} placeholder="입사일" /> +
+
+ + setUserForm((p) => ({ ...p, end_date: v }))} placeholder="퇴사일" /> +
+
+ + + + +
+
+ + {/* 엑셀 업로드 */} + fetchDepts()} + /> + + {ConfirmDialogComponent} + + + + ); +} diff --git a/frontend/app/(main)/master-data/item-info/page.tsx b/frontend/app/(main)/master-data/item-info/page.tsx new file mode 100644 index 00000000..c3ad7029 --- /dev/null +++ b/frontend/app/(main)/master-data/item-info/page.tsx @@ -0,0 +1,517 @@ +"use client"; + +import React, { useState, useEffect, useCallback } 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 { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Plus, Trash2, RotateCcw, Save, Search, Loader2, FileSpreadsheet, Download, + Package, Pencil, Copy, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; + +// 테이블 컬럼 정의 +const TABLE_COLUMNS = [ + { key: "item_number", label: "품목코드", width: "w-[120px]" }, + { key: "item_name", label: "품명", width: "min-w-[150px]" }, + { key: "division", label: "관리품목", width: "w-[100px]" }, + { key: "type", label: "품목구분", width: "w-[100px]" }, + { key: "size", label: "규격", width: "w-[100px]" }, + { key: "unit", label: "단위", width: "w-[80px]" }, + { key: "material", label: "재질", width: "w-[100px]" }, + { key: "status", label: "상태", width: "w-[80px]" }, + { key: "selling_price", label: "판매가격", width: "w-[100px]" }, + { key: "standard_price", label: "기준단가", width: "w-[100px]" }, + { key: "weight", label: "중량", width: "w-[80px]" }, + { key: "inventory_unit", label: "재고단위", width: "w-[80px]" }, + { key: "user_type01", label: "대분류", width: "w-[100px]" }, + { key: "user_type02", label: "중분류", width: "w-[100px]" }, +]; + +// 등록 모달 필드 정의 +const FORM_FIELDS = [ + { key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" }, + { key: "item_name", label: "품명", type: "text", required: true }, + { key: "division", label: "관리품목", type: "category" }, + { key: "type", label: "품목구분", type: "category" }, + { key: "size", label: "규격", type: "text" }, + { key: "unit", label: "단위", type: "category" }, + { key: "material", label: "재질", type: "category" }, + { key: "status", label: "상태", type: "category" }, + { key: "weight", label: "중량", type: "text" }, + { key: "volum", label: "부피", type: "text" }, + { key: "specific_gravity", label: "비중", type: "text" }, + { key: "inventory_unit", label: "재고단위", type: "category" }, + { key: "selling_price", label: "판매가격", type: "text" }, + { key: "standard_price", label: "기준단가", type: "text" }, + { key: "currency_code", label: "통화", type: "category" }, + { key: "user_type01", label: "대분류", type: "category" }, + { key: "user_type02", label: "중분류", type: "category" }, + { key: "meno", label: "메모", type: "textarea" }, +]; + +const TABLE_NAME = "item_info"; + +export default function ItemInfoPage() { + const { user } = useAuth(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [totalCount, setTotalCount] = useState(0); + + // 검색 + const [searchKeyword, setSearchKeyword] = useState(""); + const [searchDivision, setSearchDivision] = useState("all"); + const [searchType, setSearchType] = useState("all"); + const [searchStatus, setSearchStatus] = useState("all"); + + // 모달 + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + const [editId, setEditId] = useState(null); + const [saving, setSaving] = useState(false); + const [formData, setFormData] = useState>({}); + + // 엑셀 업로드 + const [excelUploadOpen, setExcelUploadOpen] = useState(false); + + // 카테고리 옵션 (API에서 로드) + const [categoryOptions, setCategoryOptions] = useState>({}); + + // 선택된 행 + const [selectedId, setSelectedId] = useState(null); + + // 카테고리 컬럼 목록 + const CATEGORY_COLUMNS = ["division", "type", "unit", "material", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]; + + // 카테고리 옵션 로드 (table_name + column_name 기반) + useEffect(() => { + const loadCategories = async () => { + try { + 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, label: v.valueLabel }); + if (v.children?.length) result.push(...flatten(v.children)); + } + return result; + }; + + await Promise.all( + CATEGORY_COLUMNS.map(async (colName) => { + try { + const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`); + if (res.data?.success && res.data.data?.length > 0) { + optMap[colName] = flatten(res.data.data); + } + } catch { /* skip */ } + }) + ); + setCategoryOptions(optMap); + } catch (err) { + console.error("카테고리 로드 실패:", err); + } + }; + loadCategories(); + }, []); + + // 데이터 조회 + const fetchItems = useCallback(async () => { + setLoading(true); + try { + const filters: any[] = []; + if (searchKeyword) { + filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword }); + } + if (searchDivision !== "all") { + filters.push({ columnName: "division", operator: "equals", value: searchDivision }); + } + if (searchType !== "all") { + filters.push({ columnName: "type", operator: "equals", value: searchType }); + } + if (searchStatus !== "all") { + filters.push({ columnName: "status", operator: "equals", value: searchStatus }); + } + + const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, { + page: 1, + size: 500, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + + const raw = res.data?.data?.data || res.data?.data?.rows || []; + // 카테고리 코드→라벨 변환 + const resolve = (col: string, code: string) => { + if (!code) return ""; + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + const data = raw.map((r: any) => { + const converted = { ...r }; + for (const col of CATEGORY_COLUMNS) { + if (converted[col]) converted[col] = resolve(col, converted[col]); + } + return converted; + }); + setItems(data); + setTotalCount(res.data?.data?.total || raw.length); + } catch (err) { + console.error("품목 조회 실패:", err); + toast.error("품목 목록을 불러오는데 실패했습니다."); + } finally { + setLoading(false); + } + }, [searchKeyword, searchDivision, searchType, searchStatus, categoryOptions]); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); + + // 카테고리 코드 → 라벨 변환 + const getCategoryLabel = (columnName: string, code: string) => { + if (!code) return ""; + const opts = categoryOptions[columnName]; + if (!opts) return code; + const found = opts.find((o) => o.code === code); + return found?.label || code; + }; + + // 등록 모달 열기 + const openRegisterModal = () => { + setFormData({}); + setIsEditMode(false); + setEditId(null); + setIsModalOpen(true); + }; + + // 수정 모달 열기 + const openEditModal = (item: any) => { + setFormData({ ...item }); + setIsEditMode(true); + setEditId(item.id); + setIsModalOpen(true); + }; + + // 복사 모달 열기 + const openCopyModal = (item: any) => { + const { id, item_number, created_date, updated_date, writer, ...rest } = item; + setFormData(rest); + setIsEditMode(false); + setEditId(null); + setIsModalOpen(true); + }; + + // 저장 + const handleSave = async () => { + if (!formData.item_name) { + toast.error("품명은 필수 입력입니다."); + return; + } + + setSaving(true); + try { + if (isEditMode && editId) { + // 수정 + const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData; + await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, { + originalData: { id: editId }, + updatedData: updateFields, + }); + toast.success("수정되었습니다."); + } else { + // 등록 + const { id, created_date, updated_date, ...insertFields } = formData; + await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, insertFields); + toast.success("등록되었습니다."); + } + setIsModalOpen(false); + fetchItems(); + } catch (err: any) { + console.error("저장 실패:", err); + toast.error(err.response?.data?.message || "저장에 실패했습니다."); + } finally { + setSaving(false); + } + }; + + // 삭제 + const handleDelete = async () => { + if (!selectedId) { + toast.error("삭제할 품목을 선택해주세요."); + return; + } + if (!confirm("선택한 품목을 삭제하시겠습니까?")) return; + + try { + await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { + data: [{ id: selectedId }], + }); + toast.success("삭제되었습니다."); + setSelectedId(null); + fetchItems(); + } catch (err) { + console.error("삭제 실패:", err); + toast.error("삭제에 실패했습니다."); + } + }; + + // 엑셀 다운로드 + const handleExcelDownload = async () => { + if (items.length === 0) { + toast.error("다운로드할 데이터가 없습니다."); + return; + } + const exportData = items.map((item) => { + const row: Record = {}; + for (const col of TABLE_COLUMNS) { + row[col.label] = getCategoryLabel(col.key, item[col.key]) || item[col.key] || ""; + } + return row; + }); + await exportToExcel(exportData, "품목정보.xlsx", "품목정보"); + toast.success("엑셀 다운로드 완료"); + }; + + // 검색 초기화 + const handleResetSearch = () => { + setSearchKeyword(""); + setSearchDivision("all"); + setSearchType("all"); + setSearchStatus("all"); + }; + + // 카테고리 셀렉트 렌더링 + const renderCategorySelect = (field: typeof FORM_FIELDS[0]) => { + const options = categoryOptions[field.key] || []; + return ( + + ); + }; + + return ( +
+ {/* 검색 */} + + +
+ + setSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && fetchItems()} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {loading && } + +
+ + + + {/* 메인 테이블 */} +
+
+
+ 품목 목록 + {totalCount}건 +
+
+ + + + + + +
+
+ +
+ {loading ? ( +
+ +
+ ) : items.length === 0 ? ( +
+ + 등록된 품목이 없습니다 +
+ ) : ( + + + + No + {TABLE_COLUMNS.map((col) => ( + {col.label} + ))} + + + + {items.map((item, idx) => ( + setSelectedId(item.id)} + onDoubleClick={() => openEditModal(item)} + > + {idx + 1} + {TABLE_COLUMNS.map((col) => ( + + {["division", "type", "unit", "material", "status", "inventory_unit", "user_type01", "user_type02", "currency_code"].includes(col.key) + ? getCategoryLabel(col.key, item[col.key]) + : item[col.key] || ""} + + ))} + + ))} + +
+ )} +
+
+ + {/* 등록/수정 모달 */} + + + + {isEditMode ? "품목 수정" : "품목 등록"} + + {isEditMode ? "품목 정보를 수정합니다." : "새로운 품목을 등록합니다."} + + + +
+ {FORM_FIELDS.map((field) => ( +
+ + {field.type === "category" ? ( + renderCategorySelect(field) + ) : field.type === "textarea" ? ( +