diff --git a/.omc/project-memory.json b/.omc/project-memory.json index 9a424223..b780218b 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,168 @@ "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": 16, + "lastAccessed": 1774313958064, + "type": "file" + }, + { + "path": "frontend/app/(main)/sales/shipping-plan/page.tsx", + "accessCount": 4, + "lastAccessed": 1774313720455, + "type": "file" + }, + { + "path": "frontend/components/common/DataGrid.tsx", + "accessCount": 3, + "lastAccessed": 1774313504763, + "type": "file" + }, + { + "path": "frontend/components/common/DynamicSearchFilter.tsx", + "accessCount": 2, + "lastAccessed": 1774313460662, + "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" + } + ], "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 ee964175..7a3e0071 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"; // 임시 주석 @@ -353,6 +354,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/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/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/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/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/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/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..cbb40203 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, }; @@ -3357,16 +3357,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 +3412,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 +3453,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 +5499,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/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..0a83ceb2 --- /dev/null +++ b/frontend/.omc/state/idle-notif-cooldown.json @@ -0,0 +1,3 @@ +{ + "lastSentAt": "2026-03-25T01:37:37.051Z" +} \ 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..4ee2ec12 --- /dev/null +++ b/frontend/.omc/state/last-tool-error.json @@ -0,0 +1,7 @@ +{ + "tool_name": "Read", + "tool_input_preview": "{\"file_path\":\"/Users/kimjuseok/ERP-node/frontend/app/(main)/sales/sales-item/page.tsx\"}", + "error": "File content (13282 tokens) exceeds maximum allowed tokens (10000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.", + "timestamp": "2026-03-25T01:36:58.910Z", + "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..900ee157 --- /dev/null +++ b/frontend/.omc/state/mission-state.json @@ -0,0 +1,109 @@ +{ + "updatedAt": "2026-03-25T01:37:19.659Z", + "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" + } + ] + } + ] +} \ 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..32d6a63e --- /dev/null +++ b/frontend/.omc/state/subagent-tracking.json @@ -0,0 +1,53 @@ +{ + "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 + } + ], + "total_spawned": 5, + "total_completed": 5, + "total_failed": 0, + "last_updated": "2026-03-25T01:37:19.762Z" +} \ 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..fb82e1f2 --- /dev/null +++ b/frontend/app/(main)/equipment/info/page.tsx @@ -0,0 +1,728 @@ +"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, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; +import { 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 [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); + + // 카테고리 로드 + 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..09153b6e --- /dev/null +++ b/frontend/app/(main)/master-data/department/page.tsx @@ -0,0 +1,474 @@ +"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, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { 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 [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 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" ? ( +