Compare commits
90 Commits
chpark-syn
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
9cbf0c6868 | |
|
|
3e935792d4 | |
|
|
de440f8d42 | |
|
|
2e9b67a509 | |
|
|
6fe7bfbefc | |
|
|
eacfe60f89 | |
|
|
f32861df8b | |
|
|
f10946ae5b | |
|
|
0aef19578a | |
|
|
590ae8fbb7 | |
|
|
5cad4ed7fd | |
|
|
f471ce245a | |
|
|
a29691c31e | |
|
|
3249611cfc | |
|
|
1128a4c278 | |
|
|
5da134f016 | |
|
|
cda7e7bbfe | |
|
|
761100a176 | |
|
|
bf42f27440 | |
|
|
07777e314b | |
|
|
86f9040e40 | |
|
|
0a6922edeb | |
|
|
f9e243d439 | |
|
|
8c80c854cc | |
|
|
1bf91bf043 | |
|
|
348da95823 | |
|
|
6de31eb55a | |
|
|
70e040db39 | |
|
|
02ac36c94f | |
|
|
8db6b4984b | |
|
|
49da393f17 | |
|
|
dd3b226917 | |
|
|
0852361e92 | |
|
|
8fdbbb7f41 | |
|
|
782ebb1b33 | |
|
|
df6c479589 | |
|
|
6262ddb76b | |
|
|
e67e43cd7d | |
|
|
48af85c713 | |
|
|
d674d88d1e | |
|
|
bc186111ef | |
|
|
69c5a78753 | |
|
|
bb6e17ec28 | |
|
|
d5650c5797 | |
|
|
0775a45606 | |
|
|
525237d42d | |
|
|
86e64492cf | |
|
|
e2f18b19bc | |
|
|
7c5e9b0c46 | |
|
|
0fd0a43370 | |
|
|
b677840952 | |
|
|
2da1532e65 | |
|
|
e8fe077369 | |
|
|
bda77ef844 | |
|
|
a8bb72050f | |
|
|
9afe98ec60 | |
|
|
4c113f2b8e | |
|
|
8ee6d75b3d | |
|
|
7e54940963 | |
|
|
5d4cf8d462 | |
|
|
f1ebcf7dee | |
|
|
1c562fa854 | |
|
|
cbe3242f3a | |
|
|
29640063a8 | |
|
|
d061240498 | |
|
|
ec7308bf43 | |
|
|
3f8204e662 | |
|
|
074626426b | |
|
|
cab0342081 | |
|
|
aa48d40048 | |
|
|
c54364312a | |
|
|
b5e48f0b12 | |
|
|
678e5fa368 | |
|
|
da9bce2301 | |
|
|
ca2af56aad | |
|
|
461ff6dbf7 | |
|
|
73674385be | |
|
|
17fb815513 | |
|
|
1d85de8bf6 | |
|
|
d001f82565 | |
|
|
5d12bef5e5 | |
|
|
9d164d08af | |
|
|
08ad2abdd1 | |
|
|
c8226b0ba6 | |
|
|
33dfa6b475 | |
|
|
fba5390f5a | |
|
|
20fbe85c74 | |
|
|
06c52b422f | |
|
|
230d35b03a | |
|
|
8ee10e411e |
|
|
@ -193,7 +193,9 @@ scripts/browser-test-*.js
|
||||||
|
|
||||||
# 개인 작업 문서
|
# 개인 작업 문서
|
||||||
popdocs/
|
popdocs/
|
||||||
|
kshdocs/
|
||||||
.cursor/rules/popdocs-safety.mdc
|
.cursor/rules/popdocs-safety.mdc
|
||||||
|
.cursor/rules/overtime-registration.mdc
|
||||||
|
|
||||||
# 멀티 에이전트 MCP 태스크 큐
|
# 멀티 에이전트 MCP 태스크 큐
|
||||||
mcp-task-queue/
|
mcp-task-queue/
|
||||||
|
|
@ -209,3 +211,14 @@ docs/retrospectives/
|
||||||
mes-architecture-guide.md
|
mes-architecture-guide.md
|
||||||
# MES Reference Documents
|
# MES Reference Documents
|
||||||
docs/mes-reference/
|
docs/mes-reference/
|
||||||
|
|
||||||
|
# 테스트 결과물
|
||||||
|
frontend/test-results/
|
||||||
|
|
||||||
|
# Cursor 설정
|
||||||
|
.cursor/
|
||||||
|
|
||||||
|
# Playwright 테스트 (관제탑에서 관리)
|
||||||
|
frontend/playwright.config.ts
|
||||||
|
frontend/tests/
|
||||||
|
frontend/test-results/
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastScanned": 1772609393905,
|
"lastScanned": 1774313213052,
|
||||||
"projectRoot": "/Users/johngreen/Dev/vexplor",
|
"projectRoot": "/Users/kimjuseok/ERP-node",
|
||||||
"techStack": {
|
"techStack": {
|
||||||
"languages": [
|
"languages": [
|
||||||
{
|
{
|
||||||
|
|
@ -13,7 +13,13 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"frameworks": [],
|
"frameworks": [
|
||||||
|
{
|
||||||
|
"name": "playwright",
|
||||||
|
"version": "1.58.2",
|
||||||
|
"category": "testing"
|
||||||
|
}
|
||||||
|
],
|
||||||
"packageManager": "npm",
|
"packageManager": "npm",
|
||||||
"runtime": null
|
"runtime": null
|
||||||
},
|
},
|
||||||
|
|
@ -28,16 +34,14 @@
|
||||||
"namingStyle": null,
|
"namingStyle": null,
|
||||||
"importStyle": null,
|
"importStyle": null,
|
||||||
"testPattern": null,
|
"testPattern": null,
|
||||||
"fileOrganization": "type-based"
|
"fileOrganization": null
|
||||||
},
|
},
|
||||||
"structure": {
|
"structure": {
|
||||||
"isMonorepo": false,
|
"isMonorepo": false,
|
||||||
"workspaces": [],
|
"workspaces": [],
|
||||||
"mainDirectories": [
|
"mainDirectories": [
|
||||||
"docs",
|
"docs",
|
||||||
"lib",
|
"scripts"
|
||||||
"scripts",
|
|
||||||
"src"
|
|
||||||
],
|
],
|
||||||
"gitBranches": {
|
"gitBranches": {
|
||||||
"defaultBranch": "main",
|
"defaultBranch": "main",
|
||||||
|
|
@ -46,37 +50,39 @@
|
||||||
},
|
},
|
||||||
"customNotes": [],
|
"customNotes": [],
|
||||||
"directoryMap": {
|
"directoryMap": {
|
||||||
"WebContent": {
|
"_local": {
|
||||||
"path": "WebContent",
|
"path": "_local",
|
||||||
|
"purpose": null,
|
||||||
|
"fileCount": 1,
|
||||||
|
"lastAccessed": 1774313213033,
|
||||||
|
"keyFiles": [
|
||||||
|
"pipeline-progress.json"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ai-assistant": {
|
||||||
|
"path": "ai-assistant",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 5,
|
"fileCount": 5,
|
||||||
"lastAccessed": 1772609393856,
|
"lastAccessed": 1774313213036,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"init.jsp",
|
"Dockerfile.win",
|
||||||
"init_jqGrid.jsp",
|
"README.md",
|
||||||
"init_no_login.jsp",
|
"package-lock.json",
|
||||||
"init_toastGrid.jsp",
|
"package.json"
|
||||||
"viewImage.jsp"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"backend": {
|
"backend": {
|
||||||
"path": "backend",
|
"path": "backend",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 6,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1772609393857,
|
"lastAccessed": 1774313213038,
|
||||||
"keyFiles": [
|
"keyFiles": []
|
||||||
"Dockerfile",
|
|
||||||
"Dockerfile.mac",
|
|
||||||
"build.gradle",
|
|
||||||
"gradlew",
|
|
||||||
"gradlew.bat"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"backend-node": {
|
"backend-node": {
|
||||||
"path": "backend-node",
|
"path": "backend-node",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 14,
|
"fileCount": 17,
|
||||||
"lastAccessed": 1772609393872,
|
"lastAccessed": 1774313213039,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"API_연동_가이드.md",
|
"API_연동_가이드.md",
|
||||||
"API_키_정리.md",
|
"API_키_정리.md",
|
||||||
|
|
@ -85,70 +91,88 @@
|
||||||
"README.md"
|
"README.md"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"backup": {
|
||||||
|
"path": "backup",
|
||||||
|
"purpose": null,
|
||||||
|
"fileCount": 6,
|
||||||
|
"lastAccessed": 1774313213040,
|
||||||
|
"keyFiles": [
|
||||||
|
"Dockerfile",
|
||||||
|
"README.md",
|
||||||
|
"backup.py",
|
||||||
|
"docker-compose.backup.yml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"db": {
|
"db": {
|
||||||
"path": "db",
|
"path": "db",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 2,
|
"fileCount": 14,
|
||||||
"lastAccessed": 1772609393873,
|
"lastAccessed": 1774313213041,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"00-create-roles.sh",
|
"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": {
|
"deploy": {
|
||||||
"path": "deploy",
|
"path": "deploy",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1772609393873,
|
"lastAccessed": 1774313213041,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
|
"digitalTwin": {
|
||||||
|
"path": "digitalTwin",
|
||||||
|
"purpose": null,
|
||||||
|
"fileCount": 4,
|
||||||
|
"lastAccessed": 1774313213041,
|
||||||
|
"keyFiles": [
|
||||||
|
"architecture-v4.md",
|
||||||
|
"fleet-management-plan.md",
|
||||||
|
"디지털트윈 아키텍쳐_v3.png",
|
||||||
|
"디지털트윈 아키텍쳐_v4.png"
|
||||||
|
]
|
||||||
|
},
|
||||||
"docker": {
|
"docker": {
|
||||||
"path": "docker",
|
"path": "docker",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 0,
|
||||||
"lastAccessed": 1772609393873,
|
"lastAccessed": 1774313213042,
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"docs": {
|
"docs": {
|
||||||
"path": "docs",
|
"path": "docs",
|
||||||
"purpose": "Documentation",
|
"purpose": "Documentation",
|
||||||
"fileCount": 23,
|
"fileCount": 35,
|
||||||
"lastAccessed": 1772609393873,
|
"lastAccessed": 1774313213042,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"AI_화면생성_시스템_설계서.md",
|
"AI_화면생성_시스템_설계서.md",
|
||||||
|
"BOM_개발_현황.md",
|
||||||
"DB_ARCHITECTURE_ANALYSIS.md",
|
"DB_ARCHITECTURE_ANALYSIS.md",
|
||||||
"DB_STRUCTURE_DIAGRAM.html",
|
"DB_STRUCTURE_DIAGRAM.html",
|
||||||
"DB_WORKFLOW_ANALYSIS.md",
|
"DB_WORKFLOW_ANALYSIS.md"
|
||||||
"KUBERNETES_DEPLOYMENT_GUIDE.md"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"path": "frontend",
|
"path": "frontend",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 14,
|
"fileCount": 17,
|
||||||
"lastAccessed": 1772609393873,
|
"lastAccessed": 1774313213043,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"MODAL_REPEATER_TABLE_DEBUG.md",
|
"MODAL_REPEATER_TABLE_DEBUG.md",
|
||||||
"README.md",
|
"README.md",
|
||||||
|
"approval-box-result.png",
|
||||||
"components.json",
|
"components.json",
|
||||||
"eslint.config.mjs",
|
"eslint.config.mjs"
|
||||||
"middleware.ts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hooks": {
|
|
||||||
"path": "hooks",
|
|
||||||
"purpose": null,
|
|
||||||
"fileCount": 1,
|
|
||||||
"lastAccessed": 1772609393879,
|
|
||||||
"keyFiles": [
|
|
||||||
"useScreenStandards.ts"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"k8s": {
|
"k8s": {
|
||||||
"path": "k8s",
|
"path": "k8s",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 7,
|
"fileCount": 7,
|
||||||
"lastAccessed": 1772609393882,
|
"lastAccessed": 1774313213043,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"local-path-provisioner.yaml",
|
"local-path-provisioner.yaml",
|
||||||
"namespace.yaml",
|
"namespace.yaml",
|
||||||
|
|
@ -157,18 +181,11 @@
|
||||||
"vexplor-frontend-deployment.yaml"
|
"vexplor-frontend-deployment.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"lib": {
|
|
||||||
"path": "lib",
|
|
||||||
"purpose": "Library code",
|
|
||||||
"fileCount": 0,
|
|
||||||
"lastAccessed": 1772609393883,
|
|
||||||
"keyFiles": []
|
|
||||||
},
|
|
||||||
"mcp-agent-orchestrator": {
|
"mcp-agent-orchestrator": {
|
||||||
"path": "mcp-agent-orchestrator",
|
"path": "mcp-agent-orchestrator",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 4,
|
"fileCount": 4,
|
||||||
"lastAccessed": 1772609393883,
|
"lastAccessed": 1774313213043,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"README.md",
|
"README.md",
|
||||||
"package-lock.json",
|
"package-lock.json",
|
||||||
|
|
@ -176,91 +193,68 @@
|
||||||
"tsconfig.json"
|
"tsconfig.json"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"popdocs": {
|
"mcp-task-queue": {
|
||||||
"path": "popdocs",
|
"path": "mcp-task-queue",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 12,
|
"fileCount": 4,
|
||||||
"lastAccessed": 1772609393884,
|
"lastAccessed": 1774313213043,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"ARCHITECTURE.md",
|
"package-lock.json",
|
||||||
"CHANGELOG.md",
|
"package.json",
|
||||||
"FILES.md",
|
"tsconfig.json"
|
||||||
"INDEX.md",
|
|
||||||
"PLAN.md"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"mcp-task-server": {
|
||||||
|
"path": "mcp-task-server",
|
||||||
|
"purpose": null,
|
||||||
|
"fileCount": 0,
|
||||||
|
"lastAccessed": 1774313213043,
|
||||||
|
"keyFiles": []
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"path": "scripts",
|
"path": "scripts",
|
||||||
"purpose": "Build/utility scripts",
|
"purpose": "Build/utility scripts",
|
||||||
"fileCount": 2,
|
"fileCount": 11,
|
||||||
"lastAccessed": 1772609393884,
|
"lastAccessed": 1774313213044,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"add-modal-ids.py",
|
"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": {
|
"test-output": {
|
||||||
"path": "src",
|
"path": "test-output",
|
||||||
"purpose": "Source code",
|
"purpose": null,
|
||||||
"fileCount": 0,
|
"fileCount": 2,
|
||||||
"lastAccessed": 1772609393884,
|
"lastAccessed": 1774313213044,
|
||||||
"keyFiles": []
|
"keyFiles": [
|
||||||
|
"screen-149-field-type-verification-guide.md",
|
||||||
|
"unified-field-type-config-panel-test-guide.md"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"tomcat-conf": {
|
"test-results": {
|
||||||
"path": "tomcat-conf",
|
"path": "test-results",
|
||||||
"purpose": null,
|
"purpose": null,
|
||||||
"fileCount": 1,
|
"fileCount": 1,
|
||||||
"lastAccessed": 1772609393884,
|
"lastAccessed": 1774313213044,
|
||||||
"keyFiles": [
|
|
||||||
"context.xml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"backend/build": {
|
|
||||||
"path": "backend/build",
|
|
||||||
"purpose": "Build output",
|
|
||||||
"fileCount": 0,
|
|
||||||
"lastAccessed": 1772609393884,
|
|
||||||
"keyFiles": []
|
"keyFiles": []
|
||||||
},
|
},
|
||||||
"backend/src": {
|
"ai-assistant/src": {
|
||||||
"path": "backend/src",
|
"path": "ai-assistant/src",
|
||||||
"purpose": "Source code",
|
"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,
|
"fileCount": 1,
|
||||||
"lastAccessed": 1772609393884,
|
"lastAccessed": 1774313213045,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"README_cleanup.md"
|
"app.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"frontend/app": {
|
"frontend/app": {
|
||||||
"path": "frontend/app",
|
"path": "frontend/app",
|
||||||
"purpose": "Application code",
|
"purpose": "Application code",
|
||||||
"fileCount": 5,
|
"fileCount": 5,
|
||||||
"lastAccessed": 1772609393885,
|
"lastAccessed": 1774313213046,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"favicon.ico",
|
"favicon.ico",
|
||||||
"globals.css",
|
"globals.css",
|
||||||
|
|
@ -271,7 +265,7 @@
|
||||||
"path": "frontend/components",
|
"path": "frontend/components",
|
||||||
"purpose": "UI components",
|
"purpose": "UI components",
|
||||||
"fileCount": 1,
|
"fileCount": 1,
|
||||||
"lastAccessed": 1772609393885,
|
"lastAccessed": 1774313213046,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"GlobalFileViewer.tsx"
|
"GlobalFileViewer.tsx"
|
||||||
]
|
]
|
||||||
|
|
@ -280,49 +274,174 @@
|
||||||
"path": "mcp-agent-orchestrator/src",
|
"path": "mcp-agent-orchestrator/src",
|
||||||
"purpose": "Source code",
|
"purpose": "Source code",
|
||||||
"fileCount": 1,
|
"fileCount": 1,
|
||||||
"lastAccessed": 1772609393885,
|
"lastAccessed": 1774313213047,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"index.ts"
|
"index.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"src/controllers": {
|
"mcp-task-queue/data": {
|
||||||
"path": "src/controllers",
|
"path": "mcp-task-queue/data",
|
||||||
"purpose": "Controllers",
|
"purpose": "Data files",
|
||||||
"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",
|
|
||||||
"fileCount": 2,
|
"fileCount": 2,
|
||||||
"lastAccessed": 1772609393885,
|
"lastAccessed": 1774313213047,
|
||||||
"keyFiles": [
|
"keyFiles": [
|
||||||
"databaseValidator.ts",
|
"knowledge.json",
|
||||||
"queryBuilder.ts"
|
"tasks.json"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"mcp-task-queue/dist": {
|
||||||
|
"path": "mcp-task-queue/dist",
|
||||||
|
"purpose": "Distribution/build output",
|
||||||
|
"fileCount": 28,
|
||||||
|
"lastAccessed": 1774313213048,
|
||||||
|
"keyFiles": [
|
||||||
|
"agent-runner.d.ts",
|
||||||
|
"agent-runner.d.ts.map",
|
||||||
|
"agent-runner.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mcp-task-queue/node_modules": {
|
||||||
|
"path": "mcp-task-queue/node_modules",
|
||||||
|
"purpose": "Dependencies",
|
||||||
|
"fileCount": 1,
|
||||||
|
"lastAccessed": 1774313213049,
|
||||||
|
"keyFiles": []
|
||||||
|
},
|
||||||
|
"mcp-task-queue/src": {
|
||||||
|
"path": "mcp-task-queue/src",
|
||||||
|
"purpose": "Source code",
|
||||||
|
"fileCount": 7,
|
||||||
|
"lastAccessed": 1774313213049,
|
||||||
|
"keyFiles": [
|
||||||
|
"agent-runner.ts",
|
||||||
|
"index.ts",
|
||||||
|
"knowledge-store.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mcp-task-server/data": {
|
||||||
|
"path": "mcp-task-server/data",
|
||||||
|
"purpose": "Data files",
|
||||||
|
"fileCount": 0,
|
||||||
|
"lastAccessed": 1774313213049,
|
||||||
|
"keyFiles": []
|
||||||
|
},
|
||||||
|
"mcp-task-server/dist": {
|
||||||
|
"path": "mcp-task-server/dist",
|
||||||
|
"purpose": "Distribution/build output",
|
||||||
|
"fileCount": 6,
|
||||||
|
"lastAccessed": 1774313213050,
|
||||||
|
"keyFiles": [
|
||||||
|
"index.d.ts",
|
||||||
|
"index.js",
|
||||||
|
"taskStore.d.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"mcp-task-server/node_modules": {
|
||||||
|
"path": "mcp-task-server/node_modules",
|
||||||
|
"purpose": "Dependencies",
|
||||||
|
"fileCount": 1,
|
||||||
|
"lastAccessed": 1774313213050,
|
||||||
|
"keyFiles": []
|
||||||
|
},
|
||||||
|
"mcp-task-server/src": {
|
||||||
|
"path": "mcp-task-server/src",
|
||||||
|
"purpose": "Source code",
|
||||||
|
"fileCount": 0,
|
||||||
|
"lastAccessed": 1774313213052,
|
||||||
|
"keyFiles": []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"hotPaths": [],
|
"hotPaths": [
|
||||||
|
{
|
||||||
|
"path": "frontend/app/(main)/sales/order/page.tsx",
|
||||||
|
"accessCount": 19,
|
||||||
|
"lastAccessed": 1774408850812,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/app/(main)/sales/shipping-plan/page.tsx",
|
||||||
|
"accessCount": 4,
|
||||||
|
"lastAccessed": 1774313720455,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/components/common/DataGrid.tsx",
|
||||||
|
"accessCount": 4,
|
||||||
|
"lastAccessed": 1774408732451,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/components/common/DynamicSearchFilter.tsx",
|
||||||
|
"accessCount": 3,
|
||||||
|
"lastAccessed": 1774408732309,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/app/(main)/production/plan-management/page.tsx",
|
||||||
|
"accessCount": 2,
|
||||||
|
"lastAccessed": 1774313461313,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/app/(main)",
|
||||||
|
"accessCount": 2,
|
||||||
|
"lastAccessed": 1774313529384,
|
||||||
|
"type": "directory"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/lib/api/shipping.ts",
|
||||||
|
"accessCount": 2,
|
||||||
|
"lastAccessed": 1774313725308,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": ".claude/plans/lively-wishing-yeti.md",
|
||||||
|
"accessCount": 2,
|
||||||
|
"lastAccessed": 1774313824670,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/app/(main)/sales/shipping-order/page.tsx",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774313447495,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/app/(main)/sales/claim/page.tsx",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774313450420,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/app/(main)/production/process-info/page.tsx",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774313450623,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/components/common/ExcelUploadModal.tsx",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774313454238,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/app/(main)/master-data/item-info/page.tsx",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774313528166,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/components/common/ShippingPlanModal.tsx",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774313925751,
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/components/common/TableSettingsModal.tsx",
|
||||||
|
"accessCount": 1,
|
||||||
|
"lastAccessed": 1774409034693,
|
||||||
|
"type": "file"
|
||||||
|
}
|
||||||
|
],
|
||||||
"userDirectives": []
|
"userDirectives": []
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"session_id": "037169c7-72ba-4843-8e9a-417ca1423715",
|
||||||
|
"ended_at": "2026-03-26T08:24:13.261Z",
|
||||||
|
"reason": "other",
|
||||||
|
"agents_spawned": 0,
|
||||||
|
"agents_completed": 0,
|
||||||
|
"modes_used": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"session_id": "8145031e-d7ea-4aa3-94d7-ddaa69383b8a",
|
||||||
|
"ended_at": "2026-03-26T09:35:10.082Z",
|
||||||
|
"reason": "other",
|
||||||
|
"agents_spawned": 0,
|
||||||
|
"agents_completed": 0,
|
||||||
|
"modes_used": []
|
||||||
|
}
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
|
|
@ -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}
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
{
|
{
|
||||||
"lastSentAt": "2026-03-04T07:30:30.883Z"
|
"lastSentAt": "2026-03-24T02:36:44.477Z"
|
||||||
}
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bwip-js": "^4.8.0",
|
"bwip-js": "^4.8.0",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
|
|
@ -36,6 +37,7 @@
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"oracledb": "^6.9.0",
|
"oracledb": "^6.9.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"react-quill": "^2.0.0",
|
"react-quill": "^2.0.0",
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
|
|
@ -4408,6 +4410,12 @@
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/bowser": {
|
"node_modules/bowser": {
|
||||||
"version": "2.12.1",
|
"version": "2.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz",
|
||||||
|
|
@ -4704,6 +4712,79 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cheerio": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio-select": "^2.1.0",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"encoding-sniffer": "^0.2.1",
|
||||||
|
"htmlparser2": "^10.1.0",
|
||||||
|
"parse5": "^7.3.0",
|
||||||
|
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||||
|
"parse5-parser-stream": "^7.1.2",
|
||||||
|
"undici": "^7.19.0",
|
||||||
|
"whatwg-mimetype": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio-select": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio/node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio/node_modules/htmlparser2": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"entities": "^7.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
|
|
@ -5091,6 +5172,34 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-select": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
@ -5539,6 +5648,31 @@
|
||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/encoding-sniffer": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
|
"whatwg-encoding": "^3.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ent": {
|
"node_modules/ent": {
|
||||||
"version": "2.2.2",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
|
||||||
|
|
@ -9020,6 +9154,18 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
|
@ -9254,6 +9400,55 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseley": {
|
"node_modules/parseley": {
|
||||||
"version": "0.12.1",
|
"version": "0.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
||||||
|
|
@ -9525,6 +9720,50 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postgres-array": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
|
@ -11146,6 +11385,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.24.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz",
|
||||||
|
"integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
|
@ -11310,6 +11558,40 @@
|
||||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-encoding": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||||
|
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/whatwg-url": {
|
"node_modules/whatwg-url": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bwip-js": "^4.8.0",
|
"bwip-js": "^4.8.0",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"docx": "^9.5.1",
|
"docx": "^9.5.1",
|
||||||
|
|
@ -50,6 +51,7 @@
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"oracledb": "^6.9.0",
|
"oracledb": "^6.9.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"quill": "^2.0.3",
|
"quill": "^2.0.3",
|
||||||
"react-quill": "^2.0.0",
|
"react-quill": "^2.0.0",
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관
|
||||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||||
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
||||||
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
|
import productionRoutes from "./routes/productionRoutes"; // 생산계획 관리
|
||||||
|
import crawlRoutes from "./routes/crawlRoutes"; // 웹 크롤링
|
||||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||||
|
|
@ -127,6 +128,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
|
||||||
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
|
||||||
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
|
||||||
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
|
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
|
||||||
|
import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검사 결과 관리
|
||||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||||
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
|
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
|
||||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||||
|
|
@ -151,6 +153,7 @@ import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석
|
||||||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||||
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
|
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
|
||||||
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
|
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
|
||||||
|
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
|
||||||
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
|
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
|
|
@ -253,12 +256,26 @@ app.use("/api/", limiter);
|
||||||
app.use("/api/", refreshTokenIfNeeded);
|
app.use("/api/", refreshTokenIfNeeded);
|
||||||
|
|
||||||
// 헬스 체크 엔드포인트
|
// 헬스 체크 엔드포인트
|
||||||
|
let appVersion = "unknown";
|
||||||
|
try {
|
||||||
|
// 로컬: ../../package.json, Docker(/app/src/): ../package.json
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
appVersion = require("../../package.json").version;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
appVersion = require("../package.json").version;
|
||||||
|
} catch {
|
||||||
|
/* version stays "unknown" */
|
||||||
|
}
|
||||||
|
}
|
||||||
app.get("/health", (req, res) => {
|
app.get("/health", (req, res) => {
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
status: "OK",
|
status: "OK",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
uptime: process.uptime(),
|
uptime: process.uptime(),
|
||||||
environment: config.nodeEnv,
|
environment: config.nodeEnv,
|
||||||
|
version: appVersion,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -272,6 +289,7 @@ app.use("/api/screen-management", screenManagementRoutes);
|
||||||
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
|
||||||
app.use("/api/pop", popActionRoutes); // POP 액션 실행
|
app.use("/api/pop", popActionRoutes); // POP 액션 실행
|
||||||
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
|
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
|
||||||
|
app.use("/api/pop/inspection-result", inspectionResultRoutes); // POP 검사 결과 관리
|
||||||
app.use("/api/common-codes", commonCodeRoutes);
|
app.use("/api/common-codes", commonCodeRoutes);
|
||||||
app.use("/api/dynamic-form", dynamicFormRoutes);
|
app.use("/api/dynamic-form", dynamicFormRoutes);
|
||||||
app.use("/api/files", fileRoutes);
|
app.use("/api/files", fileRoutes);
|
||||||
|
|
@ -324,6 +342,7 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||||
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
||||||
app.use("/api/production", productionRoutes); // 생산계획 관리
|
app.use("/api/production", productionRoutes); // 생산계획 관리
|
||||||
|
app.use("/api/crawl", crawlRoutes); // 웹 크롤링
|
||||||
app.use("/api/material-status", materialStatusRoutes); // 자재현황
|
app.use("/api/material-status", materialStatusRoutes); // 자재현황
|
||||||
app.use("/api/process-info", processInfoRoutes); // 공정정보관리
|
app.use("/api/process-info", processInfoRoutes); // 공정정보관리
|
||||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||||
|
|
@ -353,6 +372,7 @@ app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
||||||
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||||
app.use("/api/design", designRoutes); // 설계 모듈
|
app.use("/api/design", designRoutes); // 설계 모듈
|
||||||
app.use("/api/receiving", receivingRoutes); // 입고관리
|
app.use("/api/receiving", receivingRoutes); // 입고관리
|
||||||
|
app.use("/api/outbound", outboundRoutes); // 출고관리
|
||||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
|
|
@ -413,6 +433,11 @@ async function initializeServices() {
|
||||||
try {
|
try {
|
||||||
await BatchSchedulerService.initializeScheduler();
|
await BatchSchedulerService.initializeScheduler();
|
||||||
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
|
||||||
|
|
||||||
|
// 크롤링 스케줄러 초기화
|
||||||
|
const { CrawlService } = await import("./services/crawlService");
|
||||||
|
await CrawlService.initializeScheduler();
|
||||||
|
logger.info(`🕷️ 크롤링 스케줄러가 시작되었습니다.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2504,7 +2504,9 @@ export const changeUserStatus = async (
|
||||||
// 필수 파라미터 검증
|
// 필수 파라미터 검증
|
||||||
if (!userId || !status) {
|
if (!userId || !status) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
result: false,
|
result: false,
|
||||||
|
message: "사용자 ID와 상태는 필수입니다.",
|
||||||
msg: "사용자 ID와 상태는 필수입니다.",
|
msg: "사용자 ID와 상태는 필수입니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -2513,7 +2515,9 @@ export const changeUserStatus = async (
|
||||||
// 상태 값 검증
|
// 상태 값 검증
|
||||||
if (!["active", "inactive"].includes(status)) {
|
if (!["active", "inactive"].includes(status)) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
result: false,
|
result: false,
|
||||||
|
message: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
|
||||||
msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
|
msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -2528,7 +2532,9 @@ export const changeUserStatus = async (
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
result: false,
|
result: false,
|
||||||
|
message: "사용자를 찾을 수 없습니다.",
|
||||||
msg: "사용자를 찾을 수 없습니다.",
|
msg: "사용자를 찾을 수 없습니다.",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -2549,6 +2555,12 @@ export const changeUserStatus = async (
|
||||||
if (updateResult.length > 0) {
|
if (updateResult.length > 0) {
|
||||||
// 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
|
// 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
|
||||||
|
|
||||||
|
// inactive로 변경 시 기존 JWT 토큰 무효화
|
||||||
|
if (status === "inactive") {
|
||||||
|
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||||
|
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("사용자 상태 변경 성공", {
|
logger.info("사용자 상태 변경 성공", {
|
||||||
userId,
|
userId,
|
||||||
oldStatus: currentUser.status,
|
oldStatus: currentUser.status,
|
||||||
|
|
@ -2571,12 +2583,16 @@ export const changeUserStatus = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
success: true,
|
||||||
result: true,
|
result: true,
|
||||||
|
message: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||||
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
result: false,
|
result: false,
|
||||||
|
message: "사용자 상태 변경에 실패했습니다.",
|
||||||
msg: "사용자 상태 변경에 실패했습니다.",
|
msg: "사용자 상태 변경에 실패했습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -2587,7 +2603,9 @@ export const changeUserStatus = async (
|
||||||
status: req.body.status,
|
status: req.body.status,
|
||||||
});
|
});
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
result: false,
|
result: false,
|
||||||
|
message: "시스템 오류가 발생했습니다.",
|
||||||
msg: "시스템 오류가 발생했습니다.",
|
msg: "시스템 오류가 발생했습니다.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -2627,12 +2645,214 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 추가 유효성 검증
|
||||||
|
|
||||||
|
// 1. email 형식 검증 (값이 있는 경우만)
|
||||||
|
if (userData.email && userData.email.trim() !== "") {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(userData.email.trim())) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "이메일 형식이 올바르지 않습니다.",
|
||||||
|
error: {
|
||||||
|
code: "INVALID_EMAIL_FORMAT",
|
||||||
|
details: `Invalid email format: ${userData.email}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. companyCode 존재 확인 (값이 있는 경우만)
|
||||||
|
if (userData.companyCode && userData.companyCode.trim() !== "") {
|
||||||
|
const companyExists = await queryOne<{ company_code: string }>(
|
||||||
|
`SELECT company_code FROM company_mng WHERE company_code = $1`,
|
||||||
|
[userData.companyCode.trim()]
|
||||||
|
);
|
||||||
|
if (!companyExists) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `존재하지 않는 회사 코드입니다: ${userData.companyCode}`,
|
||||||
|
error: {
|
||||||
|
code: "INVALID_COMPANY_CODE",
|
||||||
|
details: `Company code not found: ${userData.companyCode}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. userType 유효값 검증 (값이 있는 경우만)
|
||||||
|
if (userData.userType && userData.userType.trim() !== "") {
|
||||||
|
const validUserTypes = ["SUPER_ADMIN", "COMPANY_ADMIN", "USER", "GUEST", "PARTNER"];
|
||||||
|
if (!validUserTypes.includes(userData.userType.trim())) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `유효하지 않은 사용자 유형입니다: ${userData.userType}. 허용값: ${validUserTypes.join(", ")}`,
|
||||||
|
error: {
|
||||||
|
code: "INVALID_USER_TYPE",
|
||||||
|
details: `Invalid userType: ${userData.userType}. Allowed: ${validUserTypes.join(", ")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 비밀번호 최소 길이 검증 (신규 등록 시)
|
||||||
|
if (!isUpdate && userData.userPassword && userData.userPassword.length < 4) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "비밀번호는 최소 4자 이상이어야 합니다.",
|
||||||
|
error: {
|
||||||
|
code: "PASSWORD_TOO_SHORT",
|
||||||
|
details: "Password must be at least 4 characters long",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 비밀번호 암호화 (비밀번호가 제공된 경우에만)
|
// 비밀번호 암호화 (비밀번호가 제공된 경우에만)
|
||||||
let encryptedPassword = null;
|
let encryptedPassword = null;
|
||||||
if (userData.userPassword) {
|
if (userData.userPassword) {
|
||||||
encryptedPassword = await EncryptUtil.encrypt(userData.userPassword);
|
encryptedPassword = await EncryptUtil.encrypt(userData.userPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PUT(수정) 요청 시 company_code / dept_code 변경 감지
|
||||||
|
if (isUpdate) {
|
||||||
|
const existingUser = await queryOne<{ company_code: string; dept_code: string }>(
|
||||||
|
`SELECT company_code, dept_code FROM user_info WHERE user_id = $1`,
|
||||||
|
[userData.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// company_code 변경 감지 → 이전 회사 권한 그룹 제거
|
||||||
|
if (
|
||||||
|
userData.companyCode &&
|
||||||
|
existingUser &&
|
||||||
|
existingUser.company_code &&
|
||||||
|
existingUser.company_code !== userData.companyCode
|
||||||
|
) {
|
||||||
|
const oldCompanyCode = existingUser.company_code;
|
||||||
|
logger.info("사용자 회사 코드 변경 감지 - 이전 회사 권한 그룹 제거", {
|
||||||
|
userId: userData.userId,
|
||||||
|
oldCompanyCode,
|
||||||
|
newCompanyCode: userData.companyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 이전 회사의 권한 그룹에서 해당 사용자 제거
|
||||||
|
await query(
|
||||||
|
`DELETE FROM authority_sub_user
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND master_objid IN (
|
||||||
|
SELECT objid FROM authority_master WHERE company_code = $2
|
||||||
|
)`,
|
||||||
|
[userData.userId, oldCompanyCode]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// dept_code 변경 감지 → 결재 템플릿/진행중 결재라인 경고 로그
|
||||||
|
const newDeptCode = userData.deptCode || null;
|
||||||
|
const oldDeptCode = existingUser?.dept_code || null;
|
||||||
|
if (existingUser && oldDeptCode && newDeptCode && oldDeptCode !== newDeptCode) {
|
||||||
|
logger.warn("사용자 부서 변경 감지 - 결재라인 영향 확인 시작", {
|
||||||
|
userId: userData.userId,
|
||||||
|
userName: userData.userName,
|
||||||
|
oldDeptCode,
|
||||||
|
newDeptCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) 결재선 템플릿 스텝에서 해당 사용자가 결재자로 등록된 건 조회
|
||||||
|
const templateSteps = await query<{
|
||||||
|
template_id: number;
|
||||||
|
step_order: number;
|
||||||
|
approver_label: string | null;
|
||||||
|
approver_dept_code: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT s.template_id, s.step_order, s.approver_label, s.approver_dept_code
|
||||||
|
FROM approval_line_template_steps s
|
||||||
|
WHERE s.approver_user_id = $1`,
|
||||||
|
[userData.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (templateSteps && templateSteps.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})가 결재선 템플릿 ${templateSteps.length}건에 결재자로 등록되어 있습니다. 수동 확인이 필요합니다.`,
|
||||||
|
{
|
||||||
|
userId: userData.userId,
|
||||||
|
oldDeptCode,
|
||||||
|
newDeptCode,
|
||||||
|
affectedTemplates: templateSteps.map((s) => ({
|
||||||
|
templateId: s.template_id,
|
||||||
|
stepOrder: s.step_order,
|
||||||
|
label: s.approver_label,
|
||||||
|
currentDeptInStep: s.approver_dept_code,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 진행중인 결재 요청에서 해당 사용자가 대기중 결재자인 건 조회
|
||||||
|
const pendingLines = await query<{
|
||||||
|
request_id: number;
|
||||||
|
step_order: number;
|
||||||
|
approver_dept: string | null;
|
||||||
|
status: string;
|
||||||
|
}>(
|
||||||
|
`SELECT l.request_id, l.step_order, l.approver_dept, l.status
|
||||||
|
FROM approval_lines l
|
||||||
|
JOIN approval_requests r ON r.request_id = l.request_id
|
||||||
|
WHERE l.approver_id = $1
|
||||||
|
AND l.status = 'pending'
|
||||||
|
AND r.status IN ('in_progress', 'pending')`,
|
||||||
|
[userData.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pendingLines && pendingLines.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})에게 대기중인 결재 ${pendingLines.length}건이 있습니다. 수동 확인이 필요합니다.`,
|
||||||
|
{
|
||||||
|
userId: userData.userId,
|
||||||
|
oldDeptCode,
|
||||||
|
newDeptCode,
|
||||||
|
pendingApprovals: pendingLines.map((l) => ({
|
||||||
|
requestId: l.request_id,
|
||||||
|
stepOrder: l.step_order,
|
||||||
|
currentDeptInLine: l.approver_dept,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 감사 로그 기록
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: userData.companyCode || req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "DEPT_CHANGE_WARNING",
|
||||||
|
resourceType: "USER",
|
||||||
|
resourceId: userData.userId,
|
||||||
|
resourceName: userData.userName,
|
||||||
|
summary: `사용자 "${userData.userName}"의 부서 변경 (${oldDeptCode} → ${newDeptCode}). 결재 템플릿 ${templateSteps?.length || 0}건, 대기중 결재 ${pendingLines?.length || 0}건 영향 가능`,
|
||||||
|
changes: {
|
||||||
|
before: { deptCode: oldDeptCode },
|
||||||
|
after: {
|
||||||
|
deptCode: newDeptCode,
|
||||||
|
affectedTemplateCount: templateSteps?.length || 0,
|
||||||
|
pendingApprovalCount: pendingLines?.length || 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
} catch (approvalCheckError) {
|
||||||
|
// 결재 테이블이 없는 환경에서도 사용자 저장은 계속 진행
|
||||||
|
logger.warn("결재라인 영향 확인 중 오류 (사용자 저장은 계속 진행)", {
|
||||||
|
error: approvalCheckError instanceof Error ? approvalCheckError.message : approvalCheckError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT)
|
// Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT)
|
||||||
const updatePasswordClause = encryptedPassword ? "user_password = $4," : "";
|
const updatePasswordClause = encryptedPassword ? "user_password = $4," : "";
|
||||||
|
|
||||||
|
|
@ -2688,6 +2908,12 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
savedUser.regdate &&
|
savedUser.regdate &&
|
||||||
new Date(savedUser.regdate).getTime() < Date.now() - 1000;
|
new Date(savedUser.regdate).getTime() < Date.now() - 1000;
|
||||||
|
|
||||||
|
// 기존 사용자의 비밀번호 변경 시 JWT 토큰 무효화
|
||||||
|
if (encryptedPassword && isExistingUser) {
|
||||||
|
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||||
|
await TokenInvalidationService.invalidateUserTokens(userData.userId);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료",
|
isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료",
|
||||||
{
|
{
|
||||||
|
|
@ -3534,6 +3760,10 @@ export const resetUserPassword = async (
|
||||||
if (updateResult.length > 0) {
|
if (updateResult.length > 0) {
|
||||||
// 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
|
// 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
|
||||||
|
|
||||||
|
// 비밀번호 변경 후 기존 JWT 토큰 무효화
|
||||||
|
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||||
|
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||||
|
|
||||||
logger.info("비밀번호 초기화 성공", {
|
logger.info("비밀번호 초기화 성공", {
|
||||||
userId,
|
userId,
|
||||||
updatedBy: req.user?.userId,
|
updatedBy: req.user?.userId,
|
||||||
|
|
@ -4153,6 +4383,140 @@ export const saveUserWithDept = async (
|
||||||
* GET /api/admin/users/:userId/with-dept
|
* GET /api/admin/users/:userId/with-dept
|
||||||
* 사원 + 부서 정보 조회 API (수정 모달용)
|
* 사원 + 부서 정보 조회 API (수정 모달용)
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* DELETE /api/admin/users/:userId
|
||||||
|
* 사용자 삭제 API (soft delete)
|
||||||
|
* status = 'deleted', end_date = now() 설정
|
||||||
|
* authority_sub_user 멤버십 제거, JWT 토큰 무효화
|
||||||
|
*/
|
||||||
|
export const deleteUser = async (
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
|
||||||
|
// 1. userId 파라미터 검증
|
||||||
|
if (!userId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
result: false,
|
||||||
|
message: "사용자 ID는 필수입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 자기 자신 삭제 방지
|
||||||
|
if (req.user?.userId === userId) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
result: false,
|
||||||
|
message: "자기 자신은 삭제할 수 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 사용자 존재 여부 확인
|
||||||
|
const currentUser = await queryOne<any>(
|
||||||
|
`SELECT user_id, user_name, status, company_code FROM user_info WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
result: false,
|
||||||
|
message: "사용자를 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 삭제된 사용자 체크
|
||||||
|
if (currentUser.status === "deleted") {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
result: false,
|
||||||
|
message: "이미 삭제된 사용자입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. soft delete: status = 'deleted', end_date = now()
|
||||||
|
const updateResult = await query<any>(
|
||||||
|
`UPDATE user_info
|
||||||
|
SET status = 'deleted', end_date = NOW()
|
||||||
|
WHERE user_id = $1
|
||||||
|
RETURNING *`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (updateResult.length === 0) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
result: false,
|
||||||
|
message: "사용자 삭제에 실패했습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. authority_sub_user에서 해당 사용자 멤버십 제거
|
||||||
|
await query(
|
||||||
|
`DELETE FROM authority_sub_user WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 6. JWT 토큰 무효화
|
||||||
|
try {
|
||||||
|
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||||
|
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||||
|
} catch (tokenError) {
|
||||||
|
logger.warn("토큰 무효화 중 오류 (삭제는 정상 처리됨)", { userId, error: tokenError });
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("사용자 삭제(soft delete) 성공", {
|
||||||
|
userId,
|
||||||
|
userName: currentUser.user_name,
|
||||||
|
deletedBy: req.user?.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. 감사 로그 기록
|
||||||
|
auditLogService.log({
|
||||||
|
companyCode: currentUser.company_code || req.user?.companyCode || "",
|
||||||
|
userId: req.user?.userId || "",
|
||||||
|
userName: req.user?.userName || "",
|
||||||
|
action: "DELETE",
|
||||||
|
resourceType: "USER",
|
||||||
|
resourceId: userId,
|
||||||
|
resourceName: currentUser.user_name,
|
||||||
|
summary: `사용자 "${currentUser.user_name}" (${userId}) 삭제 처리`,
|
||||||
|
changes: {
|
||||||
|
before: { status: currentUser.status },
|
||||||
|
after: { status: "deleted" },
|
||||||
|
fields: ["status", "end_date"],
|
||||||
|
},
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
requestPath: req.originalUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. 응답
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
result: true,
|
||||||
|
message: `사용자 "${currentUser.user_name}" (${userId})이(가) 삭제되었습니다.`,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("사용자 삭제 중 오류 발생", {
|
||||||
|
error: error.message,
|
||||||
|
userId: req.params.userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
result: false,
|
||||||
|
message: "시스템 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getUserWithDept = async (
|
export const getUserWithDept = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
res: Response
|
res: Response
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { CrawlService } from "../services/crawlService";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
interface AuthenticatedRequest extends Request {
|
||||||
|
user?: { companyCode: string; userId: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설정 목록 조회
|
||||||
|
export async function getCrawlConfigs(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const companyCode = req.user?.companyCode || "*";
|
||||||
|
const configs = await CrawlService.getConfigs(companyCode);
|
||||||
|
return res.json({ success: true, data: configs });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("크롤링 설정 조회 실패:", error);
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설정 상세 조회
|
||||||
|
export async function getCrawlConfig(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const config = await CrawlService.getConfigById(req.params.id);
|
||||||
|
if (!config) return res.status(404).json({ success: false, message: "설정을 찾을 수 없습니다." });
|
||||||
|
return res.json({ success: true, data: config });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("크롤링 설정 상세 조회 실패:", error);
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설정 생성
|
||||||
|
export async function createCrawlConfig(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
...req.body,
|
||||||
|
company_code: req.user?.companyCode || req.body.company_code,
|
||||||
|
writer: req.user?.userId,
|
||||||
|
};
|
||||||
|
const config = await CrawlService.createConfig(data);
|
||||||
|
return res.json({ success: true, data: config });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("크롤링 설정 생성 실패:", error);
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설정 수정
|
||||||
|
export async function updateCrawlConfig(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const config = await CrawlService.updateConfig(req.params.id, req.body);
|
||||||
|
if (!config) return res.status(404).json({ success: false, message: "설정을 찾을 수 없습니다." });
|
||||||
|
return res.json({ success: true, data: config });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("크롤링 설정 수정 실패:", error);
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설정 삭제
|
||||||
|
export async function deleteCrawlConfig(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
await CrawlService.deleteConfig(req.params.id);
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("크롤링 설정 삭제 실패:", error);
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미리보기
|
||||||
|
export async function previewCrawl(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { url, row_selector, column_mappings, method, headers, request_body } = req.body;
|
||||||
|
if (!url) return res.status(400).json({ success: false, message: "URL은 필수입니다." });
|
||||||
|
|
||||||
|
const result = await CrawlService.preview(url, row_selector, column_mappings || [], method, headers, request_body);
|
||||||
|
return res.json({ success: true, data: result });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("크롤링 미리보기 실패:", error);
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL 자동 분석 — 페이지의 테이블/리스트 구조를 감지
|
||||||
|
export async function analyzeUrl(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const { url } = req.body;
|
||||||
|
if (!url) return res.status(400).json({ success: false, message: "URL은 필수입니다." });
|
||||||
|
|
||||||
|
const result = await CrawlService.analyzeUrl(url);
|
||||||
|
return res.json({ success: true, data: result });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("URL 분석 실패:", error);
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 수동 실행
|
||||||
|
export async function executeCrawl(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const config = await CrawlService.getConfigById(req.params.id);
|
||||||
|
if (!config) return res.status(404).json({ success: false, message: "설정을 찾을 수 없습니다." });
|
||||||
|
|
||||||
|
const result = await CrawlService.executeCrawl(config);
|
||||||
|
return res.json({ success: true, data: result });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("크롤링 수동 실행 실패:", error);
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실행 로그 조회
|
||||||
|
export async function getCrawlLogs(req: AuthenticatedRequest, res: Response) {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit as string) || 20;
|
||||||
|
const logs = await CrawlService.getLogs(req.params.id, limit);
|
||||||
|
return res.json({ success: true, data: logs });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("크롤링 로그 조회 실패:", error);
|
||||||
|
return res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -561,6 +561,34 @@ export class EntityJoinController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용)
|
||||||
|
* GET /api/table-management/tables/:tableName/column-values/:columnName
|
||||||
|
*/
|
||||||
|
async getColumnUniqueValues(req: Request, res: Response): Promise<void> {
|
||||||
|
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();
|
export const entityJoinController = new EntityJoinController();
|
||||||
|
|
|
||||||
|
|
@ -191,18 +191,30 @@ export const getLangKeys = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
|
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
logger.info("다국어 키 목록 조회 요청", {
|
logger.info("다국어 키 목록 조회 요청", {
|
||||||
query: req.query,
|
query: req.query,
|
||||||
user: req.user,
|
user: req.user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// company_code 필터링: 비관리자는 자기 회사 + 공통(*) 키만 조회 가능
|
||||||
|
let effectiveCompanyCode = companyCode as string;
|
||||||
|
if (userCompanyCode !== "*") {
|
||||||
|
// 비관리자가 다른 회사의 데이터를 요청하면 자기 회사로 제한
|
||||||
|
if (companyCode && companyCode !== userCompanyCode && companyCode !== "*") {
|
||||||
|
effectiveCompanyCode = userCompanyCode || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
const multiLangService = new MultiLangService();
|
||||||
const langKeys = await multiLangService.getLangKeys({
|
const langKeys = await multiLangService.getLangKeys({
|
||||||
companyCode: companyCode as string,
|
companyCode: effectiveCompanyCode,
|
||||||
menuCode: menuCode as string,
|
menuCode: menuCode as string,
|
||||||
keyType: keyType as string,
|
keyType: keyType as string,
|
||||||
searchText: searchText as string,
|
searchText: searchText as string,
|
||||||
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
|
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
|
||||||
|
// 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환
|
||||||
|
userCompanyCode: userCompanyCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: ApiResponse<any[]> = {
|
const response: ApiResponse<any[]> = {
|
||||||
|
|
@ -235,9 +247,24 @@ export const getLangTexts = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { keyId } = req.params;
|
const { keyId } = req.params;
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user });
|
logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user });
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
const multiLangService = new MultiLangService();
|
||||||
|
|
||||||
|
// 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증
|
||||||
|
if (userCompanyCode !== "*") {
|
||||||
|
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||||
|
if (keyOwner && keyOwner !== "*" && keyOwner !== userCompanyCode) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "다른 회사의 다국어 키에 접근할 권한이 없습니다.",
|
||||||
|
error: { code: "PERMISSION_DENIED" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const langTexts = await multiLangService.getLangTexts(parseInt(keyId));
|
const langTexts = await multiLangService.getLangTexts(parseInt(keyId));
|
||||||
|
|
||||||
const response: ApiResponse<any[]> = {
|
const response: ApiResponse<any[]> = {
|
||||||
|
|
@ -270,6 +297,7 @@ export const createLangKey = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const keyData: CreateLangKeyRequest = req.body;
|
const keyData: CreateLangKeyRequest = req.body;
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
logger.info("다국어 키 생성 요청", { keyData, user: req.user });
|
logger.info("다국어 키 생성 요청", { keyData, user: req.user });
|
||||||
|
|
||||||
// 필수 입력값 검증
|
// 필수 입력값 검증
|
||||||
|
|
@ -285,6 +313,26 @@ export const createLangKey = async (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
|
||||||
|
if (keyData.companyCode === "*" && userCompanyCode !== "*") {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
|
||||||
|
error: { code: "PERMISSION_DENIED" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비관리자: 자기 회사 키만 생성 가능
|
||||||
|
if (userCompanyCode !== "*" && keyData.companyCode !== userCompanyCode) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "다른 회사의 키를 생성할 권한이 없습니다.",
|
||||||
|
error: { code: "PERMISSION_DENIED" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
const multiLangService = new MultiLangService();
|
||||||
const keyId = await multiLangService.createLangKey({
|
const keyId = await multiLangService.createLangKey({
|
||||||
...keyData,
|
...keyData,
|
||||||
|
|
@ -323,10 +371,33 @@ export const updateLangKey = async (
|
||||||
try {
|
try {
|
||||||
const { keyId } = req.params;
|
const { keyId } = req.params;
|
||||||
const keyData: UpdateLangKeyRequest = req.body;
|
const keyData: UpdateLangKeyRequest = req.body;
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
|
|
||||||
logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user });
|
logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user });
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
const multiLangService = new MultiLangService();
|
||||||
|
|
||||||
|
// 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키는 수정 불가)
|
||||||
|
if (userCompanyCode !== "*") {
|
||||||
|
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||||
|
if (!keyOwner) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "다국어 키를 찾을 수 없습니다.",
|
||||||
|
error: { code: "KEY_NOT_FOUND" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyOwner !== userCompanyCode) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "다른 회사의 다국어 키를 수정할 권한이 없습니다.",
|
||||||
|
error: { code: "PERMISSION_DENIED" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await multiLangService.updateLangKey(parseInt(keyId), {
|
await multiLangService.updateLangKey(parseInt(keyId), {
|
||||||
...keyData,
|
...keyData,
|
||||||
updatedBy: req.user?.userId || "system",
|
updatedBy: req.user?.userId || "system",
|
||||||
|
|
@ -362,9 +433,32 @@ export const deleteLangKey = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { keyId } = req.params;
|
const { keyId } = req.params;
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
logger.info("다국어 키 삭제 요청", { keyId, user: req.user });
|
logger.info("다국어 키 삭제 요청", { keyId, user: req.user });
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
const multiLangService = new MultiLangService();
|
||||||
|
|
||||||
|
// 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키 삭제 불가)
|
||||||
|
if (userCompanyCode !== "*") {
|
||||||
|
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||||
|
if (!keyOwner) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "다국어 키를 찾을 수 없습니다.",
|
||||||
|
error: { code: "KEY_NOT_FOUND" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyOwner !== userCompanyCode) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "다른 회사의 다국어 키를 삭제할 권한이 없습니다.",
|
||||||
|
error: { code: "PERMISSION_DENIED" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await multiLangService.deleteLangKey(parseInt(keyId));
|
await multiLangService.deleteLangKey(parseInt(keyId));
|
||||||
|
|
||||||
const response: ApiResponse<string> = {
|
const response: ApiResponse<string> = {
|
||||||
|
|
@ -397,9 +491,32 @@ export const toggleLangKey = async (
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { keyId } = req.params;
|
const { keyId } = req.params;
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user });
|
logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user });
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
const multiLangService = new MultiLangService();
|
||||||
|
|
||||||
|
// 비관리자: 해당 키가 자기 회사 키인지 검증
|
||||||
|
if (userCompanyCode !== "*") {
|
||||||
|
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||||
|
if (!keyOwner) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "다국어 키를 찾을 수 없습니다.",
|
||||||
|
error: { code: "KEY_NOT_FOUND" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyOwner !== userCompanyCode) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "다른 회사의 다국어 키를 변경할 권한이 없습니다.",
|
||||||
|
error: { code: "PERMISSION_DENIED" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await multiLangService.toggleLangKey(parseInt(keyId));
|
const result = await multiLangService.toggleLangKey(parseInt(keyId));
|
||||||
|
|
||||||
const response: ApiResponse<string> = {
|
const response: ApiResponse<string> = {
|
||||||
|
|
@ -433,6 +550,7 @@ export const saveLangTexts = async (
|
||||||
try {
|
try {
|
||||||
const { keyId } = req.params;
|
const { keyId } = req.params;
|
||||||
const textData: SaveLangTextsRequest = req.body;
|
const textData: SaveLangTextsRequest = req.body;
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
|
|
||||||
logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user });
|
logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user });
|
||||||
|
|
||||||
|
|
@ -454,6 +572,28 @@ export const saveLangTexts = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
const multiLangService = new MultiLangService();
|
const multiLangService = new MultiLangService();
|
||||||
|
|
||||||
|
// 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증
|
||||||
|
if (userCompanyCode !== "*") {
|
||||||
|
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||||
|
if (!keyOwner) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "다국어 키를 찾을 수 없습니다.",
|
||||||
|
error: { code: "KEY_NOT_FOUND" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (keyOwner !== userCompanyCode) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: "다른 회사의 다국어 텍스트를 수정할 권한이 없습니다.",
|
||||||
|
error: { code: "PERMISSION_DENIED" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await multiLangService.saveLangTexts(parseInt(keyId), {
|
await multiLangService.saveLangTexts(parseInt(keyId), {
|
||||||
texts: textData.texts.map((text) => ({
|
texts: textData.texts.map((text) => ({
|
||||||
...text,
|
...text,
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -173,7 +173,11 @@ export async function getPkgUnitItems(
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
const result = await pool.query(
|
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]
|
[pkgCode, companyCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -410,7 +414,11 @@ export async function getLoadingUnitPkgs(
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
|
|
||||||
const result = await pool.query(
|
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]
|
[loadingCode, companyCode]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -476,3 +484,112 @@ export async function deleteLoadingUnitPkg(
|
||||||
res.status(500).json({ success: false, message: error.message });
|
res.status(500).json({ success: false, message: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// 품목정보 연동 (division별 item_info 조회)
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getItemsByDivision(
|
||||||
|
req: AuthenticatedRequest,
|
||||||
|
res: Response
|
||||||
|
): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -148,9 +148,9 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo
|
||||||
const { processCode } = req.params;
|
const { processCode } = req.params;
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT pe.*, ei.equipment_name
|
`SELECT pe.*, em.equipment_name
|
||||||
FROM process_equipment pe
|
FROM process_equipment pe
|
||||||
LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code
|
LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code
|
||||||
WHERE pe.process_code = $1 AND pe.company_code = $2
|
WHERE pe.process_code = $1 AND pe.company_code = $2
|
||||||
ORDER BY pe.equipment_code`,
|
ORDER BY pe.equipment_code`,
|
||||||
[processCode, companyCode]
|
[processCode, companyCode]
|
||||||
|
|
@ -214,7 +214,7 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response)
|
||||||
const params = companyCode === "*" ? [] : [companyCode];
|
const params = companyCode === "*" ? [] : [companyCode];
|
||||||
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`,
|
`SELECT id, equipment_code, equipment_name FROM equipment_mng ${condition} ORDER BY equipment_code`,
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
export async function getPlanById(req: AuthenticatedRequest, res: Response) {
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,42 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
||||||
|
|
||||||
insertedRows.push(result.rows[0]);
|
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 업데이트
|
// 구매입고인 경우 발주의 received_qty 업데이트
|
||||||
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
|
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,10 @@ export const addRoleMembers = async (
|
||||||
req.user?.userId || "SYSTEM"
|
req.user?.userId || "SYSTEM"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||||
|
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||||
|
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
|
||||||
|
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "권한 그룹 멤버 추가 성공",
|
message: "권한 그룹 멤버 추가 성공",
|
||||||
|
|
@ -568,6 +572,13 @@ export const updateRoleMembers = async (
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||||
|
const allAffectedUsers = [...new Set([...toAdd, ...toRemove])];
|
||||||
|
if (allAffectedUsers.length > 0) {
|
||||||
|
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||||
|
await TokenInvalidationService.invalidateMultipleUserTokens(allAffectedUsers);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info("권한 그룹 멤버 일괄 업데이트 성공", {
|
logger.info("권한 그룹 멤버 일괄 업데이트 성공", {
|
||||||
masterObjid,
|
masterObjid,
|
||||||
added: toAdd.length,
|
added: toAdd.length,
|
||||||
|
|
@ -646,6 +657,10 @@ export const removeRoleMembers = async (
|
||||||
req.user?.userId || "SYSTEM"
|
req.user?.userId || "SYSTEM"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||||
|
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||||
|
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
|
||||||
|
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "권한 그룹 멤버 제거 성공",
|
message: "권한 그룹 멤버 제거 성공",
|
||||||
|
|
@ -777,6 +792,18 @@ export const setMenuPermissions = async (
|
||||||
req.user?.userId || "SYSTEM"
|
req.user?.userId || "SYSTEM"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 해당 권한 그룹의 모든 멤버 JWT 토큰 무효화
|
||||||
|
try {
|
||||||
|
const members = await RoleService.getRoleMembers(authObjid);
|
||||||
|
const memberIds = members.map((m: any) => m.userId);
|
||||||
|
if (memberIds.length > 0) {
|
||||||
|
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||||
|
await TokenInvalidationService.invalidateMultipleUserTokens(memberIds);
|
||||||
|
}
|
||||||
|
} catch (invalidateError) {
|
||||||
|
logger.warn("메뉴 권한 변경 후 토큰 무효화 실패 (권한 설정은 성공)", { invalidateError });
|
||||||
|
}
|
||||||
|
|
||||||
const response: ApiResponse<null> = {
|
const response: ApiResponse<null> = {
|
||||||
success: true,
|
success: true,
|
||||||
message: "메뉴 권한 설정 성공",
|
message: "메뉴 권한 설정 성공",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Request, Response, NextFunction } from "express";
|
||||||
import { JwtUtils } from "../utils/jwtUtils";
|
import { JwtUtils } from "../utils/jwtUtils";
|
||||||
import { AuthenticatedRequest, PersonBean } from "../types/auth";
|
import { AuthenticatedRequest, PersonBean } from "../types/auth";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
import { TokenInvalidationService } from "../services/tokenInvalidationService";
|
||||||
|
|
||||||
// AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export
|
// AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export
|
||||||
export { AuthenticatedRequest } from "../types/auth";
|
export { AuthenticatedRequest } from "../types/auth";
|
||||||
|
|
@ -22,11 +23,11 @@ declare global {
|
||||||
* JWT 토큰 검증 미들웨어
|
* JWT 토큰 검증 미들웨어
|
||||||
* 기존 세션 방식과 동일한 효과를 제공
|
* 기존 세션 방식과 동일한 효과를 제공
|
||||||
*/
|
*/
|
||||||
export const authenticateToken = (
|
export const authenticateToken = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): void => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Authorization 헤더에서 토큰 추출
|
// Authorization 헤더에서 토큰 추출
|
||||||
const authHeader = req.get("Authorization");
|
const authHeader = req.get("Authorization");
|
||||||
|
|
@ -46,6 +47,25 @@ export const authenticateToken = (
|
||||||
// JWT 토큰 검증 및 사용자 정보 추출
|
// JWT 토큰 검증 및 사용자 정보 추출
|
||||||
const userInfo: PersonBean = JwtUtils.verifyToken(token);
|
const userInfo: PersonBean = JwtUtils.verifyToken(token);
|
||||||
|
|
||||||
|
// token_version 검증 (JWT payload vs DB)
|
||||||
|
const decoded = JwtUtils.decodeToken(token);
|
||||||
|
const tokenVersion = decoded?.tokenVersion;
|
||||||
|
|
||||||
|
// tokenVersion이 undefined면 구버전 토큰이므로 통과 (하위 호환)
|
||||||
|
if (tokenVersion !== undefined) {
|
||||||
|
const dbVersion = await TokenInvalidationService.getUserTokenVersion(userInfo.userId);
|
||||||
|
if (tokenVersion !== dbVersion) {
|
||||||
|
res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: "TOKEN_INVALIDATED",
|
||||||
|
details: "보안 정책에 의해 재로그인이 필요합니다.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 요청 객체에 사용자 정보 설정 (기존 PersonBean과 동일)
|
// 요청 객체에 사용자 정보 설정 (기존 PersonBean과 동일)
|
||||||
req.user = userInfo;
|
req.user = userInfo;
|
||||||
|
|
||||||
|
|
@ -173,11 +193,11 @@ export const requireUserOrAdmin = (targetUserId: string) => {
|
||||||
* 토큰 갱신 미들웨어
|
* 토큰 갱신 미들웨어
|
||||||
* 토큰이 곧 만료될 경우 자동으로 갱신
|
* 토큰이 곧 만료될 경우 자동으로 갱신
|
||||||
*/
|
*/
|
||||||
export const refreshTokenIfNeeded = (
|
export const refreshTokenIfNeeded = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): void => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const authHeader = req.get("Authorization");
|
const authHeader = req.get("Authorization");
|
||||||
const token = authHeader && authHeader.split(" ")[1];
|
const token = authHeader && authHeader.split(" ")[1];
|
||||||
|
|
@ -191,6 +211,16 @@ export const refreshTokenIfNeeded = (
|
||||||
|
|
||||||
// 1시간(3600초) 이내에 만료되는 경우 갱신
|
// 1시간(3600초) 이내에 만료되는 경우 갱신
|
||||||
if (timeUntilExpiry > 0 && timeUntilExpiry < 3600) {
|
if (timeUntilExpiry > 0 && timeUntilExpiry < 3600) {
|
||||||
|
// 갱신 전 token_version 검증
|
||||||
|
if (decoded.tokenVersion !== undefined) {
|
||||||
|
const dbVersion = await TokenInvalidationService.getUserTokenVersion(decoded.userId);
|
||||||
|
if (decoded.tokenVersion !== dbVersion) {
|
||||||
|
// 무효화된 토큰은 갱신하지 않음
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newToken = JwtUtils.refreshToken(token);
|
const newToken = JwtUtils.refreshToken(token);
|
||||||
|
|
||||||
// 새로운 토큰을 응답 헤더에 포함
|
// 새로운 토큰을 응답 헤더에 포함
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
saveUser, // 사용자 등록/수정
|
saveUser, // 사용자 등록/수정
|
||||||
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
|
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
|
||||||
getUserWithDept, // 사원 + 부서 조회 (NEW!)
|
getUserWithDept, // 사원 + 부서 조회 (NEW!)
|
||||||
|
deleteUser, // 사용자 삭제 (soft delete)
|
||||||
getCompanyList,
|
getCompanyList,
|
||||||
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
||||||
getCompanyByCode, // 회사 단건 조회
|
getCompanyByCode, // 회사 단건 조회
|
||||||
|
|
@ -62,6 +63,7 @@ router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
||||||
router.put("/profile", updateProfile); // 프로필 수정
|
router.put("/profile", updateProfile); // 프로필 수정
|
||||||
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
||||||
router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화
|
router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화
|
||||||
|
router.delete("/users/:userId", deleteUser); // 사용자 삭제 (soft delete)
|
||||||
|
|
||||||
// 부서 관리 API
|
// 부서 관리 API
|
||||||
router.get("/departments", getDepartmentList); // 부서 목록 조회
|
router.get("/departments", getDepartmentList); // 부서 목록 조회
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import {
|
||||||
|
getCrawlConfigs,
|
||||||
|
getCrawlConfig,
|
||||||
|
createCrawlConfig,
|
||||||
|
updateCrawlConfig,
|
||||||
|
deleteCrawlConfig,
|
||||||
|
previewCrawl,
|
||||||
|
analyzeUrl,
|
||||||
|
executeCrawl,
|
||||||
|
getCrawlLogs,
|
||||||
|
} from "../controllers/crawlController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 설정 CRUD
|
||||||
|
router.get("/configs", authenticateToken, getCrawlConfigs);
|
||||||
|
router.get("/configs/:id", authenticateToken, getCrawlConfig);
|
||||||
|
router.post("/configs", authenticateToken, createCrawlConfig);
|
||||||
|
router.put("/configs/:id", authenticateToken, updateCrawlConfig);
|
||||||
|
router.delete("/configs/:id", authenticateToken, deleteCrawlConfig);
|
||||||
|
|
||||||
|
// 분석 & 미리보기 & 실행
|
||||||
|
router.post("/analyze", authenticateToken, analyzeUrl);
|
||||||
|
router.post("/preview", authenticateToken, previewCrawl);
|
||||||
|
router.post("/execute/:id", authenticateToken, executeCrawl);
|
||||||
|
|
||||||
|
// 실행 로그
|
||||||
|
router.get("/configs/:id/logs", authenticateToken, getCrawlLogs);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -55,6 +55,15 @@ router.get(
|
||||||
entityJoinController.getTableDataWithJoins.bind(entityJoinController)
|
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 조인 설정 관리
|
// 🎯 Entity 조인 설정 관리
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
import { Router, Request, Response } from "express";
|
||||||
|
import { getPool } from "../database/db";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// ---- 검사 기준 조회 (item_inspection_info) ----
|
||||||
|
// GET /api/pop/inspection-result/info?itemCode=ITEM-001&inspectionType=입고검사
|
||||||
|
router.get("/info", async (req: Request, res: Response) => {
|
||||||
|
const pool = getPool();
|
||||||
|
const companyCode = (req as any).user?.companyCode;
|
||||||
|
const { itemCode, itemId, inspectionType } = req.query;
|
||||||
|
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions: string[] = ["company_code = $1", "is_active = 'Y'"];
|
||||||
|
const params: unknown[] = [companyCode];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
if (itemCode) {
|
||||||
|
conditions.push(`item_code = $${idx++}`);
|
||||||
|
params.push(itemCode);
|
||||||
|
}
|
||||||
|
if (itemId) {
|
||||||
|
conditions.push(`item_id = $${idx++}`);
|
||||||
|
params.push(itemId);
|
||||||
|
}
|
||||||
|
if (inspectionType) {
|
||||||
|
conditions.push(`inspection_type = $${idx++}`);
|
||||||
|
params.push(inspectionType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT id, item_id, item_code, item_name,
|
||||||
|
inspection_type, inspection_item_name, inspection_standard,
|
||||||
|
inspection_method, pass_criteria, is_required, sort_order, memo
|
||||||
|
FROM item_inspection_info
|
||||||
|
WHERE ${conditions.join(" AND ")}
|
||||||
|
ORDER BY sort_order, inspection_item_name
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(sql, params);
|
||||||
|
return res.json({ success: true, data: result.rows });
|
||||||
|
} catch (err: any) {
|
||||||
|
return res.status(500).json({ success: false, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 검사 결과 조회 ----
|
||||||
|
// GET /api/pop/inspection-result?referenceId=xxx&referenceTable=yyy&screenId=zzz
|
||||||
|
router.get("/", async (req: Request, res: Response) => {
|
||||||
|
const pool = getPool();
|
||||||
|
const companyCode = (req as any).user?.companyCode;
|
||||||
|
const { referenceId, referenceTable, screenId } = req.query;
|
||||||
|
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const conditions: string[] = ["company_code = $1"];
|
||||||
|
const params: unknown[] = [companyCode];
|
||||||
|
let idx = 2;
|
||||||
|
|
||||||
|
if (referenceId) {
|
||||||
|
conditions.push(`reference_id = $${idx++}`);
|
||||||
|
params.push(referenceId);
|
||||||
|
}
|
||||||
|
if (referenceTable) {
|
||||||
|
conditions.push(`reference_table = $${idx++}`);
|
||||||
|
params.push(referenceTable);
|
||||||
|
}
|
||||||
|
if (screenId) {
|
||||||
|
conditions.push(`screen_id = $${idx++}`);
|
||||||
|
params.push(screenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT *
|
||||||
|
FROM inspection_result
|
||||||
|
WHERE ${conditions.join(" AND ")}
|
||||||
|
ORDER BY created_date DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query(sql, params);
|
||||||
|
return res.json({ success: true, data: result.rows });
|
||||||
|
} catch (err: any) {
|
||||||
|
return res.status(500).json({ success: false, message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 검사 결과 저장 (INSERT or UPDATE) ----
|
||||||
|
// POST /api/pop/inspection-result
|
||||||
|
router.post("/", async (req: Request, res: Response) => {
|
||||||
|
const pool = getPool();
|
||||||
|
const companyCode = (req as any).user?.companyCode;
|
||||||
|
const writer = (req as any).user?.userId;
|
||||||
|
|
||||||
|
if (!companyCode) {
|
||||||
|
return res.status(401).json({ success: false, message: "인증 정보 없음" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
referenceTable,
|
||||||
|
referenceId,
|
||||||
|
screenId,
|
||||||
|
itemId,
|
||||||
|
itemCode,
|
||||||
|
itemName,
|
||||||
|
inspectionType,
|
||||||
|
items, // 검사 항목별 결과 배열
|
||||||
|
overallJudgment,
|
||||||
|
memo,
|
||||||
|
isCompleted,
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, message: "검사 항목이 없습니다" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 기존 결과 삭제 (동일 referenceId + referenceTable 기준 덮어쓰기)
|
||||||
|
if (referenceId && referenceTable) {
|
||||||
|
await client.query(
|
||||||
|
`DELETE FROM inspection_result
|
||||||
|
WHERE company_code = $1 AND reference_id = $2 AND reference_table = $3`,
|
||||||
|
[companyCode, referenceId, referenceTable]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertedIds: string[] = [];
|
||||||
|
for (const item of items) {
|
||||||
|
const completedFlag = isCompleted ? "Y" : "N";
|
||||||
|
const completedDate = isCompleted ? new Date() : null;
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO inspection_result (
|
||||||
|
company_code, writer,
|
||||||
|
reference_table, reference_id, screen_id,
|
||||||
|
inspection_info_id, item_id, item_code, item_name,
|
||||||
|
inspection_type, inspection_item_name, inspection_standard, pass_criteria, is_required,
|
||||||
|
measured_value, judgment, overall_judgment, memo,
|
||||||
|
is_completed, completed_date
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const result = await client.query(insertSql, [
|
||||||
|
companyCode,
|
||||||
|
writer,
|
||||||
|
referenceTable || null,
|
||||||
|
referenceId || null,
|
||||||
|
screenId || null,
|
||||||
|
item.inspectionInfoId || null,
|
||||||
|
itemId || item.itemId || null,
|
||||||
|
itemCode || item.itemCode || null,
|
||||||
|
itemName || item.itemName || null,
|
||||||
|
inspectionType || item.inspectionType || null,
|
||||||
|
item.inspectionItemName || null,
|
||||||
|
item.inspectionStandard || null,
|
||||||
|
item.passCriteria || null,
|
||||||
|
item.isRequired || "Y",
|
||||||
|
item.measuredValue || null,
|
||||||
|
item.judgment || null,
|
||||||
|
overallJudgment || null,
|
||||||
|
memo || null,
|
||||||
|
completedFlag,
|
||||||
|
completedDate,
|
||||||
|
]);
|
||||||
|
insertedIds.push(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return res.json({ success: true, data: { ids: insertedIds } });
|
||||||
|
} catch (err: any) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
return res.status(500).json({ success: false, message: err.message });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
|
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
|
||||||
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
|
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
|
||||||
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
|
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
|
||||||
|
getItemsByDivision, getGeneralItems,
|
||||||
} from "../controllers/packagingController";
|
} from "../controllers/packagingController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -33,4 +34,8 @@ router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
|
||||||
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
|
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
|
||||||
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
|
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
|
||||||
|
|
||||||
|
// 품목정보 연동 (division별)
|
||||||
|
router.get("/items/general", getGeneralItems);
|
||||||
|
router.get("/items/:divisionLabel", getItemsByDivision);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@ import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
import {
|
import {
|
||||||
createWorkProcesses,
|
createWorkProcesses,
|
||||||
controlTimer,
|
controlTimer,
|
||||||
|
controlGroupTimer,
|
||||||
|
getDefectTypes,
|
||||||
|
saveResult,
|
||||||
|
confirmResult,
|
||||||
|
getResultHistory,
|
||||||
|
getAvailableQty,
|
||||||
|
acceptProcess,
|
||||||
|
cancelAccept,
|
||||||
} from "../controllers/popProductionController";
|
} from "../controllers/popProductionController";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
@ -11,5 +19,13 @@ router.use(authenticateToken);
|
||||||
|
|
||||||
router.post("/create-work-processes", createWorkProcesses);
|
router.post("/create-work-processes", createWorkProcesses);
|
||||||
router.post("/timer", controlTimer);
|
router.post("/timer", controlTimer);
|
||||||
|
router.post("/group-timer", controlGroupTimer);
|
||||||
|
router.get("/defect-types", getDefectTypes);
|
||||||
|
router.post("/save-result", saveResult);
|
||||||
|
router.post("/confirm-result", confirmResult);
|
||||||
|
router.get("/result-history", getResultHistory);
|
||||||
|
router.get("/available-qty", getAvailableQty);
|
||||||
|
router.post("/accept-process", acceptProcess);
|
||||||
|
router.post("/cancel-accept", cancelAccept);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ router.get("/order-summary", productionController.getOrderSummary);
|
||||||
// 안전재고 부족분 조회
|
// 안전재고 부족분 조회
|
||||||
router.get("/stock-shortage", productionController.getStockShortage);
|
router.get("/stock-shortage", productionController.getStockShortage);
|
||||||
|
|
||||||
|
// 생산계획 목록 조회
|
||||||
|
router.get("/plans", productionController.getPlans);
|
||||||
|
|
||||||
// 생산계획 CRUD
|
// 생산계획 CRUD
|
||||||
router.get("/plan/:id", productionController.getPlanById);
|
router.get("/plan/:id", productionController.getPlanById);
|
||||||
router.put("/plan/:id", productionController.updatePlan);
|
router.put("/plan/:id", productionController.updatePlan);
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@ export type AuditAction =
|
||||||
| "STATUS_CHANGE"
|
| "STATUS_CHANGE"
|
||||||
| "BATCH_CREATE"
|
| "BATCH_CREATE"
|
||||||
| "BATCH_UPDATE"
|
| "BATCH_UPDATE"
|
||||||
| "BATCH_DELETE";
|
| "BATCH_DELETE"
|
||||||
|
| "DEPT_CHANGE_WARNING";
|
||||||
|
|
||||||
export type AuditResourceType =
|
export type AuditResourceType =
|
||||||
| "MENU"
|
| "MENU"
|
||||||
|
|
|
||||||
|
|
@ -134,12 +134,14 @@ export class AuthService {
|
||||||
company_code: string | null;
|
company_code: string | null;
|
||||||
locale: string | null;
|
locale: string | null;
|
||||||
photo: Buffer | null;
|
photo: Buffer | null;
|
||||||
|
token_version: number | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT
|
`SELECT
|
||||||
sabun, user_id, user_name, user_name_eng, user_name_cn,
|
sabun, user_id, user_name, user_name_eng, user_name_cn,
|
||||||
dept_code, dept_name, position_code, position_name,
|
dept_code, dept_name, position_code, position_name,
|
||||||
email, tel, cell_phone, user_type, user_type_name,
|
email, tel, cell_phone, user_type, user_type_name,
|
||||||
partner_objid, company_code, locale, photo
|
partner_objid, company_code, locale, photo,
|
||||||
|
COALESCE(token_version, 0) as token_version
|
||||||
FROM user_info
|
FROM user_info
|
||||||
WHERE user_id = $1`,
|
WHERE user_id = $1`,
|
||||||
[userId]
|
[userId]
|
||||||
|
|
@ -210,6 +212,7 @@ export class AuthService {
|
||||||
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
|
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
|
||||||
: undefined,
|
: undefined,
|
||||||
locale: userInfo.locale || "KR",
|
locale: userInfo.locale || "KR",
|
||||||
|
tokenVersion: userInfo.token_version ?? 0,
|
||||||
// 권한 레벨 정보 추가 (3단계 체계)
|
// 권한 레벨 정보 추가 (3단계 체계)
|
||||||
isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN",
|
isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN",
|
||||||
isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*",
|
isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,489 @@
|
||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import axios from "axios";
|
||||||
|
import cron, { ScheduledTask } from "node-cron";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export interface CrawlConfig {
|
||||||
|
id: string;
|
||||||
|
company_code: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
request_body?: string;
|
||||||
|
selector_type: string;
|
||||||
|
row_selector: string;
|
||||||
|
column_mappings: Array<{
|
||||||
|
selector: string;
|
||||||
|
column: string;
|
||||||
|
type: "text" | "number" | "date";
|
||||||
|
attribute?: string; // href, src 등 속성값 추출
|
||||||
|
}>;
|
||||||
|
target_table: string;
|
||||||
|
upsert_key?: string;
|
||||||
|
cron_schedule?: string;
|
||||||
|
is_active: string;
|
||||||
|
writer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrawlResult {
|
||||||
|
collected: number;
|
||||||
|
saved: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_HEADERS = {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CrawlService {
|
||||||
|
private static scheduledTasks: Map<string, ScheduledTask> = new Map();
|
||||||
|
|
||||||
|
// ─── 스케줄러 ───
|
||||||
|
|
||||||
|
static async initializeScheduler() {
|
||||||
|
try {
|
||||||
|
const configs = await query<CrawlConfig>(
|
||||||
|
`SELECT * FROM crawl_configs WHERE is_active = 'Y' AND cron_schedule IS NOT NULL AND cron_schedule != ''`
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`크롤링 스케줄러: ${configs.length}개 설정 등록`);
|
||||||
|
|
||||||
|
for (const config of configs) {
|
||||||
|
this.scheduleConfig(config);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("크롤링 스케줄러 초기화 실패:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static scheduleConfig(config: CrawlConfig) {
|
||||||
|
if (!config.cron_schedule || !cron.validate(config.cron_schedule)) {
|
||||||
|
logger.warn(`크롤링 [${config.name}]: 유효하지 않은 cron 표현식 - ${config.cron_schedule}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 스케줄 제거
|
||||||
|
if (this.scheduledTasks.has(config.id)) {
|
||||||
|
this.scheduledTasks.get(config.id)!.stop();
|
||||||
|
this.scheduledTasks.delete(config.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = cron.schedule(
|
||||||
|
config.cron_schedule,
|
||||||
|
async () => {
|
||||||
|
logger.info(`크롤링 [${config.name}] 스케줄 실행 시작`);
|
||||||
|
await this.executeCrawl(config);
|
||||||
|
},
|
||||||
|
{ timezone: "Asia/Seoul" }
|
||||||
|
);
|
||||||
|
|
||||||
|
this.scheduledTasks.set(config.id, task);
|
||||||
|
logger.info(`크롤링 [${config.name}] 스케줄 등록: ${config.cron_schedule}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static unscheduleConfig(configId: string) {
|
||||||
|
if (this.scheduledTasks.has(configId)) {
|
||||||
|
this.scheduledTasks.get(configId)!.stop();
|
||||||
|
this.scheduledTasks.delete(configId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── CRUD ───
|
||||||
|
|
||||||
|
static async getConfigs(companyCode: string) {
|
||||||
|
const condition = companyCode === "*" ? "" : "WHERE company_code = $1";
|
||||||
|
const params = companyCode === "*" ? [] : [companyCode];
|
||||||
|
return query<CrawlConfig>(`SELECT * FROM crawl_configs ${condition} ORDER BY created_date DESC`, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getConfigById(id: string) {
|
||||||
|
const rows = await query<CrawlConfig>(`SELECT * FROM crawl_configs WHERE id = $1`, [id]);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createConfig(data: Partial<CrawlConfig>) {
|
||||||
|
const result = await query<CrawlConfig>(
|
||||||
|
`INSERT INTO crawl_configs (company_code, name, url, method, headers, request_body, selector_type, row_selector, column_mappings, target_table, upsert_key, cron_schedule, is_active, writer)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`,
|
||||||
|
[
|
||||||
|
data.company_code,
|
||||||
|
data.name,
|
||||||
|
data.url,
|
||||||
|
data.method || "GET",
|
||||||
|
JSON.stringify(data.headers || {}),
|
||||||
|
data.request_body || null,
|
||||||
|
data.selector_type || "css",
|
||||||
|
data.row_selector || null,
|
||||||
|
JSON.stringify(data.column_mappings || []),
|
||||||
|
data.target_table,
|
||||||
|
data.upsert_key || null,
|
||||||
|
data.cron_schedule || null,
|
||||||
|
data.is_active || "Y",
|
||||||
|
data.writer || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = result[0];
|
||||||
|
if (config.is_active === "Y" && config.cron_schedule) {
|
||||||
|
this.scheduleConfig(config);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateConfig(id: string, data: Partial<CrawlConfig>) {
|
||||||
|
const result = await query<CrawlConfig>(
|
||||||
|
`UPDATE crawl_configs SET
|
||||||
|
name = COALESCE($2, name),
|
||||||
|
url = COALESCE($3, url),
|
||||||
|
method = COALESCE($4, method),
|
||||||
|
headers = COALESCE($5, headers),
|
||||||
|
request_body = $6,
|
||||||
|
selector_type = COALESCE($7, selector_type),
|
||||||
|
row_selector = $8,
|
||||||
|
column_mappings = COALESCE($9, column_mappings),
|
||||||
|
target_table = COALESCE($10, target_table),
|
||||||
|
upsert_key = $11,
|
||||||
|
cron_schedule = $12,
|
||||||
|
is_active = COALESCE($13, is_active),
|
||||||
|
updated_date = now()
|
||||||
|
WHERE id = $1 RETURNING *`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
data.name,
|
||||||
|
data.url,
|
||||||
|
data.method,
|
||||||
|
data.headers ? JSON.stringify(data.headers) : null,
|
||||||
|
data.request_body ?? null,
|
||||||
|
data.selector_type,
|
||||||
|
data.row_selector ?? null,
|
||||||
|
data.column_mappings ? JSON.stringify(data.column_mappings) : null,
|
||||||
|
data.target_table,
|
||||||
|
data.upsert_key ?? null,
|
||||||
|
data.cron_schedule ?? null,
|
||||||
|
data.is_active,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = result[0];
|
||||||
|
if (config) {
|
||||||
|
this.unscheduleConfig(id);
|
||||||
|
if (config.is_active === "Y" && config.cron_schedule) {
|
||||||
|
this.scheduleConfig(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteConfig(id: string) {
|
||||||
|
this.unscheduleConfig(id);
|
||||||
|
await query(`DELETE FROM crawl_configs WHERE id = $1`, [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 크롤링 실행 ───
|
||||||
|
|
||||||
|
static async executeCrawl(config: CrawlConfig): Promise<CrawlResult> {
|
||||||
|
const logId = await this.createLog(config.id, config.company_code);
|
||||||
|
const errors: string[] = [];
|
||||||
|
let collected = 0;
|
||||||
|
let saved = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. HTTP 요청
|
||||||
|
const headers = { ...DEFAULT_HEADERS, ...(typeof config.headers === "string" ? JSON.parse(config.headers) : config.headers || {}) };
|
||||||
|
const response = await axios({
|
||||||
|
method: (config.method || "GET") as any,
|
||||||
|
url: config.url,
|
||||||
|
headers,
|
||||||
|
data: config.request_body || undefined,
|
||||||
|
timeout: 30000,
|
||||||
|
responseType: "text",
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = response.data;
|
||||||
|
const htmlPreview = typeof html === "string" ? html.substring(0, 2000) : "";
|
||||||
|
|
||||||
|
// 2. DOM 파싱
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const mappings = typeof config.column_mappings === "string"
|
||||||
|
? JSON.parse(config.column_mappings)
|
||||||
|
: config.column_mappings || [];
|
||||||
|
|
||||||
|
// 3. 행 추출
|
||||||
|
const rows: Record<string, any>[] = [];
|
||||||
|
|
||||||
|
if (config.row_selector) {
|
||||||
|
$(config.row_selector).each((_, el) => {
|
||||||
|
const row: Record<string, any> = {};
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
const $el = $(el).find(mapping.selector);
|
||||||
|
const raw = mapping.attribute ? $el.attr(mapping.attribute) || "" : $el.text().trim();
|
||||||
|
row[mapping.column] = this.castValue(raw, mapping.type);
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// row_selector 없으면 column_mappings의 selector로 직접 추출 (단일 행)
|
||||||
|
const row: Record<string, any> = {};
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
const $el = $(mapping.selector);
|
||||||
|
const raw = mapping.attribute ? $el.attr(mapping.attribute) || "" : $el.text().trim();
|
||||||
|
row[mapping.column] = this.castValue(raw, mapping.type);
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
collected = rows.length;
|
||||||
|
|
||||||
|
// 4. DB 저장
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
row.company_code = config.company_code;
|
||||||
|
|
||||||
|
if (config.upsert_key) {
|
||||||
|
await this.upsertRow(config.target_table, row, config.upsert_key, config.company_code);
|
||||||
|
} else {
|
||||||
|
await this.insertRow(config.target_table, row);
|
||||||
|
}
|
||||||
|
saved++;
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`행 저장 실패: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 상태 업데이트
|
||||||
|
await this.updateLog(logId, "success", collected, saved, null, htmlPreview);
|
||||||
|
await query(
|
||||||
|
`UPDATE crawl_configs SET last_executed_at = now(), last_status = 'success', last_error = null WHERE id = $1`,
|
||||||
|
[config.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`크롤링 [${config.name}] 완료: ${collected}건 수집, ${saved}건 저장`);
|
||||||
|
} catch (error: any) {
|
||||||
|
const errMsg = error.message || "Unknown error";
|
||||||
|
errors.push(errMsg);
|
||||||
|
await this.updateLog(logId, "fail", collected, saved, errMsg, null);
|
||||||
|
await query(
|
||||||
|
`UPDATE crawl_configs SET last_executed_at = now(), last_status = 'fail', last_error = $2 WHERE id = $1`,
|
||||||
|
[config.id, errMsg]
|
||||||
|
);
|
||||||
|
logger.error(`크롤링 [${config.name}] 실패:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { collected, saved, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── URL 자동 분석 ───
|
||||||
|
|
||||||
|
static async analyzeUrl(url: string) {
|
||||||
|
const response = await axios({
|
||||||
|
method: "GET",
|
||||||
|
url,
|
||||||
|
headers: DEFAULT_HEADERS,
|
||||||
|
timeout: 15000,
|
||||||
|
responseType: "text",
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(response.data);
|
||||||
|
const tables: Array<{
|
||||||
|
index: number;
|
||||||
|
selector: string;
|
||||||
|
caption: string;
|
||||||
|
headers: string[];
|
||||||
|
rowCount: number;
|
||||||
|
sampleRows: string[][];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// HTML <table> 자동 감지
|
||||||
|
$("table").each((i, tableEl) => {
|
||||||
|
const $table = $(tableEl);
|
||||||
|
// 헤더 추출
|
||||||
|
const headers: string[] = [];
|
||||||
|
$table.find("thead th, thead td, tr:first-child th").each((_, th) => {
|
||||||
|
headers.push($(th).text().trim());
|
||||||
|
});
|
||||||
|
// 헤더가 없으면 첫 행에서 추출 시도
|
||||||
|
if (headers.length === 0) {
|
||||||
|
$table.find("tr:first-child td").each((_, td) => {
|
||||||
|
headers.push($(td).text().trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 행 수
|
||||||
|
const bodyRows = $table.find("tbody tr");
|
||||||
|
const allRows = bodyRows.length > 0 ? bodyRows : $table.find("tr").slice(headers.length > 0 ? 1 : 0);
|
||||||
|
const rowCount = allRows.length;
|
||||||
|
|
||||||
|
// 샘플 (최대 3행)
|
||||||
|
const sampleRows: string[][] = [];
|
||||||
|
allRows.slice(0, 3).each((_, tr) => {
|
||||||
|
const cells: string[] = [];
|
||||||
|
$(tr).find("td, th").each((_, td) => {
|
||||||
|
cells.push($(td).text().trim());
|
||||||
|
});
|
||||||
|
sampleRows.push(cells);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (headers.length > 0 || rowCount > 0) {
|
||||||
|
// 선택자 생성
|
||||||
|
let selector = "table";
|
||||||
|
const id = $table.attr("id");
|
||||||
|
const cls = $table.attr("class");
|
||||||
|
if (id) selector = `table#${id}`;
|
||||||
|
else if (cls) selector = `table.${cls.split(/\s+/)[0]}`;
|
||||||
|
else if (i > 0) selector = `table:nth-of-type(${i + 1})`;
|
||||||
|
|
||||||
|
const caption = $table.find("caption").text().trim() || $table.attr("summary") || "";
|
||||||
|
|
||||||
|
tables.push({
|
||||||
|
index: i,
|
||||||
|
selector,
|
||||||
|
caption,
|
||||||
|
headers,
|
||||||
|
rowCount,
|
||||||
|
sampleRows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: $("title").text().trim(),
|
||||||
|
tableCount: tables.length,
|
||||||
|
tables,
|
||||||
|
htmlLength: response.data.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 미리보기 ───
|
||||||
|
|
||||||
|
static async preview(
|
||||||
|
url: string,
|
||||||
|
rowSelector: string,
|
||||||
|
columnMappings: CrawlConfig["column_mappings"],
|
||||||
|
method = "GET",
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
requestBody?: string
|
||||||
|
) {
|
||||||
|
const mergedHeaders = { ...DEFAULT_HEADERS, ...headers };
|
||||||
|
const response = await axios({
|
||||||
|
method: method as any,
|
||||||
|
url,
|
||||||
|
headers: mergedHeaders,
|
||||||
|
data: requestBody || undefined,
|
||||||
|
timeout: 15000,
|
||||||
|
responseType: "text",
|
||||||
|
});
|
||||||
|
|
||||||
|
const $ = cheerio.load(response.data);
|
||||||
|
const rows: Record<string, any>[] = [];
|
||||||
|
|
||||||
|
if (rowSelector) {
|
||||||
|
$(rowSelector)
|
||||||
|
.slice(0, 10) // 미리보기는 10행까지
|
||||||
|
.each((_, el) => {
|
||||||
|
const row: Record<string, any> = {};
|
||||||
|
for (const mapping of columnMappings) {
|
||||||
|
const $el = $(el).find(mapping.selector);
|
||||||
|
const raw = mapping.attribute ? $el.attr(mapping.attribute) || "" : $el.text().trim();
|
||||||
|
row[mapping.column] = this.castValue(raw, mapping.type);
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalElements: rowSelector ? $(rowSelector).length : 0,
|
||||||
|
previewRows: rows,
|
||||||
|
htmlLength: response.data.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 유틸 ───
|
||||||
|
|
||||||
|
private static castValue(raw: string, type: string): any {
|
||||||
|
if (!raw) return null;
|
||||||
|
switch (type) {
|
||||||
|
case "number": {
|
||||||
|
const cleaned = raw.replace(/[^0-9.\-]/g, "");
|
||||||
|
const num = parseFloat(cleaned);
|
||||||
|
return isNaN(num) ? null : num;
|
||||||
|
}
|
||||||
|
case "date":
|
||||||
|
return raw;
|
||||||
|
default:
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async insertRow(tableName: string, row: Record<string, any>) {
|
||||||
|
const cols = Object.keys(row);
|
||||||
|
const vals = Object.values(row);
|
||||||
|
const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
const colNames = cols.map((c) => `"${c}"`).join(", ");
|
||||||
|
|
||||||
|
await query(`INSERT INTO "${tableName}" (${colNames}) VALUES (${placeholders})`, vals);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async upsertRow(tableName: string, row: Record<string, any>, upsertKey: string, companyCode: string) {
|
||||||
|
const existing = await query(
|
||||||
|
`SELECT 1 FROM "${tableName}" WHERE "${upsertKey}" = $1 AND company_code = $2 LIMIT 1`,
|
||||||
|
[row[upsertKey], companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
const setClauses: string[] = [];
|
||||||
|
const vals: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
for (const [k, v] of Object.entries(row)) {
|
||||||
|
if (k === upsertKey || k === "company_code") continue;
|
||||||
|
setClauses.push(`"${k}" = $${idx}`);
|
||||||
|
vals.push(v);
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
if (setClauses.length > 0) {
|
||||||
|
vals.push(row[upsertKey], companyCode);
|
||||||
|
await query(
|
||||||
|
`UPDATE "${tableName}" SET ${setClauses.join(", ")}, updated_date = now() WHERE "${upsertKey}" = $${idx} AND company_code = $${idx + 1}`,
|
||||||
|
vals
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.insertRow(tableName, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async createLog(configId: string, companyCode: string): Promise<string> {
|
||||||
|
const result = await query<any>(
|
||||||
|
`INSERT INTO crawl_execution_logs (config_id, company_code, status) VALUES ($1, $2, 'running') RETURNING id`,
|
||||||
|
[configId, companyCode]
|
||||||
|
);
|
||||||
|
return result[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async updateLog(
|
||||||
|
logId: string,
|
||||||
|
status: string,
|
||||||
|
collected: number,
|
||||||
|
saved: number,
|
||||||
|
errorMessage: string | null,
|
||||||
|
htmlPreview: string | null
|
||||||
|
) {
|
||||||
|
await query(
|
||||||
|
`UPDATE crawl_execution_logs SET status = $2, rows_collected = $3, rows_saved = $4, error_message = $5, response_html_preview = $6, finished_at = now() WHERE id = $1`,
|
||||||
|
[logId, status, collected, saved, errorMessage, htmlPreview]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 로그 조회 ───
|
||||||
|
|
||||||
|
static async getLogs(configId: string, limit = 20) {
|
||||||
|
return query(
|
||||||
|
`SELECT * FROM crawl_execution_logs WHERE config_id = $1 ORDER BY started_at DESC LIMIT $2`,
|
||||||
|
[configId, limit]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -673,6 +673,22 @@ export class MultiLangService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 키의 소유 회사 코드 조회 (권한 검증용)
|
||||||
|
*/
|
||||||
|
async getKeyCompanyCode(keyId: number): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = await queryOne<{ company_code: string }>(
|
||||||
|
`SELECT company_code FROM multi_lang_key_master WHERE key_id = $1`,
|
||||||
|
[keyId]
|
||||||
|
);
|
||||||
|
return result?.company_code || null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("키 소유 회사 코드 조회 실패:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 다국어 키 목록 조회
|
* 다국어 키 목록 조회
|
||||||
*/
|
*/
|
||||||
|
|
@ -688,6 +704,10 @@ export class MultiLangService {
|
||||||
if (params.companyCode) {
|
if (params.companyCode) {
|
||||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||||
values.push(params.companyCode);
|
values.push(params.companyCode);
|
||||||
|
} else if (params.userCompanyCode && params.userCompanyCode !== "*") {
|
||||||
|
// 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환
|
||||||
|
whereConditions.push(`company_code IN ($${paramIndex++}, '*')`);
|
||||||
|
values.push(params.userCompanyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 메뉴 코드 필터
|
// 메뉴 코드 필터
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,33 @@ export async function getOrderSummary(
|
||||||
|
|
||||||
const whereClause = conditions.join(" AND ");
|
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 = `
|
const query = `
|
||||||
WITH order_summary AS (
|
WITH order_summary AS (
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -49,6 +76,7 @@ export async function getOrderSummary(
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
GROUP BY so.part_code, so.part_name
|
GROUP BY so.part_code, so.part_name
|
||||||
),
|
),
|
||||||
|
${itemLeadTimeCte}
|
||||||
stock_info AS (
|
stock_info AS (
|
||||||
SELECT
|
SELECT
|
||||||
item_code,
|
item_code,
|
||||||
|
|
@ -85,10 +113,12 @@ export async function getOrderSummary(
|
||||||
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
|
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),
|
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
|
||||||
0
|
0
|
||||||
) AS required_plan_qty
|
) AS required_plan_qty,
|
||||||
|
COALESCE(ilt.lead_time, 0) AS lead_time
|
||||||
FROM order_summary os
|
FROM order_summary os
|
||||||
LEFT JOIN stock_info si ON os.item_code = si.item_code
|
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 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" : ""}
|
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
|
||||||
ORDER BY os.item_code;
|
ORDER BY os.item_code;
|
||||||
`;
|
`;
|
||||||
|
|
@ -155,6 +185,80 @@ export async function getStockShortage(companyCode: string) {
|
||||||
return result.rows;
|
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 ───
|
// ─── 생산계획 CRUD ───
|
||||||
|
|
||||||
export async function getPlanById(companyCode: string, planId: number) {
|
export async function getPlanById(companyCode: string, planId: number) {
|
||||||
|
|
@ -267,49 +371,68 @@ export async function previewSchedule(
|
||||||
const deletedSchedules: any[] = [];
|
const deletedSchedules: any[] = [];
|
||||||
const keptSchedules: any[] = [];
|
const keptSchedules: any[] = [];
|
||||||
|
|
||||||
for (const item of items) {
|
// 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행
|
||||||
if (options.recalculate_unstarted) {
|
if (options.recalculate_unstarted) {
|
||||||
// 삭제 대상(planned) 상세 조회
|
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
|
||||||
|
for (const itemCode of uniqueItemCodes) {
|
||||||
const deleteResult = await pool.query(
|
const deleteResult = await pool.query(
|
||||||
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
|
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
|
||||||
FROM production_plan_mng
|
FROM production_plan_mng
|
||||||
WHERE company_code = $1 AND item_code = $2
|
WHERE company_code = $1 AND item_code = $2
|
||||||
AND COALESCE(product_type, '완제품') = $3
|
AND COALESCE(product_type, '완제품') = $3
|
||||||
AND status = 'planned'`,
|
AND status = 'planned'`,
|
||||||
[companyCode, item.item_code, productType]
|
[companyCode, itemCode, productType]
|
||||||
);
|
);
|
||||||
deletedSchedules.push(...deleteResult.rows);
|
deletedSchedules.push(...deleteResult.rows);
|
||||||
|
|
||||||
// 유지 대상(진행중 등) 상세 조회
|
|
||||||
const keptResult = await pool.query(
|
const keptResult = await pool.query(
|
||||||
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
|
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
|
||||||
FROM production_plan_mng
|
FROM production_plan_mng
|
||||||
WHERE company_code = $1 AND item_code = $2
|
WHERE company_code = $1 AND item_code = $2
|
||||||
AND COALESCE(product_type, '완제품') = $3
|
AND COALESCE(product_type, '완제품') = $3
|
||||||
AND status NOT IN ('planned', 'completed', 'cancelled')`,
|
AND status NOT IN ('planned', 'completed', 'cancelled')`,
|
||||||
[companyCode, item.item_code, productType]
|
[companyCode, itemCode, productType]
|
||||||
);
|
);
|
||||||
keptSchedules.push(...keptResult.rows);
|
keptSchedules.push(...keptResult.rows);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
const dailyCapacity = item.daily_capacity || 800;
|
const dailyCapacity = item.daily_capacity || 800;
|
||||||
|
const itemLeadTime = item.lead_time || 0;
|
||||||
|
|
||||||
|
// 프론트에서 이미 전체 잔량 기준으로 계산하여 보내므로 그대로 사용
|
||||||
|
// (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨)
|
||||||
const requiredQty = item.required_qty;
|
const requiredQty = item.required_qty;
|
||||||
|
|
||||||
if (requiredQty <= 0) continue;
|
if (requiredQty <= 0) continue;
|
||||||
|
|
||||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
|
||||||
|
|
||||||
const dueDate = new Date(item.earliest_due_date);
|
const dueDate = new Date(item.earliest_due_date);
|
||||||
const endDate = new Date(dueDate);
|
let startDate: Date;
|
||||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
let endDate: Date;
|
||||||
const startDate = new Date(endDate);
|
|
||||||
startDate.setDate(startDate.getDate() - productionDays);
|
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();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
if (startDate < today) {
|
if (startDate < today) {
|
||||||
|
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
startDate.setTime(today.getTime());
|
startDate.setTime(today.getTime());
|
||||||
endDate.setTime(startDate.getTime());
|
endDate.setTime(startDate.getTime());
|
||||||
endDate.setDate(endDate.getDate() + productionDays);
|
endDate.setDate(endDate.getDate() + duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 해당 품목의 수주 건수 확인
|
// 해당 품목의 수주 건수 확인
|
||||||
|
|
@ -326,10 +449,11 @@ export async function previewSchedule(
|
||||||
required_qty: requiredQty,
|
required_qty: requiredQty,
|
||||||
daily_capacity: dailyCapacity,
|
daily_capacity: dailyCapacity,
|
||||||
hourly_capacity: item.hourly_capacity || 100,
|
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],
|
start_date: startDate.toISOString().split("T")[0],
|
||||||
end_date: endDate.toISOString().split("T")[0],
|
end_date: endDate.toISOString().split("T")[0],
|
||||||
due_date: item.earliest_due_date,
|
due_date: item.earliest_due_date,
|
||||||
|
lead_time: itemLeadTime,
|
||||||
order_count: orderCount,
|
order_count: orderCount,
|
||||||
status: "planned",
|
status: "planned",
|
||||||
});
|
});
|
||||||
|
|
@ -343,7 +467,7 @@ export async function previewSchedule(
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.info("자동 스케줄 미리보기", { companyCode, summary });
|
logger.info("자동 스케줄 미리보기", { companyCode, summary });
|
||||||
return { summary, previews, deletedSchedules, keptSchedules };
|
return { summary, schedules: previews, deletedSchedules, keptSchedules };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateSchedule(
|
export async function generateSchedule(
|
||||||
|
|
@ -363,10 +487,22 @@ export async function generateSchedule(
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
let keptCount = 0;
|
let keptCount = 0;
|
||||||
const newSchedules: any[] = [];
|
const newSchedules: any[] = [];
|
||||||
|
const deletedQtyByItem = new Map<string, number>();
|
||||||
|
|
||||||
|
// 같은 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(
|
const deleteResult = await client.query(
|
||||||
`DELETE FROM production_plan_mng
|
`DELETE FROM production_plan_mng
|
||||||
WHERE company_code = $1
|
WHERE company_code = $1
|
||||||
|
|
@ -374,7 +510,7 @@ export async function generateSchedule(
|
||||||
AND COALESCE(product_type, '완제품') = $3
|
AND COALESCE(product_type, '완제품') = $3
|
||||||
AND status = 'planned'
|
AND status = 'planned'
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[companyCode, item.item_code, productType]
|
[companyCode, itemCode, productType]
|
||||||
);
|
);
|
||||||
deletedCount += deleteResult.rowCount || 0;
|
deletedCount += deleteResult.rowCount || 0;
|
||||||
|
|
||||||
|
|
@ -384,32 +520,48 @@ export async function generateSchedule(
|
||||||
AND item_code = $2
|
AND item_code = $2
|
||||||
AND COALESCE(product_type, '완제품') = $3
|
AND COALESCE(product_type, '완제품') = $3
|
||||||
AND status NOT IN ('planned', 'completed', 'cancelled')`,
|
AND status NOT IN ('planned', 'completed', 'cancelled')`,
|
||||||
[companyCode, item.item_code, productType]
|
[companyCode, itemCode, productType]
|
||||||
);
|
);
|
||||||
keptCount += parseInt(keptResult.rows[0].cnt, 10);
|
keptCount += parseInt(keptResult.rows[0].cnt, 10);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 생산일수 계산
|
for (const item of items) {
|
||||||
|
// 필요 수량 계산 (삭제된 planned 수량을 비율로 분배)
|
||||||
const dailyCapacity = item.daily_capacity || 800;
|
const dailyCapacity = item.daily_capacity || 800;
|
||||||
|
const itemLeadTime = item.lead_time || 0;
|
||||||
|
// 프론트에서 이미 전체 잔량 기준으로 계산하여 보내므로 그대로 사용
|
||||||
|
// (recalculate_unstarted 시 기존 planned는 위에서 이미 삭제됨)
|
||||||
const requiredQty = item.required_qty;
|
const requiredQty = item.required_qty;
|
||||||
if (requiredQty <= 0) continue;
|
if (requiredQty <= 0) continue;
|
||||||
|
|
||||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
|
||||||
|
|
||||||
// 시작일 = 납기일 - 생산일수 - 안전리드타임
|
|
||||||
const dueDate = new Date(item.earliest_due_date);
|
const dueDate = new Date(item.earliest_due_date);
|
||||||
const endDate = new Date(dueDate);
|
let startDate: Date;
|
||||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
let endDate: Date;
|
||||||
const startDate = new Date(endDate);
|
|
||||||
startDate.setDate(startDate.getDate() - productionDays);
|
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();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
if (startDate < today) {
|
if (startDate < today) {
|
||||||
|
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
startDate.setTime(today.getTime());
|
startDate.setTime(today.getTime());
|
||||||
endDate.setTime(startDate.getTime());
|
endDate.setTime(startDate.getTime());
|
||||||
endDate.setDate(endDate.getDate() + productionDays);
|
endDate.setDate(endDate.getDate() + duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
|
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
|
||||||
|
|
@ -576,13 +728,24 @@ async function getBomChildItems(
|
||||||
companyCode: string,
|
companyCode: string,
|
||||||
itemCode: 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 = `
|
const bomQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
bd.child_item_id,
|
bd.child_item_id,
|
||||||
ii.item_name AS child_item_name,
|
ii.item_name AS child_item_name,
|
||||||
ii.item_number AS child_item_code,
|
ii.item_number AS child_item_code,
|
||||||
bd.quantity AS bom_qty,
|
bd.quantity AS bom_qty,
|
||||||
bd.unit
|
bd.unit,
|
||||||
|
${leadTimeCol} AS child_lead_time
|
||||||
FROM bom b
|
FROM bom b
|
||||||
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
|
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
|
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
|
||||||
|
|
@ -641,9 +804,12 @@ export async function previewSemiSchedule(
|
||||||
|
|
||||||
if (requiredQty <= 0) continue;
|
if (requiredQty <= 0) continue;
|
||||||
|
|
||||||
|
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
|
||||||
|
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
|
||||||
const semiDueDate = plan.start_date;
|
const semiDueDate = plan.start_date;
|
||||||
|
const semiEndDate = new Date(plan.start_date);
|
||||||
const semiStartDate = 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({
|
previews.push({
|
||||||
parent_plan_id: plan.id,
|
parent_plan_id: plan.id,
|
||||||
|
|
@ -653,13 +819,14 @@ export async function previewSemiSchedule(
|
||||||
item_name: bomItem.child_item_name || bomItem.child_item_id,
|
item_name: bomItem.child_item_name || bomItem.child_item_id,
|
||||||
plan_qty: requiredQty,
|
plan_qty: requiredQty,
|
||||||
bom_qty: parseFloat(bomItem.bom_qty) || 1,
|
bom_qty: parseFloat(bomItem.bom_qty) || 1,
|
||||||
|
lead_time: childLeadTime,
|
||||||
start_date: semiStartDate.toISOString().split("T")[0],
|
start_date: semiStartDate.toISOString().split("T")[0],
|
||||||
end_date: typeof semiDueDate === "string"
|
end_date: typeof semiDueDate === "string"
|
||||||
? semiDueDate.split("T")[0]
|
? semiDueDate.split("T")[0]
|
||||||
: new Date(semiDueDate).toISOString().split("T")[0],
|
: semiEndDate.toISOString().split("T")[0],
|
||||||
due_date: typeof semiDueDate === "string"
|
due_date: typeof semiDueDate === "string"
|
||||||
? semiDueDate.split("T")[0]
|
? semiDueDate.split("T")[0]
|
||||||
: new Date(semiDueDate).toISOString().split("T")[0],
|
: semiEndDate.toISOString().split("T")[0],
|
||||||
product_type: "반제품",
|
product_type: "반제품",
|
||||||
status: "planned",
|
status: "planned",
|
||||||
});
|
});
|
||||||
|
|
@ -683,7 +850,7 @@ export async function previewSemiSchedule(
|
||||||
parent_count: plansResult.rowCount,
|
parent_count: plansResult.rowCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { summary, previews, deletedSchedules, keptSchedules };
|
return { summary, schedules: previews, deletedSchedules, keptSchedules };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 반제품 계획 자동 생성 ───
|
// ─── 반제품 계획 자동 생성 ───
|
||||||
|
|
@ -740,10 +907,12 @@ export async function generateSemiSchedule(
|
||||||
|
|
||||||
if (requiredQty <= 0) continue;
|
if (requiredQty <= 0) continue;
|
||||||
|
|
||||||
|
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
|
||||||
|
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
|
||||||
const semiDueDate = plan.start_date;
|
const semiDueDate = plan.start_date;
|
||||||
const semiEndDate = plan.start_date;
|
const semiEndDate = plan.start_date;
|
||||||
const semiStartDate = 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);
|
||||||
|
|
||||||
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
|
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
|
||||||
const planNoResult = await client.query(
|
const planNoResult = await client.query(
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { query } from "../database/db";
|
import { query, transaction } from "../database/db";
|
||||||
import { logger } from "../utils/logger";
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -145,10 +145,19 @@ export class RoleService {
|
||||||
writer: string;
|
writer: string;
|
||||||
}): Promise<RoleGroup> {
|
}): Promise<RoleGroup> {
|
||||||
try {
|
try {
|
||||||
|
// 동일 회사 내 같은 이름의 권한 그룹 중복 체크
|
||||||
|
const dupCheck = await query<{ count: string }>(
|
||||||
|
`SELECT COUNT(*) AS count FROM authority_master WHERE company_code = $1 AND auth_name = $2`,
|
||||||
|
[data.companyCode, data.authName]
|
||||||
|
);
|
||||||
|
if (dupCheck.length > 0 && parseInt(dupCheck[0].count, 10) > 0) {
|
||||||
|
throw new Error(`동일 회사 내에 이미 같은 이름의 권한 그룹이 존재합니다: ${data.authName}`);
|
||||||
|
}
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
|
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
|
||||||
VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW())
|
VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW())
|
||||||
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
|
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
|
||||||
company_code AS "companyCode", status, writer, regdate
|
company_code AS "companyCode", status, writer, regdate
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -460,35 +469,37 @@ export class RoleService {
|
||||||
writer: string
|
writer: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// 기존 권한 삭제
|
await transaction(async (client) => {
|
||||||
await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
|
// 기존 권한 삭제
|
||||||
authObjid,
|
await client.query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
|
||||||
]);
|
authObjid,
|
||||||
|
|
||||||
// 새로운 권한 삽입
|
|
||||||
if (permissions.length > 0) {
|
|
||||||
const values = permissions
|
|
||||||
.map(
|
|
||||||
(_, index) =>
|
|
||||||
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
|
|
||||||
)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
const params = permissions.flatMap((p) => [
|
|
||||||
p.menuObjid,
|
|
||||||
p.createYn,
|
|
||||||
p.readYn,
|
|
||||||
p.updateYn,
|
|
||||||
p.deleteYn,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sql = `
|
// 새로운 권한 삽입
|
||||||
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
|
if (permissions.length > 0) {
|
||||||
VALUES ${values}
|
const values = permissions
|
||||||
`;
|
.map(
|
||||||
|
(_, index) =>
|
||||||
|
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
await query(sql, [authObjid, ...params, writer]);
|
const params = permissions.flatMap((p) => [
|
||||||
}
|
p.menuObjid,
|
||||||
|
p.createYn,
|
||||||
|
p.readYn,
|
||||||
|
p.updateYn,
|
||||||
|
p.deleteYn,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
|
||||||
|
VALUES ${values}
|
||||||
|
`;
|
||||||
|
|
||||||
|
await client.query(sql, [authObjid, ...params, writer]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
logger.info("메뉴 권한 설정 성공", {
|
logger.info("메뉴 권한 설정 성공", {
|
||||||
authObjid,
|
authObjid,
|
||||||
|
|
|
||||||
|
|
@ -5957,7 +5957,21 @@ export class ScreenManagementService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutData = layout.layout_data;
|
let layoutData = layout.layout_data;
|
||||||
|
|
||||||
|
// 이중 래핑 감지 및 자동 언래핑
|
||||||
|
// layout_data 컬럼에 { version, layout_data: { components, ... } } 형태로 저장된 경우
|
||||||
|
// 실제 레이아웃은 내부 layout_data에 있으므로 언래핑한다
|
||||||
|
if (
|
||||||
|
layoutData &&
|
||||||
|
layoutData.layout_data &&
|
||||||
|
typeof layoutData.layout_data === "object" &&
|
||||||
|
!layoutData.components &&
|
||||||
|
layoutData.layout_data.components
|
||||||
|
) {
|
||||||
|
console.log(`POP 레이아웃 이중 래핑 감지 (screen_id=${screenId}), 자동 언래핑`);
|
||||||
|
layoutData = layoutData.layout_data;
|
||||||
|
}
|
||||||
|
|
||||||
// v1 → v2 자동 마이그레이션
|
// v1 → v2 자동 마이그레이션
|
||||||
if (layoutData && layoutData.version === "pop-1.0") {
|
if (layoutData && layoutData.version === "pop-1.0") {
|
||||||
|
|
@ -5994,10 +6008,22 @@ export class ScreenManagementService {
|
||||||
console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`);
|
console.log(`=== POP 레이아웃 저장 (v5 그리드 시스템) ===`);
|
||||||
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}`);
|
||||||
|
|
||||||
|
// 이중 래핑 방지: { version, layout_data: { components, ... } } 형태로 전달된 경우 언래핑
|
||||||
|
if (
|
||||||
|
layoutData &&
|
||||||
|
layoutData.layout_data &&
|
||||||
|
typeof layoutData.layout_data === "object" &&
|
||||||
|
!layoutData.components &&
|
||||||
|
layoutData.layout_data.components
|
||||||
|
) {
|
||||||
|
console.log(`저장 시 이중 래핑 감지 (screen_id=${screenId}), 자동 언래핑`);
|
||||||
|
layoutData = layoutData.layout_data;
|
||||||
|
}
|
||||||
|
|
||||||
// v5 그리드 레이아웃만 지원
|
// v5 그리드 레이아웃만 지원
|
||||||
const componentCount = Object.keys(layoutData.components || {}).length;
|
const componentCount = Object.keys(layoutData.components || {}).length;
|
||||||
console.log(`컴포넌트: ${componentCount}개`);
|
console.log(`컴포넌트: ${componentCount}개`);
|
||||||
|
|
||||||
// v5 형식 검증
|
// v5 형식 검증
|
||||||
if (layoutData.version && layoutData.version !== "pop-5.0") {
|
if (layoutData.version && layoutData.version !== "pop-5.0") {
|
||||||
console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`);
|
console.warn(`레거시 버전 감지 (${layoutData.version}), v5로 변환 필요`);
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,8 @@ class TableCategoryValueService {
|
||||||
created_at AS "createdAt",
|
created_at AS "createdAt",
|
||||||
updated_at AS "updatedAt",
|
updated_at AS "updatedAt",
|
||||||
created_by AS "createdBy",
|
created_by AS "createdBy",
|
||||||
updated_by AS "updatedBy"
|
updated_by AS "updatedBy",
|
||||||
|
path
|
||||||
FROM category_values
|
FROM category_values
|
||||||
WHERE table_name = $1
|
WHERE table_name = $1
|
||||||
AND column_name = $2
|
AND column_name = $2
|
||||||
|
|
@ -1441,7 +1442,7 @@ class TableCategoryValueService {
|
||||||
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
|
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
|
||||||
if (companyCode === "*") {
|
if (companyCode === "*") {
|
||||||
query = `
|
query = `
|
||||||
SELECT DISTINCT value_code, value_label
|
SELECT DISTINCT value_code, value_label, path
|
||||||
FROM category_values
|
FROM category_values
|
||||||
WHERE value_code IN (${placeholders1})
|
WHERE value_code IN (${placeholders1})
|
||||||
`;
|
`;
|
||||||
|
|
@ -1449,7 +1450,7 @@ class TableCategoryValueService {
|
||||||
} else {
|
} else {
|
||||||
const companyIdx = n + 1;
|
const companyIdx = n + 1;
|
||||||
query = `
|
query = `
|
||||||
SELECT DISTINCT value_code, value_label
|
SELECT DISTINCT value_code, value_label, path
|
||||||
FROM category_values
|
FROM category_values
|
||||||
WHERE value_code IN (${placeholders1})
|
WHERE value_code IN (${placeholders1})
|
||||||
AND (company_code = $${companyIdx} OR company_code = '*')
|
AND (company_code = $${companyIdx} OR company_code = '*')
|
||||||
|
|
@ -1460,10 +1461,15 @@ class TableCategoryValueService {
|
||||||
const result = await pool.query(query, params);
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
|
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
|
||||||
|
// path가 있고 '/'를 포함하면(depth>1) 전체 경로를 ' > ' 구분자로 표시
|
||||||
const labels: Record<string, string> = {};
|
const labels: Record<string, string> = {};
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
if (!labels[row.value_code]) {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1575,7 +1575,7 @@ export class TableManagementService {
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
case "equals":
|
case "equals":
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
|
||||||
values: [actualValue],
|
values: [actualValue],
|
||||||
paramCount: 1,
|
paramCount: 1,
|
||||||
};
|
};
|
||||||
|
|
@ -1859,10 +1859,10 @@ export class TableManagementService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
|
// select 필터(equals)인 경우 — 다중 값(콤마 구분) 지원
|
||||||
if (operator === "equals") {
|
if (operator === "equals") {
|
||||||
return {
|
return {
|
||||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
|
||||||
values: [String(value)],
|
values: [String(value)],
|
||||||
paramCount: 1,
|
paramCount: 1,
|
||||||
};
|
};
|
||||||
|
|
@ -2717,6 +2717,43 @@ export class TableManagementService {
|
||||||
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
|
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entity 컬럼의 display_column 자동 채우기 (예: supplier_code → supplier_name)
|
||||||
|
try {
|
||||||
|
const companyCode = data.company_code || "*";
|
||||||
|
const entityColsResult = await query<any>(
|
||||||
|
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column
|
||||||
|
FROM table_type_columns
|
||||||
|
WHERE table_name = $1 AND input_type = 'entity'
|
||||||
|
AND reference_table IS NOT NULL AND reference_table != ''
|
||||||
|
AND display_column IS NOT NULL AND display_column != ''
|
||||||
|
AND company_code IN ($2, '*')
|
||||||
|
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||||
|
[tableName, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const ec of entityColsResult) {
|
||||||
|
const srcVal = data[ec.column_name];
|
||||||
|
const displayCol = ec.display_column;
|
||||||
|
// display_column이 테이블에 존재하고, 값이 비어있거나 없으면 자동 조회
|
||||||
|
if (srcVal && columnTypeMap.has(displayCol) && (!data[displayCol] || data[displayCol] === "")) {
|
||||||
|
try {
|
||||||
|
const refResult = await query<any>(
|
||||||
|
`SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`,
|
||||||
|
[srcVal, companyCode]
|
||||||
|
);
|
||||||
|
if (refResult.length > 0 && refResult[0][displayCol]) {
|
||||||
|
data[displayCol] = refResult[0][displayCol];
|
||||||
|
logger.info(`Entity auto-fill: ${tableName}.${displayCol} = ${data[displayCol]} (from ${ec.reference_table})`);
|
||||||
|
}
|
||||||
|
} catch (refErr: any) {
|
||||||
|
logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (entityErr: any) {
|
||||||
|
logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
|
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
|
||||||
const skippedColumns: string[] = [];
|
const skippedColumns: string[] = [];
|
||||||
const existingColumns = Object.keys(data).filter((col) => {
|
const existingColumns = Object.keys(data).filter((col) => {
|
||||||
|
|
@ -2868,6 +2905,42 @@ export class TableManagementService {
|
||||||
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
|
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entity 컬럼의 display_column 자동 채우기 (수정 시)
|
||||||
|
try {
|
||||||
|
const companyCode = updatedData.company_code || originalData.company_code || "*";
|
||||||
|
const entityColsResult = await query<any>(
|
||||||
|
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column
|
||||||
|
FROM table_type_columns
|
||||||
|
WHERE table_name = $1 AND input_type = 'entity'
|
||||||
|
AND reference_table IS NOT NULL AND reference_table != ''
|
||||||
|
AND display_column IS NOT NULL AND display_column != ''
|
||||||
|
AND company_code IN ($2, '*')
|
||||||
|
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||||
|
[tableName, companyCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const ec of entityColsResult) {
|
||||||
|
const srcVal = updatedData[ec.column_name];
|
||||||
|
const displayCol = ec.display_column;
|
||||||
|
if (srcVal && columnTypeMap.has(displayCol) && (!updatedData[displayCol] || updatedData[displayCol] === "")) {
|
||||||
|
try {
|
||||||
|
const refResult = await query<any>(
|
||||||
|
`SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`,
|
||||||
|
[srcVal, companyCode]
|
||||||
|
);
|
||||||
|
if (refResult.length > 0 && refResult[0][displayCol]) {
|
||||||
|
updatedData[displayCol] = refResult[0][displayCol];
|
||||||
|
logger.info(`Entity auto-fill (edit): ${tableName}.${displayCol} = ${updatedData[displayCol]}`);
|
||||||
|
}
|
||||||
|
} catch (refErr: any) {
|
||||||
|
logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (entityErr: any) {
|
||||||
|
logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
// SET 절 생성 (수정할 데이터) - 먼저 생성
|
// SET 절 생성 (수정할 데이터) - 먼저 생성
|
||||||
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
|
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
|
||||||
const setConditions: string[] = [];
|
const setConditions: string[] = [];
|
||||||
|
|
@ -3357,16 +3430,20 @@ export class TableManagementService {
|
||||||
const safeColumn = `main."${columnName}"`;
|
const safeColumn = `main."${columnName}"`;
|
||||||
|
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
case "equals":
|
case "equals": {
|
||||||
|
const safeVal = String(value).replace(/'/g, "''");
|
||||||
filterConditions.push(
|
filterConditions.push(
|
||||||
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
|
`('${safeVal}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal}')`
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "not_equals":
|
}
|
||||||
|
case "not_equals": {
|
||||||
|
const safeVal2 = String(value).replace(/'/g, "''");
|
||||||
filterConditions.push(
|
filterConditions.push(
|
||||||
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
|
`NOT ('${safeVal2}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal2}')`
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "in": {
|
case "in": {
|
||||||
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||||
if (inArr.length > 0) {
|
if (inArr.length > 0) {
|
||||||
|
|
@ -3408,6 +3485,31 @@ export class TableManagementService {
|
||||||
case "is_not_null":
|
case "is_not_null":
|
||||||
filterConditions.push(`${safeColumn} IS NOT NULL`);
|
filterConditions.push(`${safeColumn} IS NOT NULL`);
|
||||||
break;
|
break;
|
||||||
|
case "not_contains":
|
||||||
|
filterConditions.push(
|
||||||
|
`${safeColumn}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "greater_than":
|
||||||
|
filterConditions.push(
|
||||||
|
`(${safeColumn})::numeric > ${parseFloat(String(value))}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "less_than":
|
||||||
|
filterConditions.push(
|
||||||
|
`(${safeColumn})::numeric < ${parseFloat(String(value))}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "greater_or_equal":
|
||||||
|
filterConditions.push(
|
||||||
|
`(${safeColumn})::numeric >= ${parseFloat(String(value))}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "less_or_equal":
|
||||||
|
filterConditions.push(
|
||||||
|
`(${safeColumn})::numeric <= ${parseFloat(String(value))}`
|
||||||
|
);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3424,6 +3526,89 @@ export class TableManagementService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 filterGroups 처리 (런타임 필터 빌더 - 그룹별 AND/OR 지원)
|
||||||
|
if (
|
||||||
|
options.dataFilter &&
|
||||||
|
options.dataFilter.filterGroups &&
|
||||||
|
options.dataFilter.filterGroups.length > 0
|
||||||
|
) {
|
||||||
|
const groupConditions: string[] = [];
|
||||||
|
|
||||||
|
for (const group of options.dataFilter.filterGroups) {
|
||||||
|
if (!group.conditions || group.conditions.length === 0) continue;
|
||||||
|
|
||||||
|
const conditions: string[] = [];
|
||||||
|
|
||||||
|
for (const condition of group.conditions) {
|
||||||
|
const { columnName, operator, value } = condition;
|
||||||
|
if (!columnName) continue;
|
||||||
|
|
||||||
|
const safeCol = `main."${columnName}"`;
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case "equals":
|
||||||
|
conditions.push(`${safeCol}::text = '${String(value).replace(/'/g, "''")}'`);
|
||||||
|
break;
|
||||||
|
case "not_equals":
|
||||||
|
conditions.push(`${safeCol}::text != '${String(value).replace(/'/g, "''")}'`);
|
||||||
|
break;
|
||||||
|
case "contains":
|
||||||
|
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}%'`);
|
||||||
|
break;
|
||||||
|
case "not_contains":
|
||||||
|
conditions.push(`${safeCol}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`);
|
||||||
|
break;
|
||||||
|
case "starts_with":
|
||||||
|
conditions.push(`${safeCol}::text LIKE '${String(value).replace(/'/g, "''")}%'`);
|
||||||
|
break;
|
||||||
|
case "ends_with":
|
||||||
|
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}'`);
|
||||||
|
break;
|
||||||
|
case "greater_than":
|
||||||
|
conditions.push(`(${safeCol})::numeric > ${parseFloat(String(value))}`);
|
||||||
|
break;
|
||||||
|
case "less_than":
|
||||||
|
conditions.push(`(${safeCol})::numeric < ${parseFloat(String(value))}`);
|
||||||
|
break;
|
||||||
|
case "greater_or_equal":
|
||||||
|
conditions.push(`(${safeCol})::numeric >= ${parseFloat(String(value))}`);
|
||||||
|
break;
|
||||||
|
case "less_or_equal":
|
||||||
|
conditions.push(`(${safeCol})::numeric <= ${parseFloat(String(value))}`);
|
||||||
|
break;
|
||||||
|
case "is_null":
|
||||||
|
conditions.push(`(${safeCol} IS NULL OR ${safeCol}::text = '')`);
|
||||||
|
break;
|
||||||
|
case "is_not_null":
|
||||||
|
conditions.push(`(${safeCol} IS NOT NULL AND ${safeCol}::text != '')`);
|
||||||
|
break;
|
||||||
|
case "in": {
|
||||||
|
const inArr = Array.isArray(value) ? value : [String(value)];
|
||||||
|
if (inArr.length > 0) {
|
||||||
|
const vals = inArr.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(", ");
|
||||||
|
conditions.push(`${safeCol}::text IN (${vals})`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
const logic = group.logic === "OR" ? " OR " : " AND ";
|
||||||
|
groupConditions.push(`(${conditions.join(logic)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupConditions.length > 0) {
|
||||||
|
const groupWhere = groupConditions.join(" AND ");
|
||||||
|
whereClause = whereClause
|
||||||
|
? `${whereClause} AND ${groupWhere}`
|
||||||
|
: groupWhere;
|
||||||
|
|
||||||
|
logger.info(`🔍 필터 그룹 적용 (Entity 조인): ${groupWhere}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
|
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||||
if (options.excludeFilter && options.excludeFilter.enabled) {
|
if (options.excludeFilter && options.excludeFilter.enabled) {
|
||||||
const {
|
const {
|
||||||
|
|
@ -5387,4 +5572,40 @@ export class TableManagementService {
|
||||||
return [];
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
// JWT 토큰 무효화 서비스
|
||||||
|
// user_info.token_version 기반으로 기존 JWT 토큰을 무효화
|
||||||
|
|
||||||
|
import { query } from "../database/db";
|
||||||
|
import { cache } from "../utils/cache";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
const TOKEN_VERSION_CACHE_TTL = 2 * 60 * 1000; // 2분 캐시
|
||||||
|
|
||||||
|
export class TokenInvalidationService {
|
||||||
|
/**
|
||||||
|
* 캐시 키 생성
|
||||||
|
*/
|
||||||
|
static cacheKey(userId: string): string {
|
||||||
|
return `token_version:${userId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 사용자의 토큰 무효화 (token_version +1)
|
||||||
|
*/
|
||||||
|
static async invalidateUserTokens(userId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await query(
|
||||||
|
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
cache.delete(this.cacheKey(userId));
|
||||||
|
logger.info(`토큰 무효화: ${userId}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`토큰 무효화 실패: ${userId}`, { error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 사용자의 토큰 일괄 무효화
|
||||||
|
*/
|
||||||
|
static async invalidateMultipleUserTokens(userIds: string[]): Promise<void> {
|
||||||
|
if (userIds.length === 0) return;
|
||||||
|
try {
|
||||||
|
const placeholders = userIds.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
await query(
|
||||||
|
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id IN (${placeholders})`,
|
||||||
|
userIds
|
||||||
|
);
|
||||||
|
userIds.forEach((id) => cache.delete(this.cacheKey(id)));
|
||||||
|
logger.info(`토큰 일괄 무효화: ${userIds.length}명`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`토큰 일괄 무효화 실패`, { error, userIds });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 token_version 조회 (캐시 사용)
|
||||||
|
*/
|
||||||
|
static async getUserTokenVersion(userId: string): Promise<number> {
|
||||||
|
const cacheKey = this.cacheKey(userId);
|
||||||
|
const cached = cache.get<number>(cacheKey);
|
||||||
|
if (cached !== null) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await query<{ token_version: number | null }>(
|
||||||
|
`SELECT token_version FROM user_info WHERE user_id = $1`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
const version = result.length > 0 ? (result[0].token_version ?? 0) : 0;
|
||||||
|
cache.set(cacheKey, version, TOKEN_VERSION_CACHE_TTL);
|
||||||
|
return version;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`token_version 조회 실패: ${userId}`, { error });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -64,6 +64,7 @@ export interface PersonBean {
|
||||||
companyName?: string; // 회사명 추가
|
companyName?: string; // 회사명 추가
|
||||||
photo?: string;
|
photo?: string;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
tokenVersion?: number; // JWT 토큰 무효화용 버전
|
||||||
// 권한 레벨 정보 (3단계 체계)
|
// 권한 레벨 정보 (3단계 체계)
|
||||||
isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN')
|
isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN')
|
||||||
isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN')
|
isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN')
|
||||||
|
|
@ -98,6 +99,7 @@ export interface JwtPayload {
|
||||||
companyName?: string; // 회사명 추가
|
companyName?: string; // 회사명 추가
|
||||||
userType?: string;
|
userType?: string;
|
||||||
userTypeName?: string;
|
userTypeName?: string;
|
||||||
|
tokenVersion?: number; // JWT 토큰 무효화용 버전
|
||||||
iat?: number;
|
iat?: number;
|
||||||
exp?: number;
|
exp?: number;
|
||||||
aud?: string;
|
aud?: string;
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,7 @@ export interface GetLangKeysParams {
|
||||||
includeOverrides?: boolean;
|
includeOverrides?: boolean;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
userCompanyCode?: string; // 요청 사용자의 회사 코드 (비관리자 필터링용)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetUserTextParams {
|
export interface GetUserTextParams {
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export class JwtUtils {
|
||||||
companyName: userInfo.companyName, // 회사명 추가
|
companyName: userInfo.companyName, // 회사명 추가
|
||||||
userType: userInfo.userType,
|
userType: userInfo.userType,
|
||||||
userTypeName: userInfo.userTypeName,
|
userTypeName: userInfo.userTypeName,
|
||||||
|
tokenVersion: userInfo.tokenVersion ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
return jwt.sign(payload, config.jwt.secret, {
|
return jwt.sign(payload, config.jwt.secret, {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,763 @@
|
||||||
|
# MES 구조 및 PC-POP 연동 가이드
|
||||||
|
|
||||||
|
> 작성일: 2026-03-20
|
||||||
|
> 대상: PC 화면 개발자 / POP 연동 담당자
|
||||||
|
> 목적: PC에서 작업지시를 등록할 때, POP(생산실적관리)에 공정이 자동 연동되는 전체 구조를 설명
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 전체 구조 개요
|
||||||
|
|
||||||
|
### 1.1 시스템 구성
|
||||||
|
|
||||||
|
```
|
||||||
|
[PC 영역 - 브라우저] [POP 영역 - 태블릿]
|
||||||
|
작업지시 등록 화면 생산실적관리 화면 (4480)
|
||||||
|
(screen 4155, 4493) 카드 리스트 + 상세 모달
|
||||||
|
| |
|
||||||
|
| (1) work_instruction INSERT | (3) 카드 리스트 조회
|
||||||
|
| (2) create-work-processes 호출 | (4) 접수/실적/확정
|
||||||
|
v v
|
||||||
|
=================================================================
|
||||||
|
[백엔드 - Express + PostgreSQL]
|
||||||
|
/api/data/work_instruction (범용 CRUD)
|
||||||
|
/api/pop/production/* (MES 전용 API 10개)
|
||||||
|
=================================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 데이터 흐름 요약
|
||||||
|
|
||||||
|
```
|
||||||
|
PC 등록 서버 자동 처리 POP 표시
|
||||||
|
--------- ---------------- ----------
|
||||||
|
1. 품목 선택 3. 라우팅 공정 조회 5. 카드 목록
|
||||||
|
2. 작업지시 INSERT ---> 4. work_order_process 6. 접수
|
||||||
|
+ create-work-processes N건 일괄 INSERT ---> 7. 실적 입력
|
||||||
|
API 호출 + process_work_result 8. 완료 확정
|
||||||
|
체크리스트 복사
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. DB 테이블 상세 구조
|
||||||
|
|
||||||
|
### 2.1 테이블 관계도
|
||||||
|
|
||||||
|
```
|
||||||
|
[마스터 데이터 - PC에서 사전 등록]
|
||||||
|
|
||||||
|
item_info process_mng defect_standard_mng
|
||||||
|
(품목 마스터) (공정 마스터) (불량 유형 마스터)
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
item_routing_version -----> item_routing_detail
|
||||||
|
(품목별 라우팅 버전) (공정 순서 정의)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
process_work_item -----> process_work_item_detail
|
||||||
|
(공정별 작업항목) (체크리스트 상세)
|
||||||
|
|
||||||
|
|
||||||
|
[트랜잭션 데이터 - PC 등록 + POP 실행]
|
||||||
|
|
||||||
|
work_instruction ─── 1:N ──> work_order_process ─── 1:N ──> process_work_result
|
||||||
|
(작업지시 마스터) (공정별 작업 단위) (체크리스트 결과)
|
||||||
|
|
|
||||||
|
parent_process_id (자기참조)
|
||||||
|
|
|
||||||
|
work_order_process (분할 행)
|
||||||
|
(접수/재작업 분할 카드)
|
||||||
|
|
|
||||||
|
work_order_process_log
|
||||||
|
(변경 이력 - 트리거 자동)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 work_instruction (작업지시 마스터) - 19컬럼
|
||||||
|
|
||||||
|
> PC에서 등록하는 핵심 테이블. POP에서는 읽기 전용으로 참조.
|
||||||
|
|
||||||
|
| 컬럼 | 타입 | 기본값 | 역할 | 비고 |
|
||||||
|
|------|------|--------|------|------|
|
||||||
|
| `id` | varchar | gen_random_uuid() | PK | UUID 자동 생성 |
|
||||||
|
| `work_instruction_no` | varchar | - | 작업지시번호 | 사용자 채번 (예: WI-20260320-001) |
|
||||||
|
| `item_id` | varchar | - | 품목 FK | -> item_info.id |
|
||||||
|
| `status` | varchar | - | 작업지시 상태 | waiting / in_progress / completed / cancelled |
|
||||||
|
| `progress_status` | varchar | - | 진행상태 | POP에서 완료 시 'completed'로 자동 갱신 |
|
||||||
|
| `qty` | varchar | - | 지시수량 | 핵심. POP 접수 상한의 기준 |
|
||||||
|
| `completed_qty` | varchar | '0' | 완성수량 | 마지막 공정 양품 합계로 자동 갱신 |
|
||||||
|
| `routing` | varchar | - | 라우팅 참조 | 현재 미사용 (비어있음) |
|
||||||
|
| `worker` | varchar | - | 작업자 | |
|
||||||
|
| `work_team` | varchar | - | 작업팀 | |
|
||||||
|
| `equipment_id` | varchar | - | 설비 FK | |
|
||||||
|
| `start_date` | varchar | - | 시작일 | |
|
||||||
|
| `end_date` | varchar | - | 종료일(납기) | |
|
||||||
|
| `reason` | varchar | - | 사유 | |
|
||||||
|
| `remark` | text | - | 비고 | |
|
||||||
|
| `company_code` | varchar | - | 멀티테넌시 | 필수 |
|
||||||
|
| `created_date` | timestamp | now() | 생성일 | |
|
||||||
|
| `updated_date` | timestamp | now() | 수정일 | |
|
||||||
|
| `writer` | varchar | - | 작성자 | |
|
||||||
|
|
||||||
|
### 2.3 work_order_process (공정별 작업 단위) - 37컬럼
|
||||||
|
|
||||||
|
> create-work-processes API 호출 시 자동 생성. POP에서 접수/실적/완료를 처리하는 핵심 테이블.
|
||||||
|
|
||||||
|
| 컬럼 그룹 | 컬럼 | 타입 | 기본값 | 역할 |
|
||||||
|
|-----------|------|------|--------|------|
|
||||||
|
| **연결** | `wo_id` | varchar | - | -> work_instruction.id (작업지시 FK) |
|
||||||
|
| | `seq_no` | varchar | - | 공정 순서 (1, 2, 3...) |
|
||||||
|
| | `routing_detail_id` | varchar | - | -> item_routing_detail.id (라우팅 스냅샷) |
|
||||||
|
| | `parent_process_id` | varchar | NULL | 분할 시 마스터 행 참조 (NULL = 마스터) |
|
||||||
|
| **공정정보** | `process_code` | varchar | - | 공정코드 (예: P002) |
|
||||||
|
| | `process_name` | varchar | - | 공정명 (예: 가공) |
|
||||||
|
| | `is_required` | varchar | - | 필수 여부 |
|
||||||
|
| | `is_fixed_order` | varchar | - | 순서 고정 여부 |
|
||||||
|
| | `standard_time` | varchar | - | 표준시간 |
|
||||||
|
| | `equipment_code` | varchar | - | 사용 설비 |
|
||||||
|
| **수량** | `plan_qty` | varchar | - | 계획수량 |
|
||||||
|
| | `input_qty` | varchar | - | 접수량 (접수 시 설정) |
|
||||||
|
| | `good_qty` | varchar | - | 양품수량 (누적) |
|
||||||
|
| | `defect_qty` | varchar | - | 불량수량 (누적) |
|
||||||
|
| | `total_production_qty` | varchar | - | 총 생산수량 (누적) |
|
||||||
|
| | `concession_qty` | varchar | '0' | 특채수량 (양품에 합산 + 별도 추적) |
|
||||||
|
| **상태** | `status` | varchar | - | waiting / acceptable / in_progress / completed |
|
||||||
|
| | `result_status` | varchar | 'draft' | draft / confirmed |
|
||||||
|
| **타이머** | `started_at` | varchar | - | 작업 시작 시각 |
|
||||||
|
| | `paused_at` | varchar | - | 일시정지 시각 |
|
||||||
|
| | `total_paused_time` | varchar | 0 | 누적 일시정지 시간(초) |
|
||||||
|
| | `completed_at` | varchar | - | 완료 시각 |
|
||||||
|
| | `actual_work_time` | varchar | NULL | 실 작업시간(초) |
|
||||||
|
| **작업자** | `accepted_by` | varchar | - | 접수자 |
|
||||||
|
| | `accepted_at` | varchar | - | 접수 시각 |
|
||||||
|
| | `completed_by` | varchar | NULL | 완료 처리자 |
|
||||||
|
| **실적** | `defect_detail` | varchar | - | 불량 상세 JSON (코드/수량/처분) |
|
||||||
|
| | `result_note` | varchar | - | 실적 메모 |
|
||||||
|
| | `attachments` | varchar | - | 첨부파일 |
|
||||||
|
| **재작업** | `is_rework` | varchar | 'N' | 재작업 카드 여부 (Y/N) |
|
||||||
|
| | `rework_source_id` | varchar | NULL | 재작업 원본 행 참조 |
|
||||||
|
| **표준** | `company_code`, `created_date`, `updated_date`, `writer`, `remark` | - | - | 표준 컬럼 |
|
||||||
|
|
||||||
|
### 2.4 process_work_result (체크리스트/검사 결과) - 35컬럼
|
||||||
|
|
||||||
|
> 공정별 체크리스트. create-work-processes 시 마스터 템플릿에서 복사.
|
||||||
|
|
||||||
|
| 컬럼 그룹 | 컬럼 | 역할 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| **연결** | `work_order_process_id` | -> work_order_process.id |
|
||||||
|
| | `source_work_item_id` | -> process_work_item.id (템플릿 원본) |
|
||||||
|
| | `source_detail_id` | -> process_work_item_detail.id (템플릿 상세) |
|
||||||
|
| **작업항목** | `work_phase` | 작업 단계 (PRE/IN/POST) |
|
||||||
|
| | `item_title` | 작업항목 제목 |
|
||||||
|
| | `item_sort_order` | 항목 정렬 순서 |
|
||||||
|
| **검사 상세** | `detail_content` | 검사 내용 |
|
||||||
|
| | `detail_type` | 상세 유형 |
|
||||||
|
| | `detail_sort_order` | 상세 정렬 순서 |
|
||||||
|
| | `is_required` | 필수 여부 |
|
||||||
|
| **검사 기준** | `inspection_code` | 검사 코드 |
|
||||||
|
| | `inspection_method` | 검사 방법 |
|
||||||
|
| | `unit` | 단위 |
|
||||||
|
| | `lower_limit` / `upper_limit` | 하한/상한 |
|
||||||
|
| **입력** | `input_type` | 입력 유형 |
|
||||||
|
| | `lookup_target` | 조회 대상 |
|
||||||
|
| | `display_fields` | 표시 필드 |
|
||||||
|
| | `duration_minutes` | 소요시간(분) |
|
||||||
|
| **결과** | `result_value` | 입력 결과값 |
|
||||||
|
| | `is_passed` | 합격 여부 |
|
||||||
|
| | `status` | pending / completed |
|
||||||
|
| | `recorded_by` | 기록자 |
|
||||||
|
| | `recorded_at` | 기록 시각 |
|
||||||
|
| **그룹 타이머** | `group_started_at`, `group_paused_at` | 그룹 시작/정지 |
|
||||||
|
| | `group_total_paused_time`, `group_completed_at` | 누적 정지/완료 |
|
||||||
|
|
||||||
|
### 2.5 마스터 데이터 테이블
|
||||||
|
|
||||||
|
#### item_info (품목 마스터)
|
||||||
|
|
||||||
|
| 주요 컬럼 | 역할 |
|
||||||
|
|-----------|------|
|
||||||
|
| `id` | PK (UUID) |
|
||||||
|
| `item_number` | 품목코드 (라우팅 연결 키) |
|
||||||
|
| `item_name` | 품목명 |
|
||||||
|
| `type` | 품목 유형 |
|
||||||
|
| `division` | 구분 |
|
||||||
|
|
||||||
|
#### process_mng (공정 마스터)
|
||||||
|
|
||||||
|
| 주요 컬럼 | 역할 |
|
||||||
|
|-----------|------|
|
||||||
|
| `process_code` | 공정코드 (PK 역할) |
|
||||||
|
| `process_name` | 공정명 (예: 가공, 검사, 포장) |
|
||||||
|
| `process_type` | 공정 유형 |
|
||||||
|
| `use_yn` | 사용 여부 |
|
||||||
|
|
||||||
|
#### item_routing_version (품목별 라우팅 버전)
|
||||||
|
|
||||||
|
| 주요 컬럼 | 역할 |
|
||||||
|
|-----------|------|
|
||||||
|
| `id` | PK (UUID) |
|
||||||
|
| `item_code` | -> item_info.item_number |
|
||||||
|
| `version_name` | 버전명 (예: 기본 라우팅, v1) |
|
||||||
|
| `is_default` | 기본 버전 여부 (boolean) |
|
||||||
|
|
||||||
|
#### item_routing_detail (공정 순서 정의)
|
||||||
|
|
||||||
|
| 주요 컬럼 | 역할 |
|
||||||
|
|-----------|------|
|
||||||
|
| `id` | PK (UUID) |
|
||||||
|
| `routing_version_id` | -> item_routing_version.id |
|
||||||
|
| `seq_no` | 공정 순서 (1, 2, 3...) |
|
||||||
|
| `process_code` | -> process_mng.process_code |
|
||||||
|
| `is_required` | 필수 여부 |
|
||||||
|
| `is_fixed_order` | 순서 고정 여부 |
|
||||||
|
| `standard_time` | 표준시간 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. PC에서 작업지시 등록 -> POP 연동 흐름
|
||||||
|
|
||||||
|
### 3.1 전체 시퀀스 다이어그램
|
||||||
|
|
||||||
|
```
|
||||||
|
[PC 사용자] [프론트엔드] [백엔드] [DB]
|
||||||
|
| | | |
|
||||||
|
| 1. 품목 선택 | | |
|
||||||
|
|------------------>| | |
|
||||||
|
| | 2. 라우팅 버전 조회 | |
|
||||||
|
| |--- GET /api/data/ | |
|
||||||
|
| | item_routing_ |--- SELECT |
|
||||||
|
| | version | item_routing_ |
|
||||||
|
| | ?item_code=XXX ->| version -->|
|
||||||
|
| |<-------------------|<----------------------|
|
||||||
|
| | | |
|
||||||
|
| 3. 정보 입력 | | |
|
||||||
|
| (수량/납기/etc) | | |
|
||||||
|
|------------------>| | |
|
||||||
|
| | | |
|
||||||
|
| 4. "등록" 클릭 | | |
|
||||||
|
|------------------>| | |
|
||||||
|
| | 5. 작업지시 INSERT | |
|
||||||
|
| |--- POST /api/data/ | |
|
||||||
|
| | work_instruction |--- INSERT |
|
||||||
|
| | --->| work_instruction -->|
|
||||||
|
| |<-------------------|<-- RETURNING id ------|
|
||||||
|
| | | |
|
||||||
|
| | 6. 공정 일괄 생성 | |
|
||||||
|
| |--- POST /api/pop/ | |
|
||||||
|
| | production/ | |
|
||||||
|
| | create-work- | |
|
||||||
|
| | processes -->| |
|
||||||
|
| | |-- SELECT item_routing |
|
||||||
|
| | | _detail + process_mng|
|
||||||
|
| | |<----------------------|
|
||||||
|
| | | |
|
||||||
|
| | |-- FOR EACH 공정: |
|
||||||
|
| | | INSERT work_order_ |
|
||||||
|
| | | process -->|
|
||||||
|
| | | INSERT process_work_ |
|
||||||
|
| | | result (체크리스트)->|
|
||||||
|
| | | |
|
||||||
|
| |<-- 성공 응답 ------| |
|
||||||
|
|<-- 등록 완료 -----| | |
|
||||||
|
| | | |
|
||||||
|
| | [이 시점부터 POP에서 조회 가능] |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Step 1: 작업지시 INSERT (필수)
|
||||||
|
|
||||||
|
**API**: `POST /api/data/work_instruction`
|
||||||
|
|
||||||
|
**필수 데이터**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"item_id": "품목 UUID (item_info.id)",
|
||||||
|
"qty": "지시수량 (예: 500)",
|
||||||
|
"status": "waiting",
|
||||||
|
"work_instruction_no": "작업지시번호 (예: WI-20260320-001)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**선택 데이터**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"worker": "작업자",
|
||||||
|
"work_team": "작업팀",
|
||||||
|
"equipment_id": "설비 UUID",
|
||||||
|
"start_date": "시작일",
|
||||||
|
"end_date": "종료일(납기)",
|
||||||
|
"remark": "비고"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답 예시**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": "a1b2c3d4-...",
|
||||||
|
"work_instruction_no": "WI-20260320-001",
|
||||||
|
"status": "waiting"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 이 시점에서는 work_instruction 행만 생성되고, POP에서 공정 카드가 표시되지 않는다.
|
||||||
|
|
||||||
|
### 3.3 Step 2: 공정 일괄 생성 (핵심 - 반드시 호출해야 POP 연동됨)
|
||||||
|
|
||||||
|
**API**: `POST /api/pop/production/create-work-processes`
|
||||||
|
|
||||||
|
**인증**: JWT 토큰 필수 (Authorization 헤더)
|
||||||
|
|
||||||
|
**필수 파라미터**:
|
||||||
|
|
||||||
|
| 파라미터 | 타입 | 설명 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `work_instruction_id` | string | Step 1에서 받은 작업지시 ID |
|
||||||
|
| `routing_version_id` | string | 선택한 라우팅 버전 UUID |
|
||||||
|
|
||||||
|
**선택 파라미터**:
|
||||||
|
|
||||||
|
| 파라미터 | 타입 | 설명 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `item_code` | string | 품목코드 (참고용) |
|
||||||
|
| `plan_qty` | string | 계획수량 (work_order_process.plan_qty에 저장) |
|
||||||
|
|
||||||
|
**요청 예시**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"work_instruction_id": "a1b2c3d4-...작업지시ID",
|
||||||
|
"routing_version_id": "e5f6g7h8-...라우팅버전ID",
|
||||||
|
"plan_qty": "500"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**서버 내부 동작**:
|
||||||
|
|
||||||
|
1. **중복 방지**: 해당 work_instruction_id로 이미 공정이 있으면 409 에러
|
||||||
|
2. **라우팅 조회**: `item_routing_detail` + `process_mng` JOIN으로 공정 목록 취득
|
||||||
|
3. **공정별 INSERT**: seq_no 순서대로 work_order_process 행 생성
|
||||||
|
- 1공정: `status = 'acceptable'` (POP에서 즉시 접수 가능)
|
||||||
|
- 2~N공정: `status = 'waiting'` (앞공정 완료 대기)
|
||||||
|
4. **체크리스트 복사**: 각 공정의 `routing_detail_id`에 연결된 `process_work_item` + `process_work_item_detail`을 `process_work_result`로 복사
|
||||||
|
|
||||||
|
**응답 예시**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"processes": [
|
||||||
|
{ "id": "uuid-1", "seq_no": "1", "process_name": "가공", "checklist_count": 3 },
|
||||||
|
{ "id": "uuid-2", "seq_no": "2", "process_name": "검사", "checklist_count": 5 },
|
||||||
|
{ "id": "uuid-3", "seq_no": "3", "process_name": "포장", "checklist_count": 2 }
|
||||||
|
],
|
||||||
|
"total_processes": 3,
|
||||||
|
"total_checklists": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**에러 케이스**:
|
||||||
|
|
||||||
|
| HTTP | 상황 | 메시지 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 400 | 필수 파라미터 누락 | "work_instruction_id와 routing_version_id는 필수입니다." |
|
||||||
|
| 409 | 이미 공정이 존재 | "이미 공정이 생성된 작업지시입니다." |
|
||||||
|
| 404 | 라우팅에 공정 없음 | "라우팅 버전에 등록된 공정이 없습니다." |
|
||||||
|
|
||||||
|
### 3.4 Step 1 + Step 2를 하나의 트랜잭션으로 묶는 방법
|
||||||
|
|
||||||
|
> PC 화면에서 "등록" 버튼 1회 클릭으로 두 API를 순차 호출해야 한다.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// PC 프론트엔드 예시 코드
|
||||||
|
async function registerWorkInstruction(formData) {
|
||||||
|
// Step 1: 작업지시 INSERT
|
||||||
|
const wiResponse = await apiClient.post("/api/data/work_instruction", {
|
||||||
|
item_id: formData.itemId,
|
||||||
|
qty: formData.qty,
|
||||||
|
status: "waiting",
|
||||||
|
work_instruction_no: formData.wiNo,
|
||||||
|
worker: formData.worker,
|
||||||
|
start_date: formData.startDate,
|
||||||
|
end_date: formData.endDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wiResponse.data.success) {
|
||||||
|
throw new Error("작업지시 등록 실패: " + wiResponse.data.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workInstructionId = wiResponse.data.data.id;
|
||||||
|
|
||||||
|
// Step 2: 공정 일괄 생성 (이것이 POP 연동의 핵심)
|
||||||
|
const processResponse = await apiClient.post(
|
||||||
|
"/api/pop/production/create-work-processes",
|
||||||
|
{
|
||||||
|
work_instruction_id: workInstructionId,
|
||||||
|
routing_version_id: formData.routingVersionId,
|
||||||
|
plan_qty: formData.qty,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!processResponse.data.success) {
|
||||||
|
// 실패 시 작업지시도 삭제 또는 상태 변경 필요
|
||||||
|
throw new Error("공정 생성 실패: " + processResponse.data.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
workInstruction: wiResponse.data.data,
|
||||||
|
processes: processResponse.data.data.processes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 PC 화면에서 필요한 사전 데이터 조회
|
||||||
|
|
||||||
|
#### 3.5.1 품목 목록 조회
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/data/item_info
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.5.2 선택한 품목의 라우팅 버전 목록 조회
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/data/item_routing_version?item_code={선택한 품목의 item_number}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `is_default = true`인 버전을 자동 선택하면 UX가 좋다.
|
||||||
|
|
||||||
|
#### 3.5.3 라우팅 버전의 공정 목록 미리보기 (선택사항)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/data/item_routing_detail?routing_version_id={선택한 버전 ID}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 등록 전에 사용자에게 "이 라우팅에 포함된 공정 목록"을 보여줄 수 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. POP 워크플로우 상세
|
||||||
|
|
||||||
|
### 4.1 공정 상태 전이 다이어그램
|
||||||
|
|
||||||
|
```
|
||||||
|
[PC 등록]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
waiting ---------> acceptable ---------> in_progress ---------> completed
|
||||||
|
(대기중) (접수가능) (진행중) (완료)
|
||||||
|
| | |
|
||||||
|
| (접수 취소) | | (실적 저장 -> 자동완료)
|
||||||
|
|<--------------------+ |
|
||||||
|
| | (수동 확정)
|
||||||
|
| +----> completed + confirmed
|
||||||
|
|
|
||||||
|
| (앞공정에서 양품 발생 시 자동 전환)
|
||||||
|
|
|
||||||
|
waiting ---> acceptable
|
||||||
|
```
|
||||||
|
|
||||||
|
**상태별 의미**:
|
||||||
|
|
||||||
|
| 상태 | 의미 | POP 탭 | 전환 조건 |
|
||||||
|
|------|------|--------|----------|
|
||||||
|
| `waiting` | 앞공정 미완료, 접수 불가 | 대기 탭 | 앞공정 양품 발생 시 -> acceptable |
|
||||||
|
| `acceptable` | 접수 가능, 작업자 대기 | 접수가능 탭 | 작업자가 접수 시 -> in_progress (분할 행) |
|
||||||
|
| `in_progress` | 작업 진행 중 | 진행 탭 | 실적 전량 생산 시 -> completed (자동) |
|
||||||
|
| `completed` | 작업 완료 | 완료 탭 | 수동 확정 또는 자동 완료 |
|
||||||
|
|
||||||
|
### 4.2 수량 계산 공식
|
||||||
|
|
||||||
|
```
|
||||||
|
접수가능량 = 앞공정.양품합계 - 내공정.접수합계
|
||||||
|
= (앞공정 SUM(good_qty + concession_qty)) - (내공정 분할행 SUM(input_qty))
|
||||||
|
(1공정은 앞공정.양품합계 = work_instruction.qty)
|
||||||
|
|
||||||
|
양품 = 생산수량 - 불량수량
|
||||||
|
= production_qty - defect_qty
|
||||||
|
(서버에서 계산, 클라이언트 값은 참고만)
|
||||||
|
|
||||||
|
특채(concession) = 불량 중 disposition='accept'인 항목 합계
|
||||||
|
양품에는 포함되지 않으나, 다음 공정 전달량에 합산
|
||||||
|
|
||||||
|
자동완료 조건:
|
||||||
|
- 분할 행: total_production_qty >= input_qty
|
||||||
|
- 마스터 행: 모든 분할 행 completed + 잔여 접수가능 <= 0
|
||||||
|
- 작업지시: 마지막 seq_no의 모든 행 completed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 접수(Accept) 상세
|
||||||
|
|
||||||
|
```
|
||||||
|
[접수가능 카드 클릭] -> [수량 입력 모달] -> [접수 확인]
|
||||||
|
|
|
||||||
|
v
|
||||||
|
POST /api/pop/production/accept-process
|
||||||
|
body: { work_order_process_id: 마스터행ID,
|
||||||
|
accept_qty: 접수수량 }
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[분할 행 INSERT]
|
||||||
|
- parent_process_id = 마스터행ID
|
||||||
|
- input_qty = 접수수량
|
||||||
|
- status = 'in_progress'
|
||||||
|
- accepted_by = 로그인 사용자
|
||||||
|
- 체크리스트 복사
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[POP 카드 갱신]
|
||||||
|
- 새 분할 카드가 "진행중" 탭에 표시
|
||||||
|
- 잔여 접수가능량이 남으면 마스터 카드도 "접수가능"에 유지
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 실적 저장(Save Result) 상세
|
||||||
|
|
||||||
|
```
|
||||||
|
[진행중 카드 클릭] -> [상세 모달 열림] -> [실적 입력]
|
||||||
|
|
|
||||||
|
생산수량 + 불량상세(코드/수량/처분) 입력
|
||||||
|
|
|
||||||
|
v
|
||||||
|
POST /api/pop/production/save-result
|
||||||
|
body: { work_order_process_id: 분할행ID,
|
||||||
|
production_qty: 생산수량,
|
||||||
|
defect_detail: [
|
||||||
|
{ defect_code, defect_name, qty, disposition }
|
||||||
|
] }
|
||||||
|
|
|
||||||
|
v
|
||||||
|
[서버 처리]
|
||||||
|
1. 양품/불량/특채 서버 계산
|
||||||
|
2. 기존 수량에 누적 (total_production_qty += production_qty)
|
||||||
|
3. 불량 상세 JSON 병합
|
||||||
|
4. disposition='rework' -> 재작업 카드 자동 생성
|
||||||
|
5. 양품 발생 -> 다음 공정 마스터 acceptable로 전환
|
||||||
|
6. 접수분 전량 생산 -> 분할 행 자동 completed
|
||||||
|
7. 모든 분할 행 완료 + 잔여 0 -> 마스터 자동 completed
|
||||||
|
8. 마지막 공정 전부 완료 -> work_instruction 완료
|
||||||
|
```
|
||||||
|
|
||||||
|
**불량 처분(disposition) 3종**:
|
||||||
|
|
||||||
|
| 처분 | 의미 | 수량 영향 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `scrap` (폐기) | 폐기 처리 | 불량 집계에 포함, 양품에서 차감 |
|
||||||
|
| `rework` (재작업) | 같은 공정에서 재작업 | 재작업 카드 자동 생성, 양품에서 차감 |
|
||||||
|
| `accept` (특채) | 조건부 합격 | concession_qty에 기록, 다음 공정 전달량에 합산 |
|
||||||
|
|
||||||
|
### 4.5 확정(Confirm Result) 상세
|
||||||
|
|
||||||
|
```
|
||||||
|
수동 확정 = 접수분 전량 미생산이지만 강제로 완료 처리
|
||||||
|
(예: 생산 중단, 일부만 완료 등)
|
||||||
|
|
||||||
|
POST /api/pop/production/confirm-result
|
||||||
|
body: { work_order_process_id: 분할행ID }
|
||||||
|
|
||||||
|
결과:
|
||||||
|
- status = 'completed'
|
||||||
|
- result_status = 'confirmed'
|
||||||
|
- 양품 있으면 다음 공정 활성화
|
||||||
|
- 마스터/작업지시 캐스케이드 완료 판정
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. MES 전용 API 엔드포인트 정리
|
||||||
|
|
||||||
|
**베이스 URL**: `/api/pop/production`
|
||||||
|
**인증**: 모든 엔드포인트에 JWT 토큰 필수
|
||||||
|
|
||||||
|
| 순서 | 메서드 | URL | 용도 | 호출 주체 |
|
||||||
|
|------|--------|-----|------|----------|
|
||||||
|
| 1 | POST | `/create-work-processes` | 작업지시에 공정 일괄 생성 | PC |
|
||||||
|
| 2 | POST | `/accept-process` | 공정 접수 (분할 행 생성) | POP |
|
||||||
|
| 3 | POST | `/cancel-accept` | 접수 취소 | POP |
|
||||||
|
| 4 | POST | `/save-result` | 실적 저장 (누적) | POP |
|
||||||
|
| 5 | POST | `/confirm-result` | 실적 확정 (수동 완료) | POP |
|
||||||
|
| 6 | GET | `/available-qty` | 접수가능량 조회 | POP |
|
||||||
|
| 7 | GET | `/result-history` | 차수별 실적 이력 | POP |
|
||||||
|
| 8 | GET | `/defect-types` | 불량 유형 목록 | POP |
|
||||||
|
| 9 | POST | `/timer` | 공정 타이머 (시작/정지/완료) | POP |
|
||||||
|
| 10 | POST | `/group-timer` | 그룹 타이머 (체크리스트별) | POP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. PC 화면 현황 (COMPANY_7 탑씰)
|
||||||
|
|
||||||
|
### 6.1 등록된 화면 목록
|
||||||
|
|
||||||
|
| 화면 ID | 이름 | 메인 테이블 | 용도 |
|
||||||
|
|---------|------|------------|------|
|
||||||
|
| 4155 | 작업지시 목록 | work_instruction | 목록 조회 |
|
||||||
|
| 4493 | 작업지시 등록화면 | work_instruction | 신규 등록 |
|
||||||
|
| 4156 | 수주 선택 | sales_order_detail | 모달 (수주 참조) |
|
||||||
|
| 4157 | 적용 확인 | work_instruction | 모달 (등록 확인) |
|
||||||
|
|
||||||
|
### 6.2 메뉴 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
생산관리 (COMPANY_7)
|
||||||
|
├── 생산옵션설정 (/screens/1606)
|
||||||
|
├── 생산계획 (/screens/3985)
|
||||||
|
├── 작업지시 (/production/work-instruction) <-- React 페이지 미구현
|
||||||
|
├── 공정정보관리 (/production/process-info)
|
||||||
|
├── 생산실적 (하위 없음)
|
||||||
|
└── 생산리포트 (/admin/report/production)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 현재 미구현 사항 (PC 개발 필요)
|
||||||
|
|
||||||
|
1. **라우팅 버전 선택 UI**: 품목 선택 시 해당 품목의 라우팅 버전 목록을 표시하고 선택하는 기능
|
||||||
|
2. **create-work-processes 호출 연동**: 작업지시 등록 시 자동으로 공정 생성 API를 호출하는 로직
|
||||||
|
3. **work_instruction.routing 컬럼 활용**: 현재 비어있음. routing_version_id를 저장하면 추적 가능
|
||||||
|
4. **작업지시 상태 관리**: 등록/수정/취소 워크플로우
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 마스터 데이터 사전 등록 요건
|
||||||
|
|
||||||
|
> 작업지시 등록 전에 다음 마스터 데이터가 반드시 등록되어 있어야 한다.
|
||||||
|
|
||||||
|
### 7.1 필수 사전 등록 순서
|
||||||
|
|
||||||
|
```
|
||||||
|
1. process_mng (공정 마스터) 등록
|
||||||
|
예: P002=가공, P003=검사, P009=포장
|
||||||
|
|
||||||
|
2. item_info (품목 마스터) 등록
|
||||||
|
예: item_number=R_FREE3_002, item_name=원제_AK1000
|
||||||
|
|
||||||
|
3. item_routing_version (라우팅 버전) 등록
|
||||||
|
item_code = item_info.item_number
|
||||||
|
is_default = true (기본 버전)
|
||||||
|
|
||||||
|
4. item_routing_detail (공정 순서) 등록
|
||||||
|
routing_version_id = 위에서 만든 버전 ID
|
||||||
|
seq_no = 1, process_code = P002 (1공정: 가공)
|
||||||
|
seq_no = 2, process_code = P003 (2공정: 검사)
|
||||||
|
seq_no = 3, process_code = P009 (3공정: 포장)
|
||||||
|
|
||||||
|
5. (선택) process_work_item + detail (체크리스트 템플릿)
|
||||||
|
routing_detail_id = 위의 item_routing_detail.id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 현재 COMPANY_7 데이터 현황
|
||||||
|
|
||||||
|
**등록된 공정 (15개)**:
|
||||||
|
|
||||||
|
| 공정코드 | 공정명 | 유형 |
|
||||||
|
|---------|--------|------|
|
||||||
|
| P002 | 가공 | PT001 |
|
||||||
|
| P003 | 검사 | PT003 |
|
||||||
|
| P005 | 치수검사 | PT004 |
|
||||||
|
| P006 | 테스트 | PT001 |
|
||||||
|
| P007 | 인쇄 | PT006 |
|
||||||
|
| P008 | 조립 | PT002 |
|
||||||
|
| P009 | 포장 | PT004 |
|
||||||
|
| PRC-001 ~ PRC-006 | 검수/가공/조립/검사/포장 | 다양 |
|
||||||
|
| PROC-001 | 확인 | 세척 |
|
||||||
|
|
||||||
|
**등록된 라우팅 버전 (24건)**: 다양한 품목에 대해 1~3개 공정 조합
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 데이터 예시: 전체 흐름 시뮬레이션
|
||||||
|
|
||||||
|
### 시나리오: 품목 R_FREE3_002를 500개 생산
|
||||||
|
|
||||||
|
#### Step 1: 작업지시 등록 (PC)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 자동 생성되는 행
|
||||||
|
INSERT INTO work_instruction (work_instruction_no, item_id, qty, status)
|
||||||
|
VALUES ('WI-20260320-001', 'a4e492a0-...', '500', 'waiting');
|
||||||
|
-- id = 'wi-new-001' (UUID 자동생성)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: 공정 생성 API 호출 (PC -> 서버)
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/pop/production/create-work-processes
|
||||||
|
{ work_instruction_id: 'wi-new-001',
|
||||||
|
routing_version_id: '5cff0c1e-...' }
|
||||||
|
```
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 서버가 자동 생성하는 행 (item_routing_detail 기반)
|
||||||
|
INSERT INTO work_order_process (wo_id, seq_no, process_code, process_name, status, plan_qty)
|
||||||
|
VALUES
|
||||||
|
('wi-new-001', '2', 'P002', '가공', 'acceptable', '500'), -- 1공정: 접수가능
|
||||||
|
('wi-new-001', '30', 'P006', '테스트', 'waiting', '500'); -- 2공정: 대기
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: POP에서 작업자가 1공정 접수 (300개)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- accept-process가 생성하는 분할 행
|
||||||
|
INSERT INTO work_order_process (wo_id, seq_no, process_code, status, input_qty,
|
||||||
|
parent_process_id, accepted_by)
|
||||||
|
VALUES ('wi-new-001', '2', 'P002', 'in_progress', '300',
|
||||||
|
'마스터행ID', '작업자A');
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4: POP에서 실적 저장 (생산 300개, 불량 10개)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- save-result가 UPDATE하는 행
|
||||||
|
UPDATE work_order_process
|
||||||
|
SET total_production_qty = '300',
|
||||||
|
good_qty = '290', -- 서버 계산: 300 - 10
|
||||||
|
defect_qty = '10',
|
||||||
|
status = 'completed' -- 자동완료: 300 >= 300(input_qty)
|
||||||
|
WHERE id = '분할행ID';
|
||||||
|
|
||||||
|
-- 다음 공정 자동 활성화
|
||||||
|
UPDATE work_order_process
|
||||||
|
SET status = 'acceptable' -- waiting -> acceptable
|
||||||
|
WHERE wo_id = 'wi-new-001' AND seq_no = '30'
|
||||||
|
AND parent_process_id IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 5: 2공정도 접수 -> 실적 -> 완료하면
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 작업지시 자동 완료
|
||||||
|
UPDATE work_instruction
|
||||||
|
SET status = 'completed',
|
||||||
|
progress_status = 'completed',
|
||||||
|
completed_qty = '280' -- 마지막 공정 양품 합계
|
||||||
|
WHERE id = 'wi-new-001';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 주의사항 및 제약
|
||||||
|
|
||||||
|
### 9.1 필수 규칙
|
||||||
|
|
||||||
|
1. **create-work-processes는 1회만 호출 가능**: 같은 작업지시에 대해 2번 호출하면 409 에러
|
||||||
|
2. **routing_version_id는 필수**: 라우팅 없이는 공정을 생성할 수 없음
|
||||||
|
3. **1공정만 즉시 접수가능**: 나머지는 앞공정 양품 발생 후 자동 전환
|
||||||
|
4. **수량은 모두 VARCHAR**: 정수 변환 시 parseInt 필수
|
||||||
|
5. **멀티테넌시**: 모든 쿼리에 company_code 필터 필수
|
||||||
|
6. **분할 행 구조**: 접수 시 마스터 행에서 분할 행을 INSERT하는 방식. 마스터 행에는 직접 실적 등록 불가
|
||||||
|
|
||||||
|
### 9.2 현재 미활용 컬럼
|
||||||
|
|
||||||
|
| 컬럼 | 테이블 | 상태 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `routing` | work_instruction | 비어있음 (routing_version_id 저장 권장) |
|
||||||
|
| `equipment_id` | work_instruction | 등록 가능하나 POP 연동 미구현 |
|
||||||
|
| `item_id` | work_instruction | 일부 테스트 데이터에서 비어있음 |
|
||||||
|
|
||||||
|
### 9.3 자동 완료 판정 주의
|
||||||
|
|
||||||
|
- 재작업 카드가 있으면 해당 카드가 완료될 때까지 마스터 행이 완료되지 않음
|
||||||
|
- 특채(concession_qty)는 양품에 포함되지 않으나 다음 공정 전달량에는 합산됨
|
||||||
|
- 초과 생산은 경고만 하고 차단하지 않음 (현장 유연성)
|
||||||
|
|
@ -0,0 +1,603 @@
|
||||||
|
# POP 화면 배포서버 마이그레이션 가이드
|
||||||
|
|
||||||
|
> **작성일**: 2026-03-23
|
||||||
|
> **목적**: 로컬(탑씰 COMPANY_7) POP 화면 5종을 배포서버 COMPANY_21(테스트회사)로 복사
|
||||||
|
> **대상 화면**: 4173, 4479, 4480, 4576, 4577
|
||||||
|
> **주의**: DB 작업 전 반드시 백업 후 진행
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 개념 정리 (먼저 읽기)
|
||||||
|
|
||||||
|
### PopDeployModal이란?
|
||||||
|
|
||||||
|
POP 관리 화면 내 내장된 **화면 배포 도구**입니다.
|
||||||
|
|
||||||
|
- **접근**: POP 디자이너 > 카테고리 트리 > 그룹 우클릭 또는 배포 버튼
|
||||||
|
- **하는 일**:
|
||||||
|
1. 선택한 화면들을 다른 회사 계정으로 복사 (`screen_definitions` 새 행 생성)
|
||||||
|
2. POP 레이아웃 JSON 복사 (`screen_layouts_pop`)
|
||||||
|
3. **layout_json 내 화면 ID 참조 자동 리매핑** (screenId, cartScreenId, sourceScreenId, targetScreenId)
|
||||||
|
4. 카테고리 그룹 구조 생성 (`screen_groups`, `screen_group_screens`)
|
||||||
|
5. numberingRuleId 자동 제거 (회사별 고유값이므로)
|
||||||
|
- **제약**: 같은 서버 안에서만 동작 (로컬 → 배포 서버 간 복사 불가)
|
||||||
|
- **권장 사용 시점**: 배포 DB COMPANY_7에 화면이 먼저 세팅된 후, 같은 배포 서버 내 테스트 계정으로 복사할 때
|
||||||
|
|
||||||
|
### 마이그레이션 전체 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[로컬 DB / COMPANY_7] ──── SQL 직접 복사 ────→ [배포 DB / COMPANY_7]
|
||||||
|
│
|
||||||
|
PopDeployModal
|
||||||
|
(배포서버 내)
|
||||||
|
│
|
||||||
|
↓
|
||||||
|
[배포 DB / COMPANY_21]
|
||||||
|
(테스트 환경)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 현황 요약
|
||||||
|
|
||||||
|
### 1-1. 환경 정보
|
||||||
|
|
||||||
|
| 구분 | 로컬 DB | 배포 DB |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Host | 39.117.244.52:11132 | 211.115.91.141:11134 |
|
||||||
|
| Database | plm | plm |
|
||||||
|
| 소스 회사 | COMPANY_7 (탑씰) | - |
|
||||||
|
| 1차 타겟 | - | COMPANY_7 (탑씰, SQL 직접 삽입) |
|
||||||
|
| 2차 타겟 | - | COMPANY_21 (테스트회사, PopDeployModal) |
|
||||||
|
|
||||||
|
### 1-2. 복사 대상 화면
|
||||||
|
|
||||||
|
| 화면 ID | screen_code | screen_name | 역할 |
|
||||||
|
|---------|-------------|-------------|------|
|
||||||
|
| 4479 | COMPANY_7_179 | 홈 | POP 메인 홈 화면 |
|
||||||
|
| 4576 | COMPANY_7_194 | 입고메뉴 | 입고 카테고리 메뉴 |
|
||||||
|
| 4173 | COMPANY_7_169 | 구매입고 담기 | 구매입고 항목 선택/담기 |
|
||||||
|
| 4577 | COMPANY_7_195 | 구매입고 장바구니 | 장바구니 확인/입고 확정 |
|
||||||
|
| 4480 | COMPANY_7_180 | MES공정 | MES 생산실적 관리 |
|
||||||
|
|
||||||
|
### 1-3. POP 카테고리 구조 (screen_groups)
|
||||||
|
|
||||||
|
```
|
||||||
|
탑씰 (id:3134, code:TOPSSEAL)
|
||||||
|
├── 홈 #4479
|
||||||
|
├── 입고관리 (id:3216, code:INBOUND_MENU)
|
||||||
|
│ ├── 입고메뉴 #4576
|
||||||
|
│ └── 구매입고 (id:3221, code:PURCHASE RECEIPT)
|
||||||
|
│ ├── 구매입고 담기 #4173
|
||||||
|
│ └── 구매입고 장바구니 #4577
|
||||||
|
└── 생산실적 (id:3220, code:PRODUCTION RESULTS)
|
||||||
|
└── MES공정 #4480
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1-4. 화면 간 상호참조 (layout_json 내부)
|
||||||
|
|
||||||
|
| 출발 화면 | 참조 방식 | 대상 화면 | JSON 키 |
|
||||||
|
|----------|----------|----------|---------|
|
||||||
|
| 4479 홈 | navigate | 4480 MES공정 | `screenId: "4480"` |
|
||||||
|
| 4479 홈 | navigate | 4576 입고메뉴 | `screenId: "4576"` |
|
||||||
|
| 4576 입고메뉴 | navigate | 4173 구매입고 | `screenId: "4173"` |
|
||||||
|
| 4173 구매입고 | cart-save | 4577 장바구니 | `cartScreenId: "4577"` |
|
||||||
|
| 4577 장바구니 | source | 4173 구매입고 | `sourceScreenId: 4173` (숫자) |
|
||||||
|
| 4577 장바구니 | navigate | 4173 구매입고 | `targetScreenId: "4173"` |
|
||||||
|
|
||||||
|
> PopDeployModal을 사용하면 이 참조들이 모두 자동 리매핑됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 배포 DB 현황 점검 결과
|
||||||
|
|
||||||
|
### 2-1. 테이블 누락 상태
|
||||||
|
|
||||||
|
| 테이블 | 로컬 | 배포 | MES/기능 의존도 |
|
||||||
|
|--------|------|------|----------------|
|
||||||
|
| work_order_process | O (37컬럼) | **없음** | MES공정 화면 전체 동작 불가 |
|
||||||
|
| process_work_result | O (35컬럼) | **없음** | MES 체크리스트 기능 불가 |
|
||||||
|
| work_order_process_log | O (12컬럼+트리거) | **없음** | 공정 변경 이력 로깅 불가 |
|
||||||
|
| cart_items | O (16컬럼) | **없음** | 구매입고 장바구니 전체 불가 |
|
||||||
|
|
||||||
|
### 2-2. 컬럼 누락 상태
|
||||||
|
|
||||||
|
| 테이블 | 누락 컬럼 | 영향 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| work_instruction | `reason`, `completed_qty` | MES 완료수량 업데이트 실패 |
|
||||||
|
|
||||||
|
### 2-3. COMPANY_21 (테스트회사) 현황
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| 회사 존재 | O (active) |
|
||||||
|
| 기존 POP 레이아웃 | 없음 (깨끗한 상태) |
|
||||||
|
| 로그인 가능 계정 | **0개 (계정 없음!)** |
|
||||||
|
| 기존 화면 수 | 23개 (일반 ERP 화면들) |
|
||||||
|
|
||||||
|
> **중요**: COMPANY_21에는 현재 등록된 사용자가 없습니다.
|
||||||
|
> 테스트 전에 사용자 계정을 먼저 생성해야 로그인 및 POP 테스트가 가능합니다.
|
||||||
|
|
||||||
|
### 2-4. COMPANY_7 (탑씰) 배포 서버 현황
|
||||||
|
|
||||||
|
- 기존 POP 레이아웃: `screen_id 4114` (테스트용 1개만 존재)
|
||||||
|
- 로그인 계정: `topseal_admin`, `topseal_admin2`, `topseal_user`, `test1`, `test2`
|
||||||
|
- 5개 화면(4173, 4479, 4480, 4576, 4577) 모두 배포 DB에 없음 → 안전하게 삽입 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 누락 테이블/컬럼 추가 방법
|
||||||
|
|
||||||
|
### 3-1. Vexplor DDL 시스템으로 가능한 작업
|
||||||
|
|
||||||
|
Vexplor에는 **관리자 DDL 실행 시스템**이 내장되어 있습니다.
|
||||||
|
|
||||||
|
- **접근**: 관리자 > 시스템관리 > 테이블관리 (`/admin/systemMng/tableMngList`)
|
||||||
|
- **가능한 작업**:
|
||||||
|
- 테이블 생성 (`POST /api/ddl/tables`)
|
||||||
|
- 컬럼 추가 (`POST /api/ddl/tables/:tableName/columns`)
|
||||||
|
- 생성 시 `table_type_columns` 메타데이터 자동 등록
|
||||||
|
- **권한**: 슈퍼 어드민 계정 (`company_code = '*'`)만 사용 가능
|
||||||
|
- **불가능한 것**: 트리거 함수 생성 (이건 psql 직접 실행 필요)
|
||||||
|
|
||||||
|
### 3-2. 작업 분류
|
||||||
|
|
||||||
|
| 작업 | 방법 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| work_order_process 생성 | **psql 직접 실행** | 컬럼 37개 + 인덱스 7개, UI 입력보다 SQL이 효율적 |
|
||||||
|
| process_work_result 생성 | **psql 직접 실행** | 컬럼 35개 |
|
||||||
|
| cart_items 생성 | **psql 직접 실행** | 컬럼 16개 + 인덱스 5개 |
|
||||||
|
| work_order_process_log 생성 | **psql 직접 실행** | 트리거 함수 포함 필수 |
|
||||||
|
| work_instruction 컬럼 추가 | DDL UI 또는 psql | 컬럼 2개, 어느 방법이든 가능 |
|
||||||
|
| table_type_columns 메타데이터 | psql COPY 명령 | 로컬에서 추출 후 배포에 삽입 |
|
||||||
|
|
||||||
|
> **결론**: DDL UI는 컬럼 추가(`work_instruction`) 정도에 활용하고,
|
||||||
|
> 테이블 생성은 모두 psql SQL 직접 실행이 현실적입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 실행 절차 (단계별)
|
||||||
|
|
||||||
|
### STEP 0: 배포 DB 백업
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 배포 DB에서 실행
|
||||||
|
CREATE TABLE backup_20260323_screen_definitions AS
|
||||||
|
SELECT * FROM screen_definitions WHERE company_code = 'COMPANY_7';
|
||||||
|
CREATE TABLE backup_20260323_screen_layouts_pop AS
|
||||||
|
SELECT * FROM screen_layouts_pop WHERE company_code = 'COMPANY_7';
|
||||||
|
CREATE TABLE backup_20260323_screen_groups AS
|
||||||
|
SELECT * FROM screen_groups WHERE company_code = 'COMPANY_7';
|
||||||
|
CREATE TABLE backup_20260323_screen_group_screens AS
|
||||||
|
SELECT * FROM screen_group_screens WHERE company_code = 'COMPANY_7';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### STEP 1: 누락 테이블 생성 (배포 DB에서 psql 실행)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1-1. work_order_process
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE work_order_process (
|
||||||
|
id character varying(500) NOT NULL DEFAULT (gen_random_uuid())::text,
|
||||||
|
created_date timestamp without time zone DEFAULT now(),
|
||||||
|
updated_date timestamp without time zone DEFAULT now(),
|
||||||
|
writer character varying(255),
|
||||||
|
company_code character varying(255),
|
||||||
|
wo_id character varying(500),
|
||||||
|
seq_no character varying(255),
|
||||||
|
process_code character varying(255),
|
||||||
|
process_name character varying(255),
|
||||||
|
is_required character varying(255),
|
||||||
|
is_fixed_order character varying(255),
|
||||||
|
standard_time character varying(255),
|
||||||
|
status character varying(255),
|
||||||
|
accepted_by character varying(255),
|
||||||
|
accepted_at character varying(255),
|
||||||
|
started_at character varying(255),
|
||||||
|
completed_at character varying(255),
|
||||||
|
plan_qty character varying(255),
|
||||||
|
input_qty character varying(255),
|
||||||
|
good_qty character varying(255),
|
||||||
|
defect_qty character varying(255),
|
||||||
|
equipment_code character varying(255),
|
||||||
|
remark character varying(500),
|
||||||
|
paused_at character varying(500),
|
||||||
|
total_paused_time character varying(500) DEFAULT '0',
|
||||||
|
routing_detail_id character varying(500),
|
||||||
|
actual_work_time character varying(500) DEFAULT NULL::character varying,
|
||||||
|
completed_by character varying(500) DEFAULT NULL::character varying,
|
||||||
|
total_production_qty character varying(500),
|
||||||
|
defect_detail character varying(500),
|
||||||
|
result_note character varying(500),
|
||||||
|
result_status character varying(500) DEFAULT 'draft'::character varying,
|
||||||
|
attachments character varying(500),
|
||||||
|
parent_process_id character varying(500) DEFAULT NULL::character varying,
|
||||||
|
concession_qty character varying(500) DEFAULT '0'::character varying,
|
||||||
|
is_rework character varying(500) DEFAULT 'N'::character varying,
|
||||||
|
rework_source_id character varying(500) DEFAULT NULL::character varying,
|
||||||
|
CONSTRAINT work_order_process_pkey PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_wop_company ON work_order_process (company_code);
|
||||||
|
CREATE INDEX idx_wop_wo_id ON work_order_process (wo_id);
|
||||||
|
CREATE INDEX idx_wop_wo_id_seq_no ON work_order_process (wo_id, seq_no);
|
||||||
|
CREATE INDEX idx_wop_process ON work_order_process (company_code, process_code);
|
||||||
|
CREATE INDEX idx_wop_status ON work_order_process (company_code, process_code, status);
|
||||||
|
CREATE INDEX idx_wop_parent_process_id ON work_order_process (parent_process_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1-2. process_work_result
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE process_work_result (
|
||||||
|
id character varying(500) NOT NULL DEFAULT (gen_random_uuid())::text,
|
||||||
|
created_date timestamp without time zone DEFAULT now(),
|
||||||
|
updated_date timestamp without time zone DEFAULT now(),
|
||||||
|
writer character varying(500) DEFAULT NULL::character varying,
|
||||||
|
company_code character varying(500),
|
||||||
|
work_order_process_id character varying(500),
|
||||||
|
source_work_item_id character varying(500),
|
||||||
|
source_detail_id character varying(500),
|
||||||
|
work_phase character varying(500),
|
||||||
|
item_title character varying(500),
|
||||||
|
item_sort_order character varying(500),
|
||||||
|
detail_content character varying(500),
|
||||||
|
detail_type character varying(500),
|
||||||
|
detail_sort_order character varying(500),
|
||||||
|
is_required character varying(500),
|
||||||
|
inspection_code character varying(500),
|
||||||
|
inspection_method character varying(500),
|
||||||
|
unit character varying(500),
|
||||||
|
lower_limit character varying(500),
|
||||||
|
upper_limit character varying(500),
|
||||||
|
input_type character varying(500),
|
||||||
|
lookup_target character varying(500),
|
||||||
|
display_fields character varying(500),
|
||||||
|
duration_minutes character varying(500),
|
||||||
|
status character varying(500),
|
||||||
|
result_value character varying(500),
|
||||||
|
is_passed character varying(500),
|
||||||
|
remark character varying(500),
|
||||||
|
recorded_by character varying(500),
|
||||||
|
recorded_at character varying(500),
|
||||||
|
started_at character varying(500) DEFAULT NULL::character varying,
|
||||||
|
group_started_at character varying(500) DEFAULT NULL::character varying,
|
||||||
|
group_paused_at character varying(500) DEFAULT NULL::character varying,
|
||||||
|
group_total_paused_time character varying(500) DEFAULT NULL::character varying,
|
||||||
|
group_completed_at character varying(500) DEFAULT NULL::character varying,
|
||||||
|
CONSTRAINT process_work_result_pkey PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1-3. work_order_process_log + 트리거
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 로그 테이블
|
||||||
|
CREATE TABLE work_order_process_log (
|
||||||
|
log_id SERIAL PRIMARY KEY,
|
||||||
|
operation_type character varying(10) NOT NULL,
|
||||||
|
original_id character varying(100),
|
||||||
|
changed_column character varying(100),
|
||||||
|
old_value text,
|
||||||
|
new_value text,
|
||||||
|
changed_by character varying(50),
|
||||||
|
changed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ip_address character varying(50),
|
||||||
|
user_agent text,
|
||||||
|
full_row_before jsonb,
|
||||||
|
full_row_after jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 트리거 함수 (로컬 DB에서 정확한 정의 먼저 추출)
|
||||||
|
-- 로컬에서: SELECT pg_get_functiondef(oid) FROM pg_proc WHERE proname = 'work_order_process_log_trigger_func';
|
||||||
|
-- 추출한 함수 정의를 아래에 붙여넣기
|
||||||
|
|
||||||
|
-- 트리거 등록 (함수 생성 후 실행)
|
||||||
|
CREATE TRIGGER work_order_process_audit_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON work_order_process
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION work_order_process_log_trigger_func();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1-4. cart_items
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE cart_items (
|
||||||
|
id character varying(255) NOT NULL DEFAULT (gen_random_uuid())::text,
|
||||||
|
created_date timestamp without time zone DEFAULT now(),
|
||||||
|
updated_date timestamp without time zone DEFAULT now(),
|
||||||
|
company_code character varying(20),
|
||||||
|
cart_type character varying(255),
|
||||||
|
screen_id character varying(255),
|
||||||
|
user_id character varying(255),
|
||||||
|
source_table character varying(255),
|
||||||
|
row_key text,
|
||||||
|
row_data text,
|
||||||
|
quantity character varying(255),
|
||||||
|
unit character varying(255),
|
||||||
|
package_unit character varying(255),
|
||||||
|
package_entries text,
|
||||||
|
status character varying(255),
|
||||||
|
memo text,
|
||||||
|
CONSTRAINT cart_items_pkey PRIMARY KEY (id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_cart_items_company ON cart_items (company_code);
|
||||||
|
CREATE INDEX idx_cart_items_screen_user ON cart_items (screen_id, user_id);
|
||||||
|
CREATE INDEX idx_cart_items_type ON cart_items (cart_type);
|
||||||
|
CREATE INDEX idx_cart_items_status ON cart_items (status);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### STEP 2: 기존 테이블 컬럼 추가
|
||||||
|
|
||||||
|
#### 방법 A: DDL UI (관리자 화면)
|
||||||
|
|
||||||
|
1. 슈퍼 어드민 계정으로 배포서버 접속
|
||||||
|
2. 관리자 > 시스템관리 > 테이블관리
|
||||||
|
3. `work_instruction` 테이블 선택
|
||||||
|
4. 컬럼 추가 버튼 → `reason` (varchar 500) 추가
|
||||||
|
5. 컬럼 추가 버튼 → `completed_qty` (varchar 500, 기본값: '0') 추가
|
||||||
|
|
||||||
|
#### 방법 B: SQL 직접
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE work_instruction
|
||||||
|
ADD COLUMN IF NOT EXISTS reason character varying(500),
|
||||||
|
ADD COLUMN IF NOT EXISTS completed_qty character varying(500) DEFAULT '0'::character varying;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### STEP 3: table_type_columns 메타데이터 복사 (로컬 → 배포)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 로컬 DB에서 추출
|
||||||
|
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
|
||||||
|
COPY (
|
||||||
|
SELECT * FROM table_type_columns
|
||||||
|
WHERE table_name IN ('work_order_process', 'cart_items', 'process_work_result')
|
||||||
|
) TO STDOUT WITH CSV HEADER" > /tmp/ttc_export.csv
|
||||||
|
|
||||||
|
# 배포 DB에 삽입 (충돌 시 무시)
|
||||||
|
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
||||||
|
COPY table_type_columns FROM STDIN WITH CSV HEADER
|
||||||
|
ON CONFLICT DO NOTHING" < /tmp/ttc_export.csv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### STEP 4: 화면 5종 → 배포 COMPANY_7 복사 (SQL)
|
||||||
|
|
||||||
|
> 화면 ID 4173~4577은 배포 DB에 없으므로 동일 ID로 안전하게 삽입 가능
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 로컬에서 screen_definitions 추출
|
||||||
|
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
|
||||||
|
COPY (
|
||||||
|
SELECT * FROM screen_definitions
|
||||||
|
WHERE screen_id IN (4173, 4479, 4480, 4576, 4577)
|
||||||
|
) TO STDOUT WITH CSV HEADER" > /tmp/screen_def.csv
|
||||||
|
|
||||||
|
# 배포에 삽입
|
||||||
|
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
||||||
|
COPY screen_definitions FROM STDIN WITH CSV HEADER
|
||||||
|
ON CONFLICT DO NOTHING" < /tmp/screen_def.csv
|
||||||
|
|
||||||
|
|
||||||
|
# screen_layouts_pop 추출 (layout_id 제외)
|
||||||
|
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
|
||||||
|
COPY (
|
||||||
|
SELECT screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by
|
||||||
|
FROM screen_layouts_pop
|
||||||
|
WHERE screen_id IN (4173, 4479, 4480, 4576, 4577)
|
||||||
|
) TO STDOUT WITH CSV HEADER" > /tmp/screen_layouts.csv
|
||||||
|
|
||||||
|
# 배포에 삽입
|
||||||
|
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
||||||
|
COPY screen_layouts_pop (screen_id, company_code, layout_data, created_at, updated_at, created_by, updated_by)
|
||||||
|
FROM STDIN WITH CSV HEADER
|
||||||
|
ON CONFLICT (screen_id, company_code) DO NOTHING" < /tmp/screen_layouts.csv
|
||||||
|
|
||||||
|
|
||||||
|
# screen_groups 추출 (그룹 4개: 3134, 3216, 3220, 3221)
|
||||||
|
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
|
||||||
|
COPY (
|
||||||
|
SELECT * FROM screen_groups
|
||||||
|
WHERE id IN (3134, 3216, 3220, 3221)
|
||||||
|
) TO STDOUT WITH CSV HEADER" > /tmp/screen_groups.csv
|
||||||
|
|
||||||
|
# 배포에 삽입
|
||||||
|
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
||||||
|
COPY screen_groups FROM STDIN WITH CSV HEADER
|
||||||
|
ON CONFLICT DO NOTHING" < /tmp/screen_groups.csv
|
||||||
|
|
||||||
|
|
||||||
|
# screen_group_screens 추출
|
||||||
|
PGPASSWORD='ph0909!!' psql -h 39.117.244.52 -p 11132 -U postgres -d plm -c "
|
||||||
|
COPY (
|
||||||
|
SELECT * FROM screen_group_screens
|
||||||
|
WHERE screen_id IN (4173, 4479, 4480, 4576, 4577)
|
||||||
|
) TO STDOUT WITH CSV HEADER" > /tmp/screen_group_screens.csv
|
||||||
|
|
||||||
|
# 배포에 삽입
|
||||||
|
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d plm -c "
|
||||||
|
COPY screen_group_screens FROM STDIN WITH CSV HEADER
|
||||||
|
ON CONFLICT DO NOTHING" < /tmp/screen_group_screens.csv
|
||||||
|
|
||||||
|
|
||||||
|
# 시퀀스 동기화 (배포 DB에서 실행)
|
||||||
|
SELECT setval('screen_definitions_screen_id_seq',
|
||||||
|
GREATEST((SELECT MAX(screen_id) FROM screen_definitions),
|
||||||
|
(SELECT last_value FROM screen_definitions_screen_id_seq)));
|
||||||
|
|
||||||
|
SELECT setval('screen_layouts_pop_layout_id_seq',
|
||||||
|
GREATEST((SELECT MAX(layout_id) FROM screen_layouts_pop),
|
||||||
|
(SELECT last_value FROM screen_layouts_pop_layout_id_seq)));
|
||||||
|
|
||||||
|
SELECT setval('screen_groups_id_seq',
|
||||||
|
GREATEST((SELECT MAX(id) FROM screen_groups),
|
||||||
|
(SELECT last_value FROM screen_groups_id_seq)));
|
||||||
|
|
||||||
|
SELECT setval('screen_group_screens_id_seq',
|
||||||
|
GREATEST((SELECT MAX(id) FROM screen_group_screens),
|
||||||
|
(SELECT last_value FROM screen_group_screens_id_seq)));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### STEP 5: COMPANY_21 테스트 계정 사용자 생성
|
||||||
|
|
||||||
|
> COMPANY_21에 현재 등록된 사용자가 없습니다. 로그인하려면 계정이 필요합니다.
|
||||||
|
|
||||||
|
배포서버 관리자 화면(슈퍼 어드민)에서 COMPANY_21 소속 사용자를 추가합니다:
|
||||||
|
- 관리자 > 회사관리 > COMPANY_21 > 사용자 추가
|
||||||
|
- 또는 SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- user_info 테이블에 테스트 사용자 추가 (기존 패턴 참고)
|
||||||
|
-- 실제 password 해시는 기존 계정 방식과 동일하게 처리 필요
|
||||||
|
INSERT INTO user_info (user_id, user_name, company_code, user_type, status, password)
|
||||||
|
VALUES ('test21', '테스트계정', 'COMPANY_21', 'COMPANY_ADMIN', 'active', '-- 해시된 비밀번호 --');
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### STEP 6: PopDeployModal로 COMPANY_7 → COMPANY_21 복사
|
||||||
|
|
||||||
|
1. 배포서버에서 `topseal_admin` 계정으로 로그인
|
||||||
|
2. POP 디자이너 > POP 관리 화면 > 카테고리 트리 접속
|
||||||
|
3. "탑씰" 그룹에서 배포 버튼 클릭
|
||||||
|
4. 대상 회사: **COMPANY_21 (테스트회사)** 선택
|
||||||
|
5. 5개 화면 포함 여부 확인 후 배포 실행
|
||||||
|
6. PopDeployModal이 COMPANY_21 전용 새 화면 ID 자동 부여 + 참조 자동 리매핑
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### STEP 7: 검증
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 배포 DB에서 실행 --
|
||||||
|
|
||||||
|
-- 7-1. 누락 테이블 생성 확인
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name IN ('work_order_process', 'cart_items', 'process_work_result', 'work_order_process_log');
|
||||||
|
-- 예상: 4건
|
||||||
|
|
||||||
|
-- 7-2. work_instruction 컬럼 확인
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'work_instruction' AND column_name IN ('reason', 'completed_qty');
|
||||||
|
-- 예상: 2건
|
||||||
|
|
||||||
|
-- 7-3. COMPANY_7 화면 삽입 확인
|
||||||
|
SELECT screen_id, screen_name, company_code FROM screen_definitions
|
||||||
|
WHERE screen_id IN (4173, 4479, 4480, 4576, 4577);
|
||||||
|
-- 예상: 5건 (COMPANY_7)
|
||||||
|
|
||||||
|
-- 7-4. COMPANY_21 화면 복사 확인 (PopDeployModal 후)
|
||||||
|
SELECT sd.screen_id, sd.screen_name, sd.company_code,
|
||||||
|
CASE WHEN slp.layout_id IS NOT NULL THEN 'Y' ELSE 'N' END as has_layout
|
||||||
|
FROM screen_definitions sd
|
||||||
|
LEFT JOIN screen_layouts_pop slp ON sd.screen_id = slp.screen_id
|
||||||
|
WHERE sd.company_code = 'COMPANY_21'
|
||||||
|
AND sd.screen_name IN ('홈', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니');
|
||||||
|
-- 예상: 5건 + has_layout = Y
|
||||||
|
|
||||||
|
-- 7-5. 화면 간 참조 무결성 (COMPANY_21 기준 새 ID로 리매핑됐는지 확인)
|
||||||
|
SELECT slp.screen_id,
|
||||||
|
layout_data::text LIKE '%screenId%' as has_nav_ref,
|
||||||
|
layout_data::text LIKE '%cartScreenId%' as has_cart_ref
|
||||||
|
FROM screen_layouts_pop slp
|
||||||
|
JOIN screen_definitions sd ON slp.screen_id = sd.screen_id
|
||||||
|
WHERE sd.company_code = 'COMPANY_21'
|
||||||
|
AND sd.screen_name IN ('홈', '입고메뉴', '구매입고 담기', '구매입고 장바구니');
|
||||||
|
|
||||||
|
-- 7-6. 시퀀스 정합성
|
||||||
|
SELECT 'screen_definitions' as tbl,
|
||||||
|
(SELECT MAX(screen_id) FROM screen_definitions) as max_id,
|
||||||
|
(SELECT last_value FROM screen_definitions_screen_id_seq) as seq_val,
|
||||||
|
CASE WHEN (SELECT last_value FROM screen_definitions_screen_id_seq)
|
||||||
|
>= (SELECT MAX(screen_id) FROM screen_definitions)
|
||||||
|
THEN 'OK' ELSE 'MISMATCH' END as status;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. COMPANY_21 테스트 환경 수정 가능 여부
|
||||||
|
|
||||||
|
| 항목 | 수정 가능? | 방법 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 회사명/정보 | O | 관리자 > 회사관리 |
|
||||||
|
| 사용자 추가/수정 | O | 관리자 > 사용자관리 |
|
||||||
|
| POP 화면 수정 | O | POP 디자이너에서 직접 편집 |
|
||||||
|
| 화면 삭제 후 재배포 | O | PopDeployModal 재실행 |
|
||||||
|
| 기존 ERP 화면 영향 없음 | O | POP 레이아웃 별도 테이블 관리 |
|
||||||
|
|
||||||
|
> COMPANY_21은 테스트 전용 계정이므로 자유롭게 수정/삭제 가능합니다.
|
||||||
|
> 기존 23개 ERP 화면(구매관리, 영업관리 등)은 POP과 무관하므로 건드리지 않아도 됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 요약: 실행 순서
|
||||||
|
|
||||||
|
| 순서 | 작업 | 방법 | 담당 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| STEP 0 | 배포 DB 백업 | psql | DB 담당자 |
|
||||||
|
| STEP 1 | 누락 테이블 4개 생성 | psql SQL | DB 담당자 |
|
||||||
|
| STEP 2 | work_instruction 컬럼 추가 | DDL UI 또는 psql | DB 담당자 |
|
||||||
|
| STEP 3 | table_type_columns 메타데이터 복사 | psql COPY | DB 담당자 |
|
||||||
|
| STEP 4 | 화면 5종 COMPANY_7에 삽입 | psql COPY | DB 담당자 |
|
||||||
|
| STEP 5 | COMPANY_21 테스트 사용자 생성 | 관리자 UI 또는 SQL | 어드민 계정 |
|
||||||
|
| STEP 6 | PopDeployModal로 COMPANY_21 복사 | 배포서버 UI | 어드민 계정 |
|
||||||
|
| STEP 7 | 검증 쿼리 실행 | psql | DB 담당자 |
|
||||||
|
| STEP 8 | 브라우저 POP 화면 테스트 | 브라우저 | 테스터 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 롤백 방법
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- COMPANY_21 POP 화면 삭제 (PopDeployModal로 생성된 것)
|
||||||
|
DELETE FROM screen_group_screens
|
||||||
|
WHERE screen_id IN (
|
||||||
|
SELECT screen_id FROM screen_definitions WHERE company_code = 'COMPANY_21'
|
||||||
|
AND screen_name IN ('홈', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니')
|
||||||
|
);
|
||||||
|
DELETE FROM screen_layouts_pop
|
||||||
|
WHERE screen_id IN (
|
||||||
|
SELECT screen_id FROM screen_definitions WHERE company_code = 'COMPANY_21'
|
||||||
|
AND screen_name IN ('홈', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니')
|
||||||
|
);
|
||||||
|
DELETE FROM screen_definitions
|
||||||
|
WHERE company_code = 'COMPANY_21'
|
||||||
|
AND screen_name IN ('홈', 'MES공정', '입고메뉴', '구매입고 담기', '구매입고 장바구니');
|
||||||
|
|
||||||
|
-- COMPANY_7 화면 삭제 (SQL로 삽입한 것)
|
||||||
|
DELETE FROM screen_group_screens WHERE screen_id IN (4173, 4479, 4480, 4576, 4577);
|
||||||
|
DELETE FROM screen_layouts_pop WHERE screen_id IN (4173, 4479, 4480, 4576, 4577);
|
||||||
|
DELETE FROM screen_definitions WHERE screen_id IN (4173, 4479, 4480, 4576, 4577);
|
||||||
|
DELETE FROM screen_groups WHERE id IN (3134, 3216, 3220, 3221)
|
||||||
|
AND id NOT IN (SELECT id FROM backup_20260323_screen_groups);
|
||||||
|
|
||||||
|
-- 생성한 테이블 제거
|
||||||
|
DROP TABLE IF EXISTS work_order_process_log;
|
||||||
|
DROP TABLE IF EXISTS process_work_result;
|
||||||
|
DROP TABLE IF EXISTS work_order_process;
|
||||||
|
DROP TABLE IF EXISTS cart_items;
|
||||||
|
|
||||||
|
-- 추가 컬럼 제거
|
||||||
|
ALTER TABLE work_instruction DROP COLUMN IF EXISTS reason;
|
||||||
|
ALTER TABLE work_instruction DROP COLUMN IF EXISTS completed_qty;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 문서는 로컬 DB와 배포 DB를 읽기 전용으로 점검한 결과를 바탕으로 작성되었습니다.*
|
||||||
|
*실제 실행 전 반드시 배포 DB 백업을 완료하세요.*
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||||
|
{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||||
|
{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||||
|
{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||||
|
{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||||
|
{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||||
|
{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||||
|
{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":110167}
|
||||||
|
{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":196548}
|
||||||
|
{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":253997}
|
||||||
|
{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":339528}
|
||||||
|
{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":380641}
|
||||||
|
{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":413980}
|
||||||
|
{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":401646}
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"lastSentAt": "2026-03-25T05:06:13.529Z"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"tool_name": "Bash",
|
||||||
|
"tool_input_preview": "{\"command\":\"wc -l /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx\",\"description\":\"Get total line count of the file\"}",
|
||||||
|
"error": "Exit code 1\n(eval):1: no matches found: /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx",
|
||||||
|
"timestamp": "2026-03-25T05:00:38.410Z",
|
||||||
|
"retry_count": 1
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,281 @@
|
||||||
|
{
|
||||||
|
"updatedAt": "2026-03-25T05:06:35.487Z",
|
||||||
|
"missions": [
|
||||||
|
{
|
||||||
|
"id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none",
|
||||||
|
"source": "session",
|
||||||
|
"name": "none",
|
||||||
|
"objective": "Session mission",
|
||||||
|
"createdAt": "2026-03-25T00:33:45.197Z",
|
||||||
|
"updatedAt": "2026-03-25T01:37:19.659Z",
|
||||||
|
"status": "done",
|
||||||
|
"workerCount": 5,
|
||||||
|
"taskCounts": {
|
||||||
|
"total": 5,
|
||||||
|
"pending": 0,
|
||||||
|
"blocked": 0,
|
||||||
|
"inProgress": 0,
|
||||||
|
"completed": 5,
|
||||||
|
"failed": 0
|
||||||
|
},
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"name": "Explore:ad233db",
|
||||||
|
"role": "Explore",
|
||||||
|
"ownership": "ad233db7fa6f059dd",
|
||||||
|
"status": "done",
|
||||||
|
"currentStep": null,
|
||||||
|
"latestUpdate": "completed",
|
||||||
|
"completedSummary": null,
|
||||||
|
"updatedAt": "2026-03-25T00:34:44.932Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Explore:a31a0f7",
|
||||||
|
"role": "Explore",
|
||||||
|
"ownership": "a31a0f729d328643f",
|
||||||
|
"status": "done",
|
||||||
|
"currentStep": null,
|
||||||
|
"latestUpdate": "completed",
|
||||||
|
"completedSummary": null,
|
||||||
|
"updatedAt": "2026-03-25T00:35:24.588Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "executor:a9510b7",
|
||||||
|
"role": "executor",
|
||||||
|
"ownership": "a9510b7d8ec5a1ce7",
|
||||||
|
"status": "done",
|
||||||
|
"currentStep": null,
|
||||||
|
"latestUpdate": "completed",
|
||||||
|
"completedSummary": null,
|
||||||
|
"updatedAt": "2026-03-25T00:42:01.730Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "executor:a1c1d18",
|
||||||
|
"role": "executor",
|
||||||
|
"ownership": "a1c1d186f0eb6dfc1",
|
||||||
|
"status": "done",
|
||||||
|
"currentStep": null,
|
||||||
|
"latestUpdate": "completed",
|
||||||
|
"completedSummary": null,
|
||||||
|
"updatedAt": "2026-03-25T00:40:12.608Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "executor:a9a231d",
|
||||||
|
"role": "executor",
|
||||||
|
"ownership": "a9a231d40fd5a150b",
|
||||||
|
"status": "done",
|
||||||
|
"currentStep": null,
|
||||||
|
"latestUpdate": "completed",
|
||||||
|
"completedSummary": null,
|
||||||
|
"updatedAt": "2026-03-25T01:37:19.659Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timeline": [
|
||||||
|
{
|
||||||
|
"id": "session-stop:a1c1d186f0eb6dfc1:2026-03-25T00:40:12.608Z",
|
||||||
|
"at": "2026-03-25T00:40:12.608Z",
|
||||||
|
"kind": "completion",
|
||||||
|
"agent": "executor:a1c1d18",
|
||||||
|
"detail": "completed",
|
||||||
|
"sourceKey": "session-stop:a1c1d186f0eb6dfc1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-stop:a9510b7d8ec5a1ce7:2026-03-25T00:42:01.730Z",
|
||||||
|
"at": "2026-03-25T00:42:01.730Z",
|
||||||
|
"kind": "completion",
|
||||||
|
"agent": "executor:a9510b7",
|
||||||
|
"detail": "completed",
|
||||||
|
"sourceKey": "session-stop:a9510b7d8ec5a1ce7"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-start:a9a231d40fd5a150b:2026-03-25T01:35:00.232Z",
|
||||||
|
"at": "2026-03-25T01:35:00.232Z",
|
||||||
|
"kind": "update",
|
||||||
|
"agent": "executor:a9a231d",
|
||||||
|
"detail": "started executor:a9a231d",
|
||||||
|
"sourceKey": "session-start:a9a231d40fd5a150b"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-stop:a9a231d40fd5a150b:2026-03-25T01:37:19.659Z",
|
||||||
|
"at": "2026-03-25T01:37:19.659Z",
|
||||||
|
"kind": "completion",
|
||||||
|
"agent": "executor:a9a231d",
|
||||||
|
"detail": "completed",
|
||||||
|
"sourceKey": "session-stop:a9a231d40fd5a150b"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session:037169c7-72ba-4843-8e9a-417ca1423715:none",
|
||||||
|
"source": "session",
|
||||||
|
"name": "none",
|
||||||
|
"objective": "Session mission",
|
||||||
|
"createdAt": "2026-03-25T04:59:24.101Z",
|
||||||
|
"updatedAt": "2026-03-25T05:06:35.487Z",
|
||||||
|
"status": "done",
|
||||||
|
"workerCount": 7,
|
||||||
|
"taskCounts": {
|
||||||
|
"total": 7,
|
||||||
|
"pending": 0,
|
||||||
|
"blocked": 0,
|
||||||
|
"inProgress": 0,
|
||||||
|
"completed": 7,
|
||||||
|
"failed": 0
|
||||||
|
},
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"name": "executor:a32b34c",
|
||||||
|
"role": "executor",
|
||||||
|
"ownership": "a32b34c341b854da5",
|
||||||
|
"status": "done",
|
||||||
|
"currentStep": null,
|
||||||
|
"latestUpdate": "completed",
|
||||||
|
"completedSummary": null,
|
||||||
|
"updatedAt": "2026-03-25T05:06:18.081Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "executor:ad2c89c",
|
||||||
|
"role": "executor",
|
||||||
|
"ownership": "ad2c89cf14936ea42",
|
||||||
|
"status": "done",
|
||||||
|
"currentStep": null,
|
||||||
|
"latestUpdate": "completed",
|
||||||
|
"completedSummary": null,
|
||||||
|
"updatedAt": "2026-03-25T05:02:45.524Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "executor:a2c140c",
|
||||||
|
"role": "executor",
|
||||||
|
"ownership": "a2c140c5a5adb0719",
|
||||||
|
"status": "done",
|
||||||
|
"currentStep": null,
|
||||||
|
"latestUpdate": "completed",
|
||||||
|
"completedSummary": null,
|
||||||
|
"updatedAt": "2026-03-25T05:05:13.388Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "executor:a2e5213",
|
||||||
|
"role": "executor",
|
||||||
|
"ownership": "a2e52136ea8f04385",
|
||||||
|
"status": "done",
|
||||||
|
"currentStep": null,
|
||||||
|
"latestUpdate": "completed",
|
||||||
|
"completedSummary": null,
|
||||||
|
"updatedAt": "2026-03-25T05:03:53.163Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "executor:a3735bf",
|
||||||
|
"role": "executor",
|
||||||
|
"ownership": "a3735bf51a74d6fc8",
|
||||||
|
"status": "done",
|
||||||
|
"currentStep": null,
|
||||||
|
"latestUpdate": "completed",
|
||||||
|
"completedSummary": null,
|
||||||
|
"updatedAt": "2026-03-25T05:01:33.817Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "executor:a77742b",
|
||||||
|
"role": "executor",
|
||||||
|
"ownership": "a77742ba65fd2451c",
|
||||||
|
"status": "done",
|
||||||
|
"currentStep": null,
|
||||||
|
"latestUpdate": "completed",
|
||||||
|
"completedSummary": null,
|
||||||
|
"updatedAt": "2026-03-25T05:06:09.324Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "executor:a4eb932",
|
||||||
|
"role": "executor",
|
||||||
|
"ownership": "a4eb932c438b898c0",
|
||||||
|
"status": "done",
|
||||||
|
"currentStep": null,
|
||||||
|
"latestUpdate": "completed",
|
||||||
|
"completedSummary": null,
|
||||||
|
"updatedAt": "2026-03-25T05:06:35.487Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"timeline": [
|
||||||
|
{
|
||||||
|
"id": "session-start:a3735bf51a74d6fc8:2026-03-25T04:59:43.650Z",
|
||||||
|
"at": "2026-03-25T04:59:43.650Z",
|
||||||
|
"kind": "update",
|
||||||
|
"agent": "executor:a3735bf",
|
||||||
|
"detail": "started executor:a3735bf",
|
||||||
|
"sourceKey": "session-start:a3735bf51a74d6fc8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-start:a77742ba65fd2451c:2026-03-25T04:59:48.683Z",
|
||||||
|
"at": "2026-03-25T04:59:48.683Z",
|
||||||
|
"kind": "update",
|
||||||
|
"agent": "executor:a77742b",
|
||||||
|
"detail": "started executor:a77742b",
|
||||||
|
"sourceKey": "session-start:a77742ba65fd2451c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-start:a4eb932c438b898c0:2026-03-25T04:59:53.841Z",
|
||||||
|
"at": "2026-03-25T04:59:53.841Z",
|
||||||
|
"kind": "update",
|
||||||
|
"agent": "executor:a4eb932",
|
||||||
|
"detail": "started executor:a4eb932",
|
||||||
|
"sourceKey": "session-start:a4eb932c438b898c0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-stop:a3735bf51a74d6fc8:2026-03-25T05:01:33.817Z",
|
||||||
|
"at": "2026-03-25T05:01:33.817Z",
|
||||||
|
"kind": "completion",
|
||||||
|
"agent": "executor:a3735bf",
|
||||||
|
"detail": "completed",
|
||||||
|
"sourceKey": "session-stop:a3735bf51a74d6fc8"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-stop:ad2c89cf14936ea42:2026-03-25T05:02:45.524Z",
|
||||||
|
"at": "2026-03-25T05:02:45.524Z",
|
||||||
|
"kind": "completion",
|
||||||
|
"agent": "executor:ad2c89c",
|
||||||
|
"detail": "completed",
|
||||||
|
"sourceKey": "session-stop:ad2c89cf14936ea42"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-stop:a2e52136ea8f04385:2026-03-25T05:03:53.163Z",
|
||||||
|
"at": "2026-03-25T05:03:53.163Z",
|
||||||
|
"kind": "completion",
|
||||||
|
"agent": "executor:a2e5213",
|
||||||
|
"detail": "completed",
|
||||||
|
"sourceKey": "session-stop:a2e52136ea8f04385"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-stop:a2c140c5a5adb0719:2026-03-25T05:05:13.388Z",
|
||||||
|
"at": "2026-03-25T05:05:13.388Z",
|
||||||
|
"kind": "completion",
|
||||||
|
"agent": "executor:a2c140c",
|
||||||
|
"detail": "completed",
|
||||||
|
"sourceKey": "session-stop:a2c140c5a5adb0719"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-stop:a77742ba65fd2451c:2026-03-25T05:06:09.324Z",
|
||||||
|
"at": "2026-03-25T05:06:09.324Z",
|
||||||
|
"kind": "completion",
|
||||||
|
"agent": "executor:a77742b",
|
||||||
|
"detail": "completed",
|
||||||
|
"sourceKey": "session-stop:a77742ba65fd2451c"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-stop:a32b34c341b854da5:2026-03-25T05:06:18.081Z",
|
||||||
|
"at": "2026-03-25T05:06:18.081Z",
|
||||||
|
"kind": "completion",
|
||||||
|
"agent": "executor:a32b34c",
|
||||||
|
"detail": "completed",
|
||||||
|
"sourceKey": "session-stop:a32b34c341b854da5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "session-stop:a4eb932c438b898c0:2026-03-25T05:06:35.487Z",
|
||||||
|
"at": "2026-03-25T05:06:35.487Z",
|
||||||
|
"kind": "completion",
|
||||||
|
"agent": "executor:a4eb932",
|
||||||
|
"detail": "completed",
|
||||||
|
"sourceKey": "session-stop:a4eb932c438b898c0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
{
|
||||||
|
"agents": [
|
||||||
|
{
|
||||||
|
"agent_id": "ad233db7fa6f059dd",
|
||||||
|
"agent_type": "Explore",
|
||||||
|
"started_at": "2026-03-25T00:33:45.197Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-25T00:34:44.932Z",
|
||||||
|
"duration_ms": 59735
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "a31a0f729d328643f",
|
||||||
|
"agent_type": "Explore",
|
||||||
|
"started_at": "2026-03-25T00:33:50.981Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-25T00:35:24.588Z",
|
||||||
|
"duration_ms": 93607
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "a9510b7d8ec5a1ce7",
|
||||||
|
"agent_type": "oh-my-claudecode:executor",
|
||||||
|
"started_at": "2026-03-25T00:37:40.106Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-25T00:42:01.730Z",
|
||||||
|
"duration_ms": 261624
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "a1c1d186f0eb6dfc1",
|
||||||
|
"agent_type": "oh-my-claudecode:executor",
|
||||||
|
"started_at": "2026-03-25T00:37:56.359Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-25T00:40:12.608Z",
|
||||||
|
"duration_ms": 136249
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "a9a231d40fd5a150b",
|
||||||
|
"agent_type": "oh-my-claudecode:executor",
|
||||||
|
"started_at": "2026-03-25T01:35:00.232Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-25T01:37:19.659Z",
|
||||||
|
"duration_ms": 139427
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "a32b34c341b854da5",
|
||||||
|
"agent_type": "oh-my-claudecode:executor",
|
||||||
|
"started_at": "2026-03-25T04:59:24.101Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-25T05:06:18.081Z",
|
||||||
|
"duration_ms": 413980
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "ad2c89cf14936ea42",
|
||||||
|
"agent_type": "oh-my-claudecode:executor",
|
||||||
|
"started_at": "2026-03-25T04:59:28.976Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-25T05:02:45.524Z",
|
||||||
|
"duration_ms": 196548
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "a2c140c5a5adb0719",
|
||||||
|
"agent_type": "oh-my-claudecode:executor",
|
||||||
|
"started_at": "2026-03-25T04:59:33.860Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-25T05:05:13.388Z",
|
||||||
|
"duration_ms": 339528
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "a2e52136ea8f04385",
|
||||||
|
"agent_type": "oh-my-claudecode:executor",
|
||||||
|
"started_at": "2026-03-25T04:59:39.166Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-25T05:03:53.163Z",
|
||||||
|
"duration_ms": 253997
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "a3735bf51a74d6fc8",
|
||||||
|
"agent_type": "oh-my-claudecode:executor",
|
||||||
|
"started_at": "2026-03-25T04:59:43.650Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-25T05:01:33.817Z",
|
||||||
|
"duration_ms": 110167
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "a77742ba65fd2451c",
|
||||||
|
"agent_type": "oh-my-claudecode:executor",
|
||||||
|
"started_at": "2026-03-25T04:59:48.683Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-25T05:06:09.324Z",
|
||||||
|
"duration_ms": 380641
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "a4eb932c438b898c0",
|
||||||
|
"agent_type": "oh-my-claudecode:executor",
|
||||||
|
"started_at": "2026-03-25T04:59:53.841Z",
|
||||||
|
"parent_mode": "none",
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": "2026-03-25T05:06:35.487Z",
|
||||||
|
"duration_ms": 401646
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_spawned": 12,
|
||||||
|
"total_completed": 12,
|
||||||
|
"total_failed": 0,
|
||||||
|
"last_updated": "2026-03-25T05:06:35.589Z"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,752 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설비정보 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* 좌측: 설비 목록 (equipment_mng)
|
||||||
|
* 우측: 탭 (기본정보 / 점검항목 / 소모품)
|
||||||
|
* 점검항목 복사 기능 포함
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
import {
|
||||||
|
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||||
|
Wrench, ClipboardCheck, Package, Copy, Info, Settings2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||||
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||||
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
|
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||||
|
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||||
|
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
|
||||||
|
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
|
||||||
|
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||||
|
|
||||||
|
const EQUIP_TABLE = "equipment_mng";
|
||||||
|
const INSPECTION_TABLE = "equipment_inspection_item";
|
||||||
|
const CONSUMABLE_TABLE = "equipment_consumable";
|
||||||
|
|
||||||
|
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "equipment_code", label: "설비코드", width: "w-[110px]" },
|
||||||
|
{ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]" },
|
||||||
|
{ key: "equipment_type", label: "설비유형", width: "w-[90px]" },
|
||||||
|
{ key: "manufacturer", label: "제조사", width: "w-[100px]" },
|
||||||
|
{ key: "installation_location", label: "설치장소", width: "w-[100px]" },
|
||||||
|
{ key: "operation_status", label: "가동상태", width: "w-[80px]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INSPECTION_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "inspection_item", label: "점검항목", minWidth: "min-w-[120px]", editable: true },
|
||||||
|
{ key: "inspection_cycle", label: "점검주기", width: "w-[80px]" },
|
||||||
|
{ key: "inspection_method", label: "점검방법", width: "w-[80px]" },
|
||||||
|
{ key: "lower_limit", label: "하한치", width: "w-[70px]", editable: true },
|
||||||
|
{ key: "upper_limit", label: "상한치", width: "w-[70px]", editable: true },
|
||||||
|
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
|
||||||
|
{ key: "inspection_content", label: "점검내용", minWidth: "min-w-[150px]", editable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONSUMABLE_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "image_path", label: "이미지", width: "w-[50px]", renderType: "image", sortable: false, filterable: false },
|
||||||
|
{ key: "consumable_name", label: "소모품명", minWidth: "min-w-[120px]", editable: true },
|
||||||
|
{ key: "replacement_cycle", label: "교체주기", width: "w-[90px]", editable: true },
|
||||||
|
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
|
||||||
|
{ key: "specification", label: "규격", width: "w-[100px]", editable: true },
|
||||||
|
{ key: "manufacturer", label: "제조사", width: "w-[100px]", editable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EquipmentInfoPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||||
|
|
||||||
|
// 좌측
|
||||||
|
const [equipments, setEquipments] = useState<any[]>([]);
|
||||||
|
const [equipLoading, setEquipLoading] = useState(false);
|
||||||
|
const [equipCount, setEquipCount] = useState(0);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||||
|
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||||
|
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 우측 탭
|
||||||
|
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
|
||||||
|
const [inspections, setInspections] = useState<any[]>([]);
|
||||||
|
const [inspectionLoading, setInspectionLoading] = useState(false);
|
||||||
|
const [consumables, setConsumables] = useState<any[]>([]);
|
||||||
|
const [consumableLoading, setConsumableLoading] = useState(false);
|
||||||
|
|
||||||
|
// 카테고리
|
||||||
|
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||||
|
|
||||||
|
// 모달
|
||||||
|
const [equipModalOpen, setEquipModalOpen] = useState(false);
|
||||||
|
const [equipEditMode, setEquipEditMode] = useState(false);
|
||||||
|
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 기본정보 탭 편집 폼
|
||||||
|
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
|
||||||
|
const [infoSaving, setInfoSaving] = useState(false);
|
||||||
|
|
||||||
|
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
|
||||||
|
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||||
|
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
|
||||||
|
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// 점검항목 복사
|
||||||
|
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||||
|
const [copySourceEquip, setCopySourceEquip] = useState("");
|
||||||
|
const [copyItems, setCopyItems] = useState<any[]>([]);
|
||||||
|
const [copyChecked, setCopyChecked] = useState<Set<string>>(new Set());
|
||||||
|
const [copyLoading, setCopyLoading] = useState(false);
|
||||||
|
|
||||||
|
// 엑셀
|
||||||
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||||
|
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
|
||||||
|
const [excelDetecting, setExcelDetecting] = useState(false);
|
||||||
|
|
||||||
|
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||||
|
setFilterConfig(settings.filters);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = loadTableSettings("equipment-info");
|
||||||
|
if (saved) applyTableSettings(saved);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 카테고리 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||||
|
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<string, any>();
|
||||||
|
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) => (
|
||||||
|
<Select value={value || ""} onValueChange={onChange}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(catOptions[key] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
<DynamicSearchFilter tableName={EQUIP_TABLE} filterId="equipment-info" onFilterChange={setSearchFilters} dataCount={equipCount}
|
||||||
|
externalFilterConfig={filterConfig}
|
||||||
|
extraActions={
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||||
|
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
|
||||||
|
onClick={async () => {
|
||||||
|
setExcelDetecting(true);
|
||||||
|
try {
|
||||||
|
const r = await autoDetectMultiTableConfig(EQUIP_TABLE);
|
||||||
|
if (r.success && r.data) { setExcelChainConfig(r.data); setExcelUploadOpen(true); }
|
||||||
|
else toast.error("테이블 구조 분석 실패");
|
||||||
|
} catch { toast.error("오류"); } finally { setExcelDetecting(false); }
|
||||||
|
}}>
|
||||||
|
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />} 엑셀 업로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
{/* 좌측: 설비 목록 */}
|
||||||
|
<ResizablePanel defaultSize={40} minSize={25}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Wrench className="w-4 h-4" /> 설비 목록 <Badge variant="secondary" className="font-normal">{equipCount}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button size="sm" onClick={openEquipRegister}><Plus className="w-3.5 h-3.5 mr-1" /> 등록</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> 수정</Button>
|
||||||
|
<Button variant="destructive" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataGrid gridId="equip-left" columns={LEFT_COLUMNS} data={equipments} loading={equipLoading}
|
||||||
|
selectedId={selectedEquipId} onSelect={setSelectedEquipId} onRowDoubleClick={() => openEquipEdit()}
|
||||||
|
emptyMessage="등록된 설비가 없습니다" />
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 탭 */}
|
||||||
|
<ResizablePanel defaultSize={60} minSize={30}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-2 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
|
||||||
|
<button key={tab} onClick={() => setRightTab(tab)}
|
||||||
|
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1",
|
||||||
|
rightTab === tab ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />{label}
|
||||||
|
{tab === "inspection" && inspections.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{inspections.length}</Badge>}
|
||||||
|
{tab === "consumable" && consumables.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{consumables.length}</Badge>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{selectedEquip && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedEquip.equipment_name}</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{rightTab === "inspection" && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionModalOpen(true); }}>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
|
||||||
|
<Copy className="w-3.5 h-3.5 mr-1" /> 복사
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rightTab === "consumable" && (
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); loadConsumableItems(); setConsumableModalOpen(true); }}>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedEquipId ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">좌측에서 설비를 선택하세요</div>
|
||||||
|
) : rightTab === "info" ? (
|
||||||
|
<div className="p-4 overflow-auto">
|
||||||
|
<div className="flex justify-end mb-3">
|
||||||
|
<Button size="sm" onClick={handleInfoSave} disabled={infoSaving}>
|
||||||
|
{infoSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm text-muted-foreground">설비코드</Label>
|
||||||
|
<Input value={infoForm.equipment_code || ""} className="h-9 bg-muted/50" disabled />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">설비명</Label>
|
||||||
|
<Input value={infoForm.equipment_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">설비유형</Label>
|
||||||
|
{catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">설치장소</Label>
|
||||||
|
<Input value={infoForm.installation_location || ""} onChange={(e) => setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">제조사</Label>
|
||||||
|
<Input value={infoForm.manufacturer || ""} onChange={(e) => setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">모델명</Label>
|
||||||
|
<Input value={infoForm.model_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">도입일자</Label>
|
||||||
|
<FormDatePicker value={infoForm.introduction_date || ""} onChange={(v) => setInfoForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">가동상태</Label>
|
||||||
|
{catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<Label className="text-sm">비고</Label>
|
||||||
|
<Input value={infoForm.remarks || ""} onChange={(e) => setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<Label className="text-sm">이미지</Label>
|
||||||
|
<ImageUpload value={infoForm.image_path} onChange={(v) => setInfoForm((p) => ({ ...p, image_path: v }))}
|
||||||
|
tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : rightTab === "inspection" ? (
|
||||||
|
<DataGrid gridId="equip-inspection" columns={INSPECTION_COLUMNS} data={inspections} loading={inspectionLoading}
|
||||||
|
showRowNumber={false} tableName={INSPECTION_TABLE} emptyMessage="점검항목이 없습니다"
|
||||||
|
onCellEdit={() => refreshRight()} />
|
||||||
|
) : (
|
||||||
|
<DataGrid gridId="equip-consumable" columns={CONSUMABLE_COLUMNS} data={consumables} loading={consumableLoading}
|
||||||
|
showRowNumber={false} tableName={CONSUMABLE_TABLE} emptyMessage="소모품이 없습니다"
|
||||||
|
onCellEdit={() => refreshRight()} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설비 등록/수정 모달 */}
|
||||||
|
<FullscreenDialog open={equipModalOpen} onOpenChange={setEquipModalOpen}
|
||||||
|
title={equipEditMode ? "설비 수정" : "설비 등록"} description={equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}
|
||||||
|
defaultMaxWidth="max-w-2xl"
|
||||||
|
footer={<><Button variant="outline" onClick={() => setEquipModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button></>}>
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">설비코드</Label>
|
||||||
|
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">설비명 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">설비유형</Label>
|
||||||
|
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">가동상태</Label>
|
||||||
|
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">설치장소</Label>
|
||||||
|
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">제조사</Label>
|
||||||
|
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">모델명</Label>
|
||||||
|
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">도입일자</Label>
|
||||||
|
<FormDatePicker value={equipForm.introduction_date || ""} onChange={(v) => setEquipForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" /></div>
|
||||||
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">비고</Label>
|
||||||
|
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">이미지</Label>
|
||||||
|
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
|
||||||
|
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
|
||||||
|
</div>
|
||||||
|
</FullscreenDialog>
|
||||||
|
|
||||||
|
{/* 점검항목 추가 모달 */}
|
||||||
|
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader><DialogTitle>점검항목 추가</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 점검항목을 추가합니다.</DialogDescription></DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">점검항목 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" /></div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">점검주기</Label>
|
||||||
|
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">점검방법</Label>
|
||||||
|
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">하한치</Label>
|
||||||
|
<Input value={inspectionForm.lower_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">상한치</Label>
|
||||||
|
<Input value={inspectionForm.upper_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">단위</Label>
|
||||||
|
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">점검내용</Label>
|
||||||
|
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter><Button variant="outline" onClick={() => setInspectionModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 소모품 추가 모달 */}
|
||||||
|
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader><DialogTitle>소모품 추가</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 소모품을 추가합니다.</DialogDescription></DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">소모품명 <span className="text-destructive">*</span></Label>
|
||||||
|
{consumableItemOptions.length > 0 ? (
|
||||||
|
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
|
||||||
|
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
|
||||||
|
setConsumableForm((p) => ({
|
||||||
|
...p,
|
||||||
|
consumable_name: v,
|
||||||
|
specification: item?.size || p.specification || "",
|
||||||
|
unit: item?.unit || p.unit || "",
|
||||||
|
manufacturer: item?.manufacturer || p.manufacturer || "",
|
||||||
|
}));
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="소모품 선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{consumableItemOptions.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.item_name || item.item_number}>
|
||||||
|
{item.item_name}{item.size ? ` (${item.size})` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Input value={consumableForm.consumable_name || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))}
|
||||||
|
placeholder="소모품명 직접 입력" className="h-9" />
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">품목정보에 소모품 타입 품목을 등록하면 선택 가능합니다</p>
|
||||||
|
</div>
|
||||||
|
)}</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">교체주기</Label>
|
||||||
|
<Input value={consumableForm.replacement_cycle || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">단위</Label>
|
||||||
|
<Input value={consumableForm.unit || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">규격</Label>
|
||||||
|
<Input value={consumableForm.specification || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">제조사</Label>
|
||||||
|
<Input value={consumableForm.manufacturer || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">이미지</Label>
|
||||||
|
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
|
||||||
|
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter><Button variant="outline" onClick={() => setConsumableModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 점검항목 복사 모달 */}
|
||||||
|
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[70vh]">
|
||||||
|
<DialogHeader><DialogTitle>점검항목 복사</DialogTitle>
|
||||||
|
<DialogDescription>다른 설비의 점검항목을 선택하여 {selectedEquip?.equipment_name}에 복사합니다.</DialogDescription></DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">소스 설비 선택</Label>
|
||||||
|
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="복사할 설비 선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{equipments.filter((e) => e.equipment_code !== selectedEquip?.equipment_code).map((e) => (
|
||||||
|
<SelectItem key={e.equipment_code} value={e.equipment_code}>{e.equipment_name} ({e.equipment_code})</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg overflow-auto max-h-[300px]">
|
||||||
|
{copyLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : copyItems.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없습니다" : "설비를 선택하세요"}</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
|
||||||
|
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>점검항목</TableHead><TableHead className="w-[80px]">점검주기</TableHead>
|
||||||
|
<TableHead className="w-[80px]">점검방법</TableHead><TableHead className="w-[70px]">하한</TableHead>
|
||||||
|
<TableHead className="w-[70px]">상한</TableHead><TableHead className="w-[60px]">단위</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{copyItems.map((item) => (
|
||||||
|
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
|
||||||
|
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
|
||||||
|
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
|
||||||
|
<TableCell className="text-sm">{item.inspection_item}</TableCell>
|
||||||
|
<TableCell className="text-xs">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
||||||
|
<TableCell className="text-xs">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
||||||
|
<TableCell className="text-xs">{item.lower_limit || "-"}</TableCell>
|
||||||
|
<TableCell className="text-xs">{item.upper_limit || "-"}</TableCell>
|
||||||
|
<TableCell className="text-xs">{item.unit || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex items-center gap-2 w-full justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">{copyChecked.size}개 선택됨</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setCopyModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleCopyApply} disabled={saving || copyChecked.size === 0}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Copy className="w-4 h-4 mr-1.5" />} 복사 적용
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 엑셀 업로드 (멀티테이블) */}
|
||||||
|
{excelChainConfig && (
|
||||||
|
<MultiTableExcelUploadModal open={excelUploadOpen}
|
||||||
|
onOpenChange={(open) => { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }}
|
||||||
|
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableSettingsModal
|
||||||
|
open={tableSettingsOpen}
|
||||||
|
onOpenChange={setTableSettingsOpen}
|
||||||
|
tableName={EQUIP_TABLE}
|
||||||
|
settingsId="equipment-info"
|
||||||
|
onSave={applyTableSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<string, string> = {
|
||||||
|
BOX: "박스", PACK: "팩", CANBOARD: "캔보드", AIRCAP: "에어캡",
|
||||||
|
ZIPCOS: "집코스", CYLINDER: "원통형", POLYCARTON: "포리/카톤",
|
||||||
|
};
|
||||||
|
const LOADING_TYPE_LABEL: Record<string, string> = {
|
||||||
|
PALLET: "파렛트", WOOD_PALLET: "목재파렛트", PLASTIC_PALLET: "플라스틱파렛트",
|
||||||
|
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
|
||||||
|
CAGE: "케이지", ETC: "기타",
|
||||||
|
};
|
||||||
|
const STATUS_LABEL: Record<string, string> = { 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<PkgUnit[]>([]);
|
||||||
|
const [pkgLoading, setPkgLoading] = useState(false);
|
||||||
|
const [selectedPkg, setSelectedPkg] = useState<PkgUnit | null>(null);
|
||||||
|
const [pkgItems, setPkgItems] = useState<PkgUnitItem[]>([]);
|
||||||
|
const [pkgItemsLoading, setPkgItemsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 적재함 데이터
|
||||||
|
const [loadingUnits, setLoadingUnits] = useState<LoadingUnit[]>([]);
|
||||||
|
const [loadingLoading, setLoadingLoading] = useState(false);
|
||||||
|
const [selectedLoading, setSelectedLoading] = useState<LoadingUnit | null>(null);
|
||||||
|
const [loadingPkgs, setLoadingPkgs] = useState<LoadingUnitPkg[]>([]);
|
||||||
|
const [loadingPkgsLoading, setLoadingPkgsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 모달
|
||||||
|
const [pkgModalOpen, setPkgModalOpen] = useState(false);
|
||||||
|
const [pkgModalMode, setPkgModalMode] = useState<"create" | "edit">("create");
|
||||||
|
const [pkgForm, setPkgForm] = useState<Record<string, any>>({});
|
||||||
|
const [pkgItemOptions, setPkgItemOptions] = useState<ItemInfoForPkg[]>([]);
|
||||||
|
const [pkgItemPopoverOpen, setPkgItemPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||||
|
const [loadModalMode, setLoadModalMode] = useState<"create" | "edit">("create");
|
||||||
|
const [loadForm, setLoadForm] = useState<Record<string, any>>({});
|
||||||
|
const [loadItemOptions, setLoadItemOptions] = useState<ItemInfoForPkg[]>([]);
|
||||||
|
const [loadItemPopoverOpen, setLoadItemPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const [itemMatchModalOpen, setItemMatchModalOpen] = useState(false);
|
||||||
|
const [itemMatchKeyword, setItemMatchKeyword] = useState("");
|
||||||
|
const [itemMatchResults, setItemMatchResults] = useState<ItemInfoForPkg[]>([]);
|
||||||
|
const [itemMatchSelected, setItemMatchSelected] = useState<ItemInfoForPkg | null>(null);
|
||||||
|
const [itemMatchQty, setItemMatchQty] = useState(1);
|
||||||
|
|
||||||
|
const [pkgMatchModalOpen, setPkgMatchModalOpen] = useState(false);
|
||||||
|
const [pkgMatchQty, setPkgMatchQty] = useState(1);
|
||||||
|
const [pkgMatchMethod, setPkgMatchMethod] = useState("");
|
||||||
|
const [pkgMatchSelected, setPkgMatchSelected] = useState<PkgUnit | null>(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 (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-4">
|
||||||
|
{/* 검색 바 */}
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border bg-card p-3">
|
||||||
|
<Input
|
||||||
|
placeholder="포장코드 / 포장명 / 적재함명 검색"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
className="h-9 w-[280px] text-xs"
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setSearchKeyword("")} className="h-9">
|
||||||
|
<RotateCcw className="mr-1 h-4 w-4" /> 초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-1 border-b">
|
||||||
|
{([["packing", "포장재 관리", filteredPkgUnits.length] as const, ["loading", "적재함 관리", filteredLoadingUnits.length] as const]).map(([tab, label, count]) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
|
||||||
|
activeTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab === "packing" ? <Package className="h-4 w-4" /> : <Box className="h-4 w-4" />}
|
||||||
|
{label}
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5">{count}</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 콘텐츠 */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{activeTab === "packing" ? (
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||||
|
{/* 좌측: 포장재 목록 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={30}>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||||
|
<span className="text-sm font-semibold">포장재 목록 <span className="text-muted-foreground font-normal">({filteredPkgUnits.length}건)</span></span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("create")}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" /> 등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-[11px] bg-muted/50">
|
||||||
|
<TableHead className="p-2">품목코드</TableHead>
|
||||||
|
<TableHead className="p-2">포장명</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px]">유형</TableHead>
|
||||||
|
<TableHead className="p-2 w-[90px]">크기(mm)</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px] text-right">최대중량</TableHead>
|
||||||
|
<TableHead className="p-2 w-[55px] text-center">상태</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pkgLoading ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
||||||
|
) : filteredPkgUnits.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs">등록된 포장재가 없습니다</TableCell></TableRow>
|
||||||
|
) : filteredPkgUnits.map((p) => (
|
||||||
|
<TableRow
|
||||||
|
key={p.id}
|
||||||
|
className={cn("cursor-pointer text-xs", selectedPkg?.id === p.id && "bg-primary/5")}
|
||||||
|
onClick={() => selectPkg(p)}
|
||||||
|
>
|
||||||
|
<TableCell className="p-2 font-medium truncate max-w-[100px]">{p.pkg_code}</TableCell>
|
||||||
|
<TableCell className="p-2 truncate max-w-[120px]">{p.pkg_name}</TableCell>
|
||||||
|
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
|
||||||
|
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-center">
|
||||||
|
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(p.status))}>{STATUS_LABEL[p.status] || p.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
{/* 우측: 상세 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
{!selectedPkg ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<Package className="h-12 w-12 opacity-20 mb-2" />
|
||||||
|
<p className="text-sm">좌측 목록에서 포장재를 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 요약 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b bg-blue-50 dark:bg-blue-950/20 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Package className="h-5 w-5 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-sm">{selectedPkg.pkg_name}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{selectedPkg.pkg_code} · {PKG_TYPE_LABEL[selectedPkg.pkg_type] || selectedPkg.pkg_type} · {fmtSize(selectedPkg.width_mm, selectedPkg.length_mm, selectedPkg.height_mm)}mm</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("edit")}>
|
||||||
|
<Edit2 className="mr-1 h-3 w-3" /> 수정
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeletePkg(selectedPkg)}>
|
||||||
|
<Trash2 className="mr-1 h-3 w-3" /> 삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 매칭 품목 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">매칭 품목 ({pkgItems.length}건)</span>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openItemMatchModal}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" /> 품목 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{pkgItemsLoading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
|
||||||
|
) : pkgItems.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs">매칭된 품목이 없습니다</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-[11px]">
|
||||||
|
<TableHead className="p-2">품목코드</TableHead>
|
||||||
|
<TableHead className="p-2">품목명</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px]">규격</TableHead>
|
||||||
|
<TableHead className="p-2 w-[50px]">단위</TableHead>
|
||||||
|
<TableHead className="p-2 w-[80px] text-right">포장수량</TableHead>
|
||||||
|
<TableHead className="p-2 w-[40px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pkgItems.map((item) => (
|
||||||
|
<TableRow key={item.id} className="text-xs">
|
||||||
|
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
|
||||||
|
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2">{item.spec || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="p-2 text-center">
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
) : (
|
||||||
|
/* 적재함 관리 탭 */
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||||
|
<ResizablePanel defaultSize={45} minSize={30}>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||||
|
<span className="text-sm font-semibold">적재함 목록 <span className="text-muted-foreground font-normal">({filteredLoadingUnits.length}건)</span></span>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("create")}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" /> 등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-[11px] bg-muted/50">
|
||||||
|
<TableHead className="p-2">품목코드</TableHead>
|
||||||
|
<TableHead className="p-2">적재함명</TableHead>
|
||||||
|
<TableHead className="p-2 w-[80px]">유형</TableHead>
|
||||||
|
<TableHead className="p-2 w-[90px]">크기(mm)</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px] text-right">최대적재</TableHead>
|
||||||
|
<TableHead className="p-2 w-[55px] text-center">상태</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loadingLoading ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
||||||
|
) : filteredLoadingUnits.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs">등록된 적재함이 없습니다</TableCell></TableRow>
|
||||||
|
) : filteredLoadingUnits.map((l) => (
|
||||||
|
<TableRow
|
||||||
|
key={l.id}
|
||||||
|
className={cn("cursor-pointer text-xs", selectedLoading?.id === l.id && "bg-primary/5")}
|
||||||
|
onClick={() => selectLoading(l)}
|
||||||
|
>
|
||||||
|
<TableCell className="p-2 font-medium truncate max-w-[100px]">{l.loading_code}</TableCell>
|
||||||
|
<TableCell className="p-2 truncate max-w-[120px]">{l.loading_name}</TableCell>
|
||||||
|
<TableCell className="p-2">{LOADING_TYPE_LABEL[l.loading_type] || l.loading_type || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-[10px]">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
|
||||||
|
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-center">
|
||||||
|
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(l.status))}>{STATUS_LABEL[l.status] || l.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
{!selectedLoading ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<Box className="h-12 w-12 opacity-20 mb-2" />
|
||||||
|
<p className="text-sm">좌측 목록에서 적재함을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b bg-green-50 dark:bg-green-950/20 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Box className="h-5 w-5 text-green-600" />
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-sm">{selectedLoading.loading_name}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{selectedLoading.loading_code} · {LOADING_TYPE_LABEL[selectedLoading.loading_type] || selectedLoading.loading_type} · {fmtSize(selectedLoading.width_mm, selectedLoading.length_mm, selectedLoading.height_mm)}mm</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("edit")}><Edit2 className="mr-1 h-3 w-3" /> 수정</Button>
|
||||||
|
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeleteLoading(selectedLoading)}><Trash2 className="mr-1 h-3 w-3" /> 삭제</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">적재 가능 포장단위 ({loadingPkgs.length}건)</span>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openPkgMatchModal}><Plus className="mr-1 h-3 w-3" /> 포장단위 추가</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loadingPkgsLoading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
|
||||||
|
) : loadingPkgs.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs">등록된 포장단위가 없습니다</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-[11px]">
|
||||||
|
<TableHead className="p-2">포장코드</TableHead>
|
||||||
|
<TableHead className="p-2">포장명</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px]">유형</TableHead>
|
||||||
|
<TableHead className="p-2 w-[80px] text-right">최대수량</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px]">적재방향</TableHead>
|
||||||
|
<TableHead className="p-2 w-[40px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loadingPkgs.map((lp) => (
|
||||||
|
<TableRow key={lp.id} className="text-xs">
|
||||||
|
<TableCell className="p-2 font-medium">{lp.pkg_code}</TableCell>
|
||||||
|
<TableCell className="p-2">{lp.pkg_name || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2">{PKG_TYPE_LABEL[lp.pkg_type || ""] || lp.pkg_type || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-right font-semibold">{Number(lp.max_load_qty).toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="p-2">{lp.load_method || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-center">
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeleteLoadPkg(lp)}><X className="h-3 w-3" /></Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 포장재 등록/수정 모달 */}
|
||||||
|
<FullscreenDialog open={pkgModalOpen} onOpenChange={setPkgModalOpen}
|
||||||
|
title={pkgModalMode === "create" ? "포장재 등록" : "포장재 수정"}
|
||||||
|
description="품목정보에서 포장재를 선택하면 코드와 이름이 자동 연동됩니다."
|
||||||
|
footer={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setPkgModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={savePkgUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} 저장</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
{/* 품목정보 연결 */}
|
||||||
|
{pkgModalMode === "create" && (
|
||||||
|
<div className="rounded-lg border bg-blue-50 dark:bg-blue-950/20 p-4">
|
||||||
|
<Label className="text-xs font-semibold mb-2 block">품목정보 연결 (구분: 포장재)</Label>
|
||||||
|
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
|
||||||
|
{pkgForm.pkg_code
|
||||||
|
? `${pkgForm.pkg_name} (${pkgForm.pkg_code})`
|
||||||
|
: "품목정보에서 포장재를 선택하세요"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<Command filter={(value, search) => {
|
||||||
|
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;
|
||||||
|
}}>
|
||||||
|
<CommandInput placeholder="품목코드 / 품목명 검색..." />
|
||||||
|
<CommandList className="max-h-[200px]">
|
||||||
|
<CommandEmpty>검색 결과가 없습니다</CommandEmpty>
|
||||||
|
{pkgItemOptions.map((item) => (
|
||||||
|
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="font-medium">{item.item_name}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
|
||||||
|
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><Label className="text-xs">품목코드</Label><Input value={pkgForm.pkg_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||||
|
<div><Label className="text-xs">포장명</Label><Input value={pkgForm.pkg_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">포장유형 <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={pkgForm.pkg_type || ""} onValueChange={(v) => setPkgForm((p) => ({ ...p, pkg_type: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>{Object.entries(PKG_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">상태</Label>
|
||||||
|
<Select value={pkgForm.status || "ACTIVE"} onValueChange={(v) => setPkgForm((p) => ({ ...p, status: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-semibold">규격정보</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mt-2">
|
||||||
|
<div><Label className="text-[10px]">가로(mm)</Label><Input type="number" value={pkgForm.width_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
<div><Label className="text-[10px]">세로(mm)</Label><Input type="number" value={pkgForm.length_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
<div><Label className="text-[10px]">높이(mm)</Label><Input type="number" value={pkgForm.height_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
<div><Label className="text-[10px]">자체중량(kg)</Label><Input type="number" value={pkgForm.self_weight_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||||
|
<div><Label className="text-[10px]">최대적재중량(kg)</Label><Input type="number" value={pkgForm.max_load_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||||
|
<div><Label className="text-[10px]">내용적(L)</Label><Input type="number" value={pkgForm.volume_l || ""} onChange={(e) => setPkgForm((p) => ({ ...p, volume_l: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><Label className="text-xs">비고</Label><Input value={pkgForm.remarks || ""} onChange={(e) => setPkgForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
|
||||||
|
</div>
|
||||||
|
</FullscreenDialog>
|
||||||
|
|
||||||
|
{/* 적재함 등록/수정 모달 */}
|
||||||
|
<FullscreenDialog open={loadModalOpen} onOpenChange={setLoadModalOpen}
|
||||||
|
title={loadModalMode === "create" ? "적재함 등록" : "적재함 수정"}
|
||||||
|
description="품목정보에서 적재함을 선택하면 코드와 이름이 자동 연동됩니다."
|
||||||
|
footer={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setLoadModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={saveLoadingUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} 저장</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
{loadModalMode === "create" && (
|
||||||
|
<div className="rounded-lg border bg-green-50 dark:bg-green-950/20 p-4">
|
||||||
|
<Label className="text-xs font-semibold mb-2 block">품목정보 연결 (구분: 적재함)</Label>
|
||||||
|
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
|
||||||
|
{loadForm.loading_code
|
||||||
|
? `${loadForm.loading_name} (${loadForm.loading_code})`
|
||||||
|
: "품목정보에서 적재함을 선택하세요"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<Command filter={(value, search) => {
|
||||||
|
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;
|
||||||
|
}}>
|
||||||
|
<CommandInput placeholder="품목코드 / 품목명 검색..." />
|
||||||
|
<CommandList className="max-h-[200px]">
|
||||||
|
<CommandEmpty>검색 결과가 없습니다</CommandEmpty>
|
||||||
|
{loadItemOptions.map((item) => (
|
||||||
|
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="font-medium">{item.item_name}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
|
||||||
|
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><Label className="text-xs">적재함코드</Label><Input value={loadForm.loading_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||||
|
<div><Label className="text-xs">적재함명</Label><Input value={loadForm.loading_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">적재유형 <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={loadForm.loading_type || ""} onValueChange={(v) => setLoadForm((p) => ({ ...p, loading_type: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>{Object.entries(LOADING_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">상태</Label>
|
||||||
|
<Select value={loadForm.status || "ACTIVE"} onValueChange={(v) => setLoadForm((p) => ({ ...p, status: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-semibold">규격정보</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mt-2">
|
||||||
|
<div><Label className="text-[10px]">가로(mm)</Label><Input type="number" value={loadForm.width_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
<div><Label className="text-[10px]">세로(mm)</Label><Input type="number" value={loadForm.length_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
<div><Label className="text-[10px]">높이(mm)</Label><Input type="number" value={loadForm.height_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
<div><Label className="text-[10px]">자체중량(kg)</Label><Input type="number" value={loadForm.self_weight_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||||
|
<div><Label className="text-[10px]">최대적재중량(kg)</Label><Input type="number" value={loadForm.max_load_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||||
|
<div><Label className="text-[10px]">최대단수</Label><Input type="number" value={loadForm.max_stack || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_stack: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><Label className="text-xs">비고</Label><Input value={loadForm.remarks || ""} onChange={(e) => setLoadForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
|
||||||
|
</div>
|
||||||
|
</FullscreenDialog>
|
||||||
|
|
||||||
|
{/* 품목 추가 모달 (포장재 매칭) */}
|
||||||
|
<Dialog open={itemMatchModalOpen} onOpenChange={setItemMatchModalOpen}>
|
||||||
|
<DialogContent className="max-w-[900px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>품목 추가 — {selectedPkg?.pkg_name}</DialogTitle>
|
||||||
|
<DialogDescription>포장재에 매칭할 품목을 검색하여 추가합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input placeholder="품목코드 / 품목명 검색 (입력 시 자동 검색)" value={itemMatchKeyword}
|
||||||
|
onChange={(e) => {
|
||||||
|
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" />
|
||||||
|
<div className="max-h-[300px] overflow-auto border rounded">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-[11px]">
|
||||||
|
<TableHead className="p-2 w-[30px]" />
|
||||||
|
<TableHead className="p-2 w-[130px]">품목코드</TableHead>
|
||||||
|
<TableHead className="p-2">품목명</TableHead>
|
||||||
|
<TableHead className="p-2 w-[100px]">규격</TableHead>
|
||||||
|
<TableHead className="p-2 w-[80px]">재질</TableHead>
|
||||||
|
<TableHead className="p-2 w-[50px]">단위</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16">검색 결과가 없습니다</TableCell></TableRow>
|
||||||
|
) : itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).map((item) => (
|
||||||
|
<TableRow key={item.id} className={cn("cursor-pointer text-xs", itemMatchSelected?.id === item.id && "bg-primary/10")}
|
||||||
|
onClick={() => setItemMatchSelected(item)}>
|
||||||
|
<TableCell className="p-2 text-center">{itemMatchSelected?.id === item.id ? "✓" : ""}</TableCell>
|
||||||
|
<TableCell className="p-2 font-medium truncate max-w-[130px]">{item.item_number}</TableCell>
|
||||||
|
<TableCell className="p-2 truncate max-w-[200px]">{item.item_name}</TableCell>
|
||||||
|
<TableCell className="p-2 truncate">{item.spec || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 truncate">{item.material || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-xs">선택된 품목</Label>
|
||||||
|
<Input value={itemMatchSelected ? `${itemMatchSelected.item_name} (${itemMatchSelected.item_number})` : ""} readOnly className="h-9 bg-muted text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="w-[120px]">
|
||||||
|
<Label htmlFor="pkg-item-match-qty" className="text-xs">포장수량(EA) <span className="text-destructive">*</span></Label>
|
||||||
|
<Input id="pkg-item-match-qty" type="number" value={itemMatchQty} onChange={(e) => setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setItemMatchModalOpen(false)}>취소</Button>
|
||||||
|
<Button type="button" data-action-type="custom" onClick={saveItemMatch} disabled={saving || !itemMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} 추가</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 포장단위 추가 모달 (적재함 구성) */}
|
||||||
|
<Dialog open={pkgMatchModalOpen} onOpenChange={setPkgMatchModalOpen}>
|
||||||
|
<DialogContent className="max-w-[800px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>포장단위 추가 — {selectedLoading?.loading_name}</DialogTitle>
|
||||||
|
<DialogDescription>적재함에 적재할 포장단위를 선택합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
placeholder="포장코드 / 포장명 검색"
|
||||||
|
value={pkgMatchSearchKw}
|
||||||
|
onChange={(e) => setPkgMatchSearchKw(e.target.value)}
|
||||||
|
className="h-9 text-xs"
|
||||||
|
/>
|
||||||
|
<div className="max-h-[300px] overflow-auto border rounded">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-[11px]">
|
||||||
|
<TableHead className="p-2 w-[30px]" />
|
||||||
|
<TableHead className="p-2 w-[120px]">포장코드</TableHead>
|
||||||
|
<TableHead className="p-2">포장명</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px]">유형</TableHead>
|
||||||
|
<TableHead className="p-2 w-[100px]">크기(mm)</TableHead>
|
||||||
|
<TableHead className="p-2 w-[80px] text-right">최대중량</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(() => {
|
||||||
|
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 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16">추가 가능한 포장단위가 없습니다</TableCell></TableRow>
|
||||||
|
) : filtered.map((p) => (
|
||||||
|
<TableRow key={p.id} className={cn("cursor-pointer text-xs", pkgMatchSelected?.id === p.id && "bg-primary/10")}
|
||||||
|
onClick={() => setPkgMatchSelected(p)}>
|
||||||
|
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
|
||||||
|
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
|
||||||
|
<TableCell className="p-2">{p.pkg_name}</TableCell>
|
||||||
|
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
|
||||||
|
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
|
||||||
|
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<div className="w-[150px]">
|
||||||
|
<Label htmlFor="loading-pkg-match-qty" className="text-xs">최대적재수량 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input id="loading-pkg-match-qty" type="number" value={pkgMatchQty} onChange={(e) => setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-xs">적재방향</Label>
|
||||||
|
<Input value={pkgMatchMethod} onChange={(e) => setPkgMatchMethod(e.target.value)} placeholder="수직/수평/혼합" className="h-9 text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setPkgMatchModalOpen(false)}>취소</Button>
|
||||||
|
<Button type="button" data-action-type="custom" onClick={savePkgMatch} disabled={saving || !pkgMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} 추가</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -18,14 +18,7 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import {
|
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -693,14 +686,51 @@ export default function ReceivingPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 입고 등록 모달 */}
|
{/* 입고 등록 모달 */}
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
<FullscreenDialog
|
||||||
<DialogContent className="flex h-[90vh] max-w-[95vw] flex-col p-0 sm:max-w-[1600px]">
|
open={isModalOpen}
|
||||||
<DialogHeader className="border-b px-6 py-4">
|
onOpenChange={setIsModalOpen}
|
||||||
<DialogTitle className="text-lg">입고 등록</DialogTitle>
|
title="입고 등록"
|
||||||
<DialogDescription className="text-xs">
|
description="입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가하세요."
|
||||||
입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가하세요.
|
defaultMaxWidth="sm:max-w-[1600px]"
|
||||||
</DialogDescription>
|
defaultWidth="w-[95vw]"
|
||||||
</DialogHeader>
|
className="h-[90vh] p-0"
|
||||||
|
footer={
|
||||||
|
<div className="flex w-full items-center justify-between px-6 py-3">
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{selectedItems.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{totalSummary.count}건 | 수량 합계:{" "}
|
||||||
|
{totalSummary.qty.toLocaleString()} | 금액 합계:{" "}
|
||||||
|
{totalSummary.amount.toLocaleString()}원
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"품목을 추가해주세요"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || selectedItems.length === 0}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
{/* 입고유형 선택 */}
|
{/* 입고유형 선택 */}
|
||||||
<div className="flex items-center gap-4 border-b px-6 py-3">
|
<div className="flex items-center gap-4 border-b px-6 py-3">
|
||||||
|
|
@ -974,43 +1004,7 @@ export default function ReceivingPage() {
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 푸터 */}
|
</FullscreenDialog>
|
||||||
<DialogFooter className="flex items-center justify-between border-t px-6 py-3">
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
{selectedItems.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{totalSummary.count}건 | 수량 합계:{" "}
|
|
||||||
{totalSummary.qty.toLocaleString()} | 금액 합계:{" "}
|
|
||||||
{totalSummary.amount.toLocaleString()}원
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"품목을 추가해주세요"
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsModalOpen(false)}
|
|
||||||
className="h-9 text-sm"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || selectedItems.length === 0}
|
|
||||||
className="h-9 text-sm"
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Save className="mr-1 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
저장
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,498 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서관리 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* 좌측: 부서 목록 (dept_info)
|
||||||
|
* 우측: 선택한 부서의 인원 목록 (user_info)
|
||||||
|
*
|
||||||
|
* 모달: 부서 등록(dept_info), 사원 추가(user_info)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
import {
|
||||||
|
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||||
|
Building2, Users, Settings2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||||
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||||
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
|
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||||
|
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
|
||||||
|
|
||||||
|
const DEPT_TABLE = "dept_info";
|
||||||
|
const USER_TABLE = "user_info";
|
||||||
|
|
||||||
|
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
|
||||||
|
{ key: "dept_name", label: "부서명", minWidth: "min-w-[150px]" },
|
||||||
|
{ key: "parent_dept_code", label: "상위부서", width: "w-[100px]" },
|
||||||
|
{ key: "status", label: "상태", width: "w-[70px]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RIGHT_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "sabun", label: "사번", width: "w-[80px]" },
|
||||||
|
{ key: "user_name", label: "이름", width: "w-[90px]" },
|
||||||
|
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
|
||||||
|
{ key: "position_name", label: "직급", width: "w-[80px]" },
|
||||||
|
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]" },
|
||||||
|
{ key: "email", label: "이메일", minWidth: "min-w-[150px]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DepartmentPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||||
|
|
||||||
|
// 좌측: 부서
|
||||||
|
const [depts, setDepts] = useState<any[]>([]);
|
||||||
|
const [deptLoading, setDeptLoading] = useState(false);
|
||||||
|
const [deptCount, setDeptCount] = useState(0);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
|
||||||
|
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||||
|
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||||
|
|
||||||
|
// 우측: 사원
|
||||||
|
const [members, setMembers] = useState<any[]>([]);
|
||||||
|
const [memberLoading, setMemberLoading] = useState(false);
|
||||||
|
|
||||||
|
// 부서 모달
|
||||||
|
const [deptModalOpen, setDeptModalOpen] = useState(false);
|
||||||
|
const [deptEditMode, setDeptEditMode] = useState(false);
|
||||||
|
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 사원 모달
|
||||||
|
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||||
|
const [userEditMode, setUserEditMode] = useState(false);
|
||||||
|
const [userForm, setUserForm] = useState<Record<string, any>>({});
|
||||||
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 엑셀
|
||||||
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||||
|
|
||||||
|
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||||
|
setFilterConfig(settings.filters);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = loadTableSettings("department");
|
||||||
|
if (saved) applyTableSettings(saved);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 부서 조회
|
||||||
|
const fetchDepts = useCallback(async () => {
|
||||||
|
setDeptLoading(true);
|
||||||
|
try {
|
||||||
|
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
|
||||||
|
page: 1, size: 500,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
// dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑
|
||||||
|
const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code }));
|
||||||
|
setDepts(data);
|
||||||
|
setDeptCount(res.data?.data?.total || data.length);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("부서 조회 실패:", err);
|
||||||
|
toast.error("부서 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setDeptLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchDepts(); }, [fetchDepts]);
|
||||||
|
|
||||||
|
// 선택된 부서
|
||||||
|
const selectedDept = depts.find((d) => d.id === selectedDeptId);
|
||||||
|
const selectedDeptCode = selectedDept?.dept_code || null;
|
||||||
|
|
||||||
|
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
|
||||||
|
const fetchMembers = useCallback(async () => {
|
||||||
|
setMemberLoading(true);
|
||||||
|
try {
|
||||||
|
const filters = selectedDeptCode
|
||||||
|
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
|
||||||
|
: [];
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
|
||||||
|
page: 1, size: 500,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
|
||||||
|
} catch { setMembers([]); } finally { setMemberLoading(false); }
|
||||||
|
}, [selectedDeptCode]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||||||
|
|
||||||
|
// 부서 등록
|
||||||
|
const openDeptRegister = () => {
|
||||||
|
setDeptForm({});
|
||||||
|
setDeptEditMode(false);
|
||||||
|
setDeptModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeptEdit = () => {
|
||||||
|
if (!selectedDept) return;
|
||||||
|
setDeptForm({ ...selectedDept });
|
||||||
|
setDeptEditMode(true);
|
||||||
|
setDeptModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeptSave = async () => {
|
||||||
|
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (deptEditMode && deptForm.dept_code) {
|
||||||
|
await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, {
|
||||||
|
originalData: { dept_code: deptForm.dept_code },
|
||||||
|
updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null },
|
||||||
|
});
|
||||||
|
toast.success("수정되었습니다.");
|
||||||
|
} else {
|
||||||
|
await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, {
|
||||||
|
dept_code: deptForm.dept_code || "",
|
||||||
|
dept_name: deptForm.dept_name,
|
||||||
|
parent_dept_code: deptForm.parent_dept_code || null,
|
||||||
|
});
|
||||||
|
toast.success("등록되었습니다.");
|
||||||
|
}
|
||||||
|
setDeptModalOpen(false);
|
||||||
|
fetchDepts();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부서 삭제
|
||||||
|
const handleDeptDelete = async () => {
|
||||||
|
if (!selectedDeptCode) return;
|
||||||
|
const ok = await confirm("부서를 삭제하시겠습니까?", {
|
||||||
|
description: "해당 부서에 소속된 사원 정보는 유지됩니다.",
|
||||||
|
variant: "destructive", confirmText: "삭제",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, {
|
||||||
|
data: [{ dept_code: selectedDeptCode }],
|
||||||
|
});
|
||||||
|
toast.success("삭제되었습니다.");
|
||||||
|
setSelectedDeptId(null);
|
||||||
|
fetchDepts();
|
||||||
|
} catch { toast.error("삭제에 실패했습니다."); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사원 추가
|
||||||
|
const openUserModal = (editData?: any) => {
|
||||||
|
if (editData) {
|
||||||
|
setUserEditMode(true);
|
||||||
|
setUserForm({ ...editData, user_password: "" });
|
||||||
|
} else {
|
||||||
|
setUserEditMode(false);
|
||||||
|
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
|
||||||
|
}
|
||||||
|
setFormErrors({});
|
||||||
|
setUserModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserFormChange = (field: string, value: string) => {
|
||||||
|
const formatted = formatField(field, value);
|
||||||
|
setUserForm((prev) => ({ ...prev, [field]: formatted }));
|
||||||
|
const error = validateField(field, formatted);
|
||||||
|
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserSave = async () => {
|
||||||
|
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
|
||||||
|
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
|
||||||
|
const errors = validateForm(userForm, ["cell_phone", "email"]);
|
||||||
|
setFormErrors(errors);
|
||||||
|
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// 비밀번호 미입력 시 기본값 (신규만)
|
||||||
|
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
|
||||||
|
|
||||||
|
await apiClient.post("/admin/users/with-dept", {
|
||||||
|
userInfo: {
|
||||||
|
user_id: userForm.user_id,
|
||||||
|
user_name: userForm.user_name,
|
||||||
|
user_name_eng: userForm.user_name_eng || undefined,
|
||||||
|
user_password: password || undefined,
|
||||||
|
email: userForm.email || undefined,
|
||||||
|
tel: userForm.tel || undefined,
|
||||||
|
cell_phone: userForm.cell_phone || undefined,
|
||||||
|
sabun: userForm.sabun || undefined,
|
||||||
|
position_name: userForm.position_name || undefined,
|
||||||
|
dept_code: userForm.dept_code || undefined,
|
||||||
|
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
|
||||||
|
status: userForm.status || "active",
|
||||||
|
},
|
||||||
|
mainDept: userForm.dept_code ? {
|
||||||
|
dept_code: userForm.dept_code,
|
||||||
|
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
|
||||||
|
position_name: userForm.position_name || undefined,
|
||||||
|
} : undefined,
|
||||||
|
isUpdate: userEditMode,
|
||||||
|
});
|
||||||
|
toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다.");
|
||||||
|
setUserModalOpen(false);
|
||||||
|
fetchMembers();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 다운로드
|
||||||
|
const handleExcelDownload = async () => {
|
||||||
|
if (depts.length === 0) return;
|
||||||
|
const data = depts.map((d) => ({
|
||||||
|
부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status,
|
||||||
|
}));
|
||||||
|
await exportToExcel(data, "부서관리.xlsx", "부서");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={DEPT_TABLE}
|
||||||
|
filterId="department"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={deptCount}
|
||||||
|
externalFilterConfig={filterConfig}
|
||||||
|
extraActions={
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||||
|
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||||
|
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
{/* 좌측: 부서 */}
|
||||||
|
<ResizablePanel defaultSize={40} minSize={25}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Building2 className="w-4 h-4" /> 부서
|
||||||
|
<Badge variant="secondary" className="font-normal">{deptCount}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button size="sm" onClick={openDeptRegister}><Plus className="w-3.5 h-3.5 mr-1" /> 등록</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> 수정</Button>
|
||||||
|
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={handleDeptDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
gridId="dept-left"
|
||||||
|
columns={LEFT_COLUMNS}
|
||||||
|
data={depts}
|
||||||
|
loading={deptLoading}
|
||||||
|
selectedId={selectedDeptId}
|
||||||
|
onSelect={(id) => {
|
||||||
|
setSelectedDeptId((prev) => (prev === id ? null : id));
|
||||||
|
}}
|
||||||
|
onRowDoubleClick={() => openDeptEdit()}
|
||||||
|
emptyMessage="등록된 부서가 없습니다"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 사원 */}
|
||||||
|
<ResizablePanel defaultSize={60} minSize={30}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{selectedDept ? "부서 인원" : "전체 사원"}
|
||||||
|
{selectedDept && <Badge variant="outline" className="font-normal">{selectedDept.dept_name}</Badge>}
|
||||||
|
{members.length > 0 && <Badge variant="secondary" className="font-normal">{members.length}명</Badge>}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" /> 사원 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
gridId="dept-right"
|
||||||
|
columns={RIGHT_COLUMNS}
|
||||||
|
data={members}
|
||||||
|
loading={memberLoading}
|
||||||
|
showRowNumber={false}
|
||||||
|
tableName={USER_TABLE}
|
||||||
|
emptyMessage={selectedDeptCode ? "소속 사원이 없습니다" : "등록된 사원이 없습니다"}
|
||||||
|
onRowDoubleClick={(row) => openUserModal(row)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 부서 등록/수정 모달 */}
|
||||||
|
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
|
||||||
|
<DialogDescription>{deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">부서코드</Label>
|
||||||
|
<Input value={deptForm.dept_code || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_code: e.target.value }))}
|
||||||
|
placeholder="부서코드" className="h-9" disabled={deptEditMode} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">부서명 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={deptForm.dept_name || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
|
||||||
|
placeholder="부서명" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">상위부서</Label>
|
||||||
|
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">없음</SelectItem>
|
||||||
|
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
|
||||||
|
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeptModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleDeptSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 사원 추가 모달 */}
|
||||||
|
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
|
||||||
|
<DialogDescription>{userEditMode ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">사용자 ID <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
|
||||||
|
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">이름 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
|
||||||
|
placeholder="이름" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">사번</Label>
|
||||||
|
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
|
||||||
|
placeholder="사번" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">비밀번호</Label>
|
||||||
|
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
|
||||||
|
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">직급</Label>
|
||||||
|
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
|
||||||
|
placeholder="직급" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">부서</Label>
|
||||||
|
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">휴대폰</Label>
|
||||||
|
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
|
||||||
|
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
|
||||||
|
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">이메일</Label>
|
||||||
|
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
|
||||||
|
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
|
||||||
|
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">입사일</Label>
|
||||||
|
<FormDatePicker value={userForm.regdate || ""} onChange={(v) => setUserForm((p) => ({ ...p, regdate: v }))} placeholder="입사일" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">퇴사일</Label>
|
||||||
|
<FormDatePicker value={userForm.end_date || ""} onChange={(v) => setUserForm((p) => ({ ...p, end_date: v }))} placeholder="퇴사일" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setUserModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleUserSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 엑셀 업로드 */}
|
||||||
|
<ExcelUploadModal
|
||||||
|
open={excelUploadOpen}
|
||||||
|
onOpenChange={setExcelUploadOpen}
|
||||||
|
tableName={DEPT_TABLE}
|
||||||
|
userId={user?.userId}
|
||||||
|
onSuccess={() => fetchDepts()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
|
||||||
|
<TableSettingsModal
|
||||||
|
open={tableSettingsOpen}
|
||||||
|
onOpenChange={setTableSettingsOpen}
|
||||||
|
tableName={DEPT_TABLE}
|
||||||
|
settingsId="department"
|
||||||
|
onSave={applyTableSettings}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<any[]>([]);
|
||||||
|
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<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// 엑셀 업로드
|
||||||
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||||
|
|
||||||
|
// 카테고리 옵션 (API에서 로드)
|
||||||
|
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||||
|
|
||||||
|
// 선택된 행
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(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<string, { code: string; label: string }[]> = {};
|
||||||
|
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<string, any> = {};
|
||||||
|
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 (
|
||||||
|
<Select
|
||||||
|
value={formData[field.key] || ""}
|
||||||
|
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder={`${field.label} 선택`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<SelectItem key={opt.code} value={opt.code}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<Card className="shrink-0">
|
||||||
|
<CardContent className="p-4 flex flex-wrap items-end gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">품명/품목코드</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="검색"
|
||||||
|
className="w-[180px] h-9"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && fetchItems()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">관리품목</Label>
|
||||||
|
<Select value={searchDivision} onValueChange={setSearchDivision}>
|
||||||
|
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{(categoryOptions["division"] || []).map((o) => (
|
||||||
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">품목구분</Label>
|
||||||
|
<Select value={searchType} onValueChange={setSearchType}>
|
||||||
|
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{(categoryOptions["type"] || []).map((o) => (
|
||||||
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">상태</Label>
|
||||||
|
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||||||
|
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{(categoryOptions["status"] || []).map((o) => (
|
||||||
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" /> 초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 메인 테이블 */}
|
||||||
|
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Package className="w-5 h-5" /> 품목 목록
|
||||||
|
<Badge variant="secondary" className="font-normal">{totalCount}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||||
|
<FileSpreadsheet className="w-4 h-4 mr-1.5" /> 엑셀 업로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||||
|
<Download className="w-4 h-4 mr-1.5" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={openRegisterModal}>
|
||||||
|
<Plus className="w-4 h-4 mr-1.5" /> 품목 등록
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
|
||||||
|
const item = items.find((i) => i.id === selectedId);
|
||||||
|
if (item) openCopyModal(item);
|
||||||
|
}}>
|
||||||
|
<Copy className="w-4 h-4 mr-1.5" /> 복사
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
|
||||||
|
const item = items.find((i) => i.id === selectedId);
|
||||||
|
if (item) openEditModal(item);
|
||||||
|
}}>
|
||||||
|
<Pencil className="w-4 h-4 mr-1.5" /> 수정
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1.5" /> 삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground gap-2">
|
||||||
|
<Package className="w-8 h-8 opacity-50" />
|
||||||
|
<span>등록된 품목이 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px] text-center">No</TableHead>
|
||||||
|
{TABLE_COLUMNS.map((col) => (
|
||||||
|
<TableHead key={col.key} className={col.width}>{col.label}</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className={cn("cursor-pointer", selectedId === item.id && "bg-primary/5")}
|
||||||
|
onClick={() => setSelectedId(item.id)}
|
||||||
|
onDoubleClick={() => openEditModal(item)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||||
|
{TABLE_COLUMNS.map((col) => (
|
||||||
|
<TableCell key={col.key} className="text-sm">
|
||||||
|
{["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] || ""}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 등록/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEditMode ? "품목 정보를 수정합니다." : "새로운 품목을 등록합니다."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
{FORM_FIELDS.map((field) => (
|
||||||
|
<div key={field.key} className={cn("space-y-1.5", field.type === "textarea" && "col-span-2")}>
|
||||||
|
<Label className="text-sm">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
{field.type === "category" ? (
|
||||||
|
renderCategorySelect(field)
|
||||||
|
) : field.type === "textarea" ? (
|
||||||
|
<Textarea
|
||||||
|
value={formData[field.key] || ""}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
placeholder={field.label}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={formData[field.key] || ""}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
placeholder={field.disabled ? field.placeholder : field.label}
|
||||||
|
disabled={field.disabled && !isEditMode}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 엑셀 업로드 모달 */}
|
||||||
|
<ExcelUploadModal
|
||||||
|
open={excelUploadOpen}
|
||||||
|
onOpenChange={setExcelUploadOpen}
|
||||||
|
tableName={TABLE_NAME}
|
||||||
|
userId={user?.userId}
|
||||||
|
onSuccess={() => {
|
||||||
|
fetchItems();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,534 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외주품목정보 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* 좌측: 품목 목록 (subcontractor_item_mapping 기반 품목, item_info 조인)
|
||||||
|
* 우측: 선택한 품목의 외주업체 정보 (subcontractor_item_mapping → subcontractor_mng 조인)
|
||||||
|
*
|
||||||
|
* 외주업체관리와 양방향 연동 (같은 subcontractor_item_mapping 테이블)
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, Settings2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||||
|
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||||
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
|
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||||
|
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
const ITEM_TABLE = "item_info";
|
||||||
|
const MAPPING_TABLE = "subcontractor_item_mapping";
|
||||||
|
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
|
||||||
|
|
||||||
|
// 좌측: 품목 컬럼
|
||||||
|
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "item_number", label: "품번", width: "w-[110px]" },
|
||||||
|
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
|
||||||
|
{ key: "size", label: "규격", width: "w-[90px]" },
|
||||||
|
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||||
|
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||||
|
{ key: "status", label: "상태", width: "w-[60px]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 우측: 외주업체 정보 컬럼
|
||||||
|
const RIGHT_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "subcontractor_code", label: "외주업체코드", width: "w-[110px]" },
|
||||||
|
{ key: "subcontractor_name", label: "외주업체명", minWidth: "min-w-[120px]" },
|
||||||
|
{ key: "subcontractor_item_code", label: "외주품번", width: "w-[100px]" },
|
||||||
|
{ key: "subcontractor_item_name", label: "외주품명", width: "w-[100px]" },
|
||||||
|
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SubcontractorItemPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||||
|
|
||||||
|
// 좌측: 품목
|
||||||
|
const [items, setItems] = useState<any[]>([]);
|
||||||
|
const [itemLoading, setItemLoading] = useState(false);
|
||||||
|
const [itemCount, setItemCount] = useState(0);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||||
|
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 우측: 외주업체
|
||||||
|
const [subcontractorItems, setSubcontractorItems] = useState<any[]>([]);
|
||||||
|
const [subcontractorLoading, setSubcontractorLoading] = useState(false);
|
||||||
|
|
||||||
|
// 카테고리
|
||||||
|
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||||
|
|
||||||
|
// 외주업체 추가 모달
|
||||||
|
const [subSelectOpen, setSubSelectOpen] = useState(false);
|
||||||
|
const [subSearchKeyword, setSubSearchKeyword] = useState("");
|
||||||
|
const [subSearchResults, setSubSearchResults] = useState<any[]>([]);
|
||||||
|
const [subSearchLoading, setSubSearchLoading] = useState(false);
|
||||||
|
const [subCheckedIds, setSubCheckedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 품목 수정 모달
|
||||||
|
const [editItemOpen, setEditItemOpen] = useState(false);
|
||||||
|
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 엑셀
|
||||||
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||||
|
|
||||||
|
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||||
|
setFilterConfig(settings.filters);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = loadTableSettings("subcontractor-item");
|
||||||
|
if (saved) applyTableSettings(saved);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 카테고리 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
|
||||||
|
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
setCategoryOptions(optMap);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolve = (col: string, code: string) => {
|
||||||
|
if (!code) return "";
|
||||||
|
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
|
||||||
|
const outsourcingDivisionCode = categoryOptions["division"]?.find(
|
||||||
|
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
|
||||||
|
)?.code;
|
||||||
|
|
||||||
|
const fetchItems = useCallback(async () => {
|
||||||
|
setItemLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||||
|
// division = 외주관리 필터 추가
|
||||||
|
if (outsourcingDivisionCode) {
|
||||||
|
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
|
||||||
|
}
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${ITEM_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 || [];
|
||||||
|
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
|
||||||
|
const data = raw.map((r: any) => {
|
||||||
|
const converted = { ...r };
|
||||||
|
for (const col of CATS) {
|
||||||
|
if (converted[col]) converted[col] = resolve(col, converted[col]);
|
||||||
|
}
|
||||||
|
return converted;
|
||||||
|
});
|
||||||
|
setItems(data);
|
||||||
|
setItemCount(res.data?.data?.total || raw.length);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("품목 조회 실패:", err);
|
||||||
|
toast.error("품목 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setItemLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters, categoryOptions, outsourcingDivisionCode]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchItems(); }, [fetchItems]);
|
||||||
|
|
||||||
|
// 선택된 품목
|
||||||
|
const selectedItem = items.find((i) => i.id === selectedItemId);
|
||||||
|
|
||||||
|
// 우측: 외주업체 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedItem?.item_number) { setSubcontractorItems([]); return; }
|
||||||
|
const itemKey = selectedItem.item_number;
|
||||||
|
const fetchSubcontractorItems = async () => {
|
||||||
|
setSubcontractorLoading(true);
|
||||||
|
try {
|
||||||
|
// subcontractor_item_mapping에서 해당 품목의 매핑 조회
|
||||||
|
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||||
|
page: 1, size: 500,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||||
|
|
||||||
|
// subcontractor_id → subcontractor_mng 조인 (외주업체명)
|
||||||
|
const subIds = [...new Set(mappings.map((m: any) => m.subcontractor_id).filter(Boolean))];
|
||||||
|
let subMap: Record<string, any> = {};
|
||||||
|
if (subIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const subRes = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
|
||||||
|
page: 1, size: subIds.length + 10,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "subcontractor_code", operator: "in", value: subIds }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
for (const s of (subRes.data?.data?.data || subRes.data?.data?.rows || [])) {
|
||||||
|
subMap[s.subcontractor_code] = s;
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubcontractorItems(mappings.map((m: any) => ({
|
||||||
|
...m,
|
||||||
|
subcontractor_code: m.subcontractor_id,
|
||||||
|
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
|
||||||
|
})));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("외주업체 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setSubcontractorLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchSubcontractorItems();
|
||||||
|
}, [selectedItem?.item_number]);
|
||||||
|
|
||||||
|
// 외주업체 검색
|
||||||
|
const searchSubcontractors = async () => {
|
||||||
|
setSubSearchLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (subSearchKeyword) filters.push({ columnName: "subcontractor_name", operator: "contains", value: subSearchKeyword });
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
|
||||||
|
page: 1, size: 50,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const all = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
// 이미 등록된 외주업체 제외
|
||||||
|
const existing = new Set(subcontractorItems.map((s: any) => s.subcontractor_id || s.subcontractor_code));
|
||||||
|
setSubSearchResults(all.filter((s: any) => !existing.has(s.subcontractor_code)));
|
||||||
|
} catch { /* skip */ } finally { setSubSearchLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 외주업체 추가 저장
|
||||||
|
const addSelectedSubcontractors = async () => {
|
||||||
|
const selected = subSearchResults.filter((s) => subCheckedIds.has(s.id));
|
||||||
|
if (selected.length === 0 || !selectedItem) return;
|
||||||
|
try {
|
||||||
|
for (const sub of selected) {
|
||||||
|
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
||||||
|
subcontractor_id: sub.subcontractor_code,
|
||||||
|
item_id: selectedItem.item_number,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success(`${selected.length}개 외주업체가 추가되었습니다.`);
|
||||||
|
setSubCheckedIds(new Set());
|
||||||
|
setSubSelectOpen(false);
|
||||||
|
// 우측 새로고침
|
||||||
|
const sid = selectedItemId;
|
||||||
|
setSelectedItemId(null);
|
||||||
|
setTimeout(() => setSelectedItemId(sid), 50);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.response?.data?.message || "외주업체 추가에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 품목 수정
|
||||||
|
const openEditItem = () => {
|
||||||
|
if (!selectedItem) return;
|
||||||
|
setEditItemForm({ ...selectedItem });
|
||||||
|
setEditItemOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async () => {
|
||||||
|
if (!editItemForm.id) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
|
||||||
|
originalData: { id: editItemForm.id },
|
||||||
|
updatedData: {
|
||||||
|
selling_price: editItemForm.selling_price || null,
|
||||||
|
standard_price: editItemForm.standard_price || null,
|
||||||
|
currency_code: editItemForm.currency_code || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success("수정되었습니다.");
|
||||||
|
setEditItemOpen(false);
|
||||||
|
fetchItems();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 다운로드
|
||||||
|
const handleExcelDownload = async () => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const data = items.map((i) => ({
|
||||||
|
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
|
||||||
|
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
|
||||||
|
}));
|
||||||
|
await exportToExcel(data, "외주품목정보.xlsx", "외주품목");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={ITEM_TABLE}
|
||||||
|
filterId="subcontractor-item"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={itemCount}
|
||||||
|
externalFilterConfig={filterConfig}
|
||||||
|
extraActions={
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||||
|
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||||
|
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
{/* 좌측: 외주품목 목록 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Package className="w-4 h-4" /> 외주품목 목록
|
||||||
|
<Badge variant="secondary" className="font-normal">{itemCount}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
|
||||||
|
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
gridId="subcontractor-item-left"
|
||||||
|
columns={LEFT_COLUMNS}
|
||||||
|
data={items}
|
||||||
|
loading={itemLoading}
|
||||||
|
selectedId={selectedItemId}
|
||||||
|
onSelect={setSelectedItemId}
|
||||||
|
onRowDoubleClick={() => openEditItem()}
|
||||||
|
emptyMessage="등록된 외주품목이 없습니다"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 외주업체 정보 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={25}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Users className="w-4 h-4" /> 외주업체 정보
|
||||||
|
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedItemId}
|
||||||
|
onClick={() => { setSubCheckedIds(new Set()); setSubSelectOpen(true); searchSubcontractors(); }}>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" /> 외주업체 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!selectedItemId ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
좌측에서 품목을 선택하세요
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataGrid
|
||||||
|
gridId="subcontractor-item-right"
|
||||||
|
columns={RIGHT_COLUMNS}
|
||||||
|
data={subcontractorItems}
|
||||||
|
loading={subcontractorLoading}
|
||||||
|
showRowNumber={false}
|
||||||
|
emptyMessage="등록된 외주업체가 없습니다"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 수정 모달 */}
|
||||||
|
<FullscreenDialog
|
||||||
|
open={editItemOpen}
|
||||||
|
onOpenChange={setEditItemOpen}
|
||||||
|
title="외주품목 수정"
|
||||||
|
description={`${editItemForm.item_number || ""} — ${editItemForm.item_name || ""}`}
|
||||||
|
defaultMaxWidth="max-w-2xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setEditItemOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleEditSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
{[
|
||||||
|
{ key: "item_number", label: "품목코드" },
|
||||||
|
{ key: "item_name", label: "품명" },
|
||||||
|
{ key: "size", label: "규격" },
|
||||||
|
{ key: "unit", label: "단위" },
|
||||||
|
{ key: "material", label: "재질" },
|
||||||
|
{ key: "status", label: "상태" },
|
||||||
|
].map((f) => (
|
||||||
|
<div key={f.key} className="space-y-1.5">
|
||||||
|
<Label className="text-sm text-muted-foreground">{f.label}</Label>
|
||||||
|
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="col-span-2 border-t my-2" />
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">판매가격</Label>
|
||||||
|
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
|
||||||
|
placeholder="판매가격" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">기준단가</Label>
|
||||||
|
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
|
||||||
|
placeholder="기준단가" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">통화</Label>
|
||||||
|
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FullscreenDialog>
|
||||||
|
|
||||||
|
{/* 외주업체 추가 모달 */}
|
||||||
|
<Dialog open={subSelectOpen} onOpenChange={setSubSelectOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[70vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>외주업체 선택</DialogTitle>
|
||||||
|
<DialogDescription>품목에 추가할 외주업체를 선택하세요.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<Input placeholder="외주업체명 검색" value={subSearchKeyword}
|
||||||
|
onChange={(e) => setSubSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && searchSubcontractors()}
|
||||||
|
className="h-9 flex-1" />
|
||||||
|
<Button size="sm" onClick={searchSubcontractors} disabled={subSearchLoading} className="h-9">
|
||||||
|
{subSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={subSearchResults.length > 0 && subCheckedIds.size === subSearchResults.length}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) setSubCheckedIds(new Set(subSearchResults.map((s) => s.id)));
|
||||||
|
else setSubCheckedIds(new Set());
|
||||||
|
}} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[110px]">외주업체코드</TableHead>
|
||||||
|
<TableHead className="min-w-[130px]">외주업체명</TableHead>
|
||||||
|
<TableHead className="w-[80px]">거래유형</TableHead>
|
||||||
|
<TableHead className="w-[80px]">담당자</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{subSearchResults.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||||
|
) : subSearchResults.map((s) => (
|
||||||
|
<TableRow key={s.id} className={cn("cursor-pointer", subCheckedIds.has(s.id) && "bg-primary/5")}
|
||||||
|
onClick={() => setSubCheckedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(s.id)) next.delete(s.id); else next.add(s.id);
|
||||||
|
return next;
|
||||||
|
})}>
|
||||||
|
<TableCell className="text-center"><input type="checkbox" checked={subCheckedIds.has(s.id)} readOnly /></TableCell>
|
||||||
|
<TableCell className="text-xs">{s.subcontractor_code}</TableCell>
|
||||||
|
<TableCell className="text-sm">{s.subcontractor_name}</TableCell>
|
||||||
|
<TableCell className="text-xs">{s.division}</TableCell>
|
||||||
|
<TableCell className="text-xs">{s.contact_person}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex items-center gap-2 w-full justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">{subCheckedIds.size}개 선택됨</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setSubSelectOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={addSelectedSubcontractors} disabled={subCheckedIds.size === 0}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" /> {subCheckedIds.size}개 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 엑셀 업로드 */}
|
||||||
|
<ExcelUploadModal
|
||||||
|
open={excelUploadOpen}
|
||||||
|
onOpenChange={setExcelUploadOpen}
|
||||||
|
tableName={ITEM_TABLE}
|
||||||
|
userId={user?.userId}
|
||||||
|
onSuccess={() => fetchItems()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TableSettingsModal
|
||||||
|
open={tableSettingsOpen}
|
||||||
|
onOpenChange={setTableSettingsOpen}
|
||||||
|
tableName={ITEM_TABLE}
|
||||||
|
settingsId="subcontractor-item"
|
||||||
|
onSave={applyTableSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -733,15 +733,17 @@ export default function WorkInstructionPage() {
|
||||||
<div className="max-h-[280px] overflow-auto">
|
<div className="max-h-[280px] overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-background z-10">
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead className="w-[100px] text-right">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[100px] text-right">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{editItems.length === 0 ? (
|
{editItems.length === 0 ? (
|
||||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">품목이 없습니다</TableCell></TableRow>
|
<TableRow><TableCell colSpan={7} className="text-center py-8 text-muted-foreground text-sm">품목이 없습니다</TableCell></TableRow>
|
||||||
) : editItems.map((item, idx) => (
|
) : editItems.map((item, idx) => (
|
||||||
<TableRow key={idx}>
|
<TableRow key={idx}>
|
||||||
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
||||||
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
||||||
|
<TableCell className="text-xs max-w-[180px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||||
|
<TableCell className="text-xs max-w-[100px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
||||||
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||||
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,947 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } 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";
|
||||||
|
// Card, CardContent 제거 — DynamicSearchFilter가 대체
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download,
|
||||||
|
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
|
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||||
|
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||||
|
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
|
||||||
|
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||||
|
|
||||||
|
const DETAIL_TABLE = "sales_order_detail";
|
||||||
|
|
||||||
|
// 천단위 구분자 표시용 (입력 중에는 콤마 포함 표시, 저장 시 숫자만)
|
||||||
|
const formatNumber = (val: string) => {
|
||||||
|
const num = val.replace(/[^\d.-]/g, "");
|
||||||
|
if (!num) return "";
|
||||||
|
const parts = num.split(".");
|
||||||
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
|
return parts.join(".");
|
||||||
|
};
|
||||||
|
const parseNumber = (val: string) => val.replace(/,/g, "");
|
||||||
|
const MASTER_TABLE = "sales_order_mng";
|
||||||
|
|
||||||
|
// 메인 목록 테이블 컬럼 (sales_order_detail 기준)
|
||||||
|
const GRID_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "order_no", label: "수주번호", width: "w-[120px]" },
|
||||||
|
{ key: "part_code", label: "품번", width: "w-[120px]", editable: true },
|
||||||
|
{ key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true },
|
||||||
|
{ key: "spec", label: "규격", width: "w-[120px]", editable: true },
|
||||||
|
{ key: "unit", label: "단위", width: "w-[70px]", editable: true },
|
||||||
|
{ key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
|
||||||
|
{ key: "ship_qty", label: "출하수량", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
|
||||||
|
{ key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "due_date", label: "납기일", width: "w-[110px]" },
|
||||||
|
{ key: "memo", label: "메모", width: "w-[100px]", editable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 조건부 레이어 설정 (input_mode, sell_mode에 따라 표시 필드가 달라짐)
|
||||||
|
// Zone 10: input_mode → 공급업체우선(CAT_MLZWPH5R_983R) / 품목우선(CAT_MLZWPUQC_PB8Z)
|
||||||
|
// Zone 17: sell_mode → 해외판매(CAT_MLZWFF2Z_BQCV)
|
||||||
|
|
||||||
|
export default function SalesOrderPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||||
|
const [orders, setOrders] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
|
||||||
|
// 모달
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
// isModalFullscreen 제거됨 — FullscreenDialog 사용
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
|
||||||
|
const [detailRows, setDetailRows] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// 품목 선택 모달 (리피터에서 품목 추가용)
|
||||||
|
const [itemSelectOpen, setItemSelectOpen] = useState(false);
|
||||||
|
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||||
|
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
||||||
|
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
||||||
|
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 엑셀 업로드
|
||||||
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||||
|
|
||||||
|
// 출하계획 모달
|
||||||
|
const [shippingPlanOpen, setShippingPlanOpen] = useState(false);
|
||||||
|
|
||||||
|
// 카테고리 옵션
|
||||||
|
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||||
|
|
||||||
|
// 체크된 행 (다중선택)
|
||||||
|
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 테이블 설정
|
||||||
|
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||||
|
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(GRID_COLUMNS);
|
||||||
|
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||||
|
|
||||||
|
// 테이블 설정 적용 (컬럼 + 필터)
|
||||||
|
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||||
|
// 컬럼 표시/숨김/순서/너비
|
||||||
|
const colMap = new Map(GRID_COLUMNS.map((c) => [c.key, c]));
|
||||||
|
const applied: DataGridColumn[] = [];
|
||||||
|
for (const cs of settings.columns) {
|
||||||
|
if (!cs.visible) continue;
|
||||||
|
const orig = colMap.get(cs.columnName);
|
||||||
|
if (orig) {
|
||||||
|
applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const settingKeys = new Set(settings.columns.map((c) => c.columnName));
|
||||||
|
for (const col of GRID_COLUMNS) {
|
||||||
|
if (!settingKeys.has(col.key)) applied.push(col);
|
||||||
|
}
|
||||||
|
setGridColumns(applied.length > 0 ? applied : GRID_COLUMNS);
|
||||||
|
|
||||||
|
// 필터 설정 → DynamicSearchFilter에 전달
|
||||||
|
setFilterConfig(settings.filters);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 마운트 시 저장된 설정 복원
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = loadTableSettings("sales-order");
|
||||||
|
if (saved) applyTableSettings(saved);
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 카테고리 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadCategories = async () => {
|
||||||
|
const catColumns = ["sell_mode", "input_mode", "price_mode", "incoterms", "payment_term"];
|
||||||
|
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
// 라벨 치환 + 중복 제거 (같은 label이면 첫 번째만 유지)
|
||||||
|
const LABEL_REPLACE: Record<string, string> = {
|
||||||
|
"공급업체 우선": "거래처 우선",
|
||||||
|
"공급업체우선": "거래처 우선",
|
||||||
|
};
|
||||||
|
const dedup = (items: { code: string; label: string }[]) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return items
|
||||||
|
.map((item) => ({ ...item, label: LABEL_REPLACE[item.label] || item.label }))
|
||||||
|
.filter((item) => {
|
||||||
|
const key = item.label.replace(/\s/g, "");
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
await Promise.all(
|
||||||
|
catColumns.map(async (col) => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`);
|
||||||
|
if (res.data?.success && res.data.data?.length > 0) {
|
||||||
|
optMap[col] = dedup(flatten(res.data.data));
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// 거래처 목록도 로드
|
||||||
|
try {
|
||||||
|
const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, {
|
||||||
|
page: 1, size: 500, autoFilter: true,
|
||||||
|
});
|
||||||
|
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
|
||||||
|
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
|
||||||
|
} catch { /* skip */ }
|
||||||
|
// 사용자 목록 로드 (담당자 선택용)
|
||||||
|
try {
|
||||||
|
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||||
|
page: 1, size: 500, autoFilter: true,
|
||||||
|
});
|
||||||
|
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||||
|
optMap["manager_id"] = users.map((u: any) => ({
|
||||||
|
code: u.user_id || u.id,
|
||||||
|
label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`,
|
||||||
|
}));
|
||||||
|
} catch { /* skip */ }
|
||||||
|
// item_info 카테고리도 로드 (unit, material 등 코드→라벨 변환용)
|
||||||
|
for (const col of ["unit", "material", "division", "type"]) {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
|
||||||
|
if (res.data?.success && res.data.data?.length > 0) {
|
||||||
|
optMap[`item_${col}`] = flatten(res.data.data);
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
setCategoryOptions(optMap);
|
||||||
|
};
|
||||||
|
loadCategories();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 데이터 조회
|
||||||
|
const fetchOrders = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = searchFilters.map((f) => ({
|
||||||
|
columnName: f.columnName,
|
||||||
|
operator: f.operator,
|
||||||
|
value: f.value,
|
||||||
|
}));
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||||
|
page: 1, size: 500,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
sort: { columnName: "order_no", order: "desc" },
|
||||||
|
});
|
||||||
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
|
||||||
|
// part_code → item_info 조인 (품명/규격이 비어있는 경우 보강)
|
||||||
|
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
|
||||||
|
let itemMap: Record<string, any> = {};
|
||||||
|
if (partCodes.length > 0) {
|
||||||
|
try {
|
||||||
|
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
|
page: 1, size: partCodes.length + 10,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: partCodes }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||||
|
for (const item of items) {
|
||||||
|
itemMap[item.item_number] = item;
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조인 적용 + 카테고리 코드→라벨 변환
|
||||||
|
const resolveLabel = (key: string, code: string) => {
|
||||||
|
if (!code) return "";
|
||||||
|
const opts = categoryOptions[key];
|
||||||
|
if (!opts) return code;
|
||||||
|
return opts.find((o) => o.code === code)?.label || code;
|
||||||
|
};
|
||||||
|
const data = rows.map((row: any) => {
|
||||||
|
const item = itemMap[row.part_code];
|
||||||
|
const rawUnit = row.unit || item?.unit || "";
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
part_name: row.part_name || item?.item_name || "",
|
||||||
|
spec: row.spec || item?.size || "",
|
||||||
|
unit: resolveLabel("item_unit", rawUnit) || rawUnit,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setOrders(data);
|
||||||
|
setTotalCount(res.data?.data?.total || data.length);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("수주 조회 실패:", err);
|
||||||
|
toast.error("수주 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||||
|
|
||||||
|
const getCategoryLabel = (col: string, code: string) => {
|
||||||
|
if (!code) return "";
|
||||||
|
const found = categoryOptions[col]?.find((o) => o.code === code);
|
||||||
|
return found?.label || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 등록 모달 열기
|
||||||
|
// 납품처 목록 (거래처 선택 시 조회)
|
||||||
|
const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]);
|
||||||
|
|
||||||
|
const loadDeliveryOptions = async (customerCode: string) => {
|
||||||
|
if (!customerCode) { setDeliveryOptions([]); return; }
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, {
|
||||||
|
page: 1, size: 100,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
setDeliveryOptions(rows.map((r: any) => ({
|
||||||
|
code: r.destination_code || r.id,
|
||||||
|
label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`,
|
||||||
|
})));
|
||||||
|
} catch { setDeliveryOptions([]); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRegisterModal = () => {
|
||||||
|
// 기본값: 각 카테고리의 첫 번째 옵션
|
||||||
|
const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || "";
|
||||||
|
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
|
||||||
|
const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || "";
|
||||||
|
setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "" });
|
||||||
|
setDetailRows([]);
|
||||||
|
setDeliveryOptions([]);
|
||||||
|
setIsEditMode(false);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수정 모달 열기 (order_no로 마스터 + 디테일 조회)
|
||||||
|
const openEditModal = async (orderNo: string) => {
|
||||||
|
try {
|
||||||
|
// 마스터 조회
|
||||||
|
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||||
|
page: 1, size: 1,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const masterData = (masterRes.data?.data?.data || masterRes.data?.data?.rows || [])[0];
|
||||||
|
|
||||||
|
// 디테일 조회
|
||||||
|
const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||||
|
page: 1, size: 100,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
|
||||||
|
|
||||||
|
setMasterForm(masterData || {});
|
||||||
|
setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` })));
|
||||||
|
setIsEditMode(true);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("수주 상세 조회 실패:", err);
|
||||||
|
toast.error("수주 정보를 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 (다중 선택)
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
|
||||||
|
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||||
|
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||||
|
const ok = await confirm(`${checkedIds.length}건의 수주 데이터를 삭제하시겠습니까?`, {
|
||||||
|
description: "삭제된 데이터는 복구할 수 없습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
confirmText: "삭제",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
// 선택된 디테일 행 삭제
|
||||||
|
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
||||||
|
data: checkedIds.map((id) => ({ id })),
|
||||||
|
});
|
||||||
|
// 해당 수주번호의 남은 디테일이 없으면 마스터도 삭제
|
||||||
|
for (const orderNo of orderNos) {
|
||||||
|
const remaining = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||||
|
page: 1, size: 1,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const rows = remaining.data?.data?.data || remaining.data?.data?.rows || [];
|
||||||
|
if (rows.length === 0) {
|
||||||
|
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||||
|
page: 1, size: 1,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||||
|
if (masters.length > 0) {
|
||||||
|
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
||||||
|
data: masters.map((m: any) => ({ id: m.id })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success("삭제되었습니다.");
|
||||||
|
setCheckedIds([]);
|
||||||
|
fetchOrders();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("삭제 실패:", err);
|
||||||
|
toast.error("삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장 (마스터 + 디테일)
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!masterForm.order_no && !isEditMode) {
|
||||||
|
toast.error("수주번호는 필수입니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (detailRows.length === 0) {
|
||||||
|
toast.error("품목을 1개 이상 추가해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm;
|
||||||
|
|
||||||
|
if (isEditMode && id) {
|
||||||
|
// 마스터 수정
|
||||||
|
await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, {
|
||||||
|
originalData: { id },
|
||||||
|
updatedData: masterFields,
|
||||||
|
});
|
||||||
|
// 기존 디테일 삭제 후 재삽입
|
||||||
|
const existingDetails = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||||
|
page: 1, size: 100,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: masterForm.order_no }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const existings = existingDetails.data?.data?.data || existingDetails.data?.data?.rows || [];
|
||||||
|
if (existings.length > 0) {
|
||||||
|
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
||||||
|
data: existings.map((d: any) => ({ id: d.id })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 마스터 등록
|
||||||
|
await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디테일 등록
|
||||||
|
for (const row of detailRows) {
|
||||||
|
const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
|
||||||
|
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
|
||||||
|
...detailFields,
|
||||||
|
order_no: masterForm.order_no,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(isEditMode ? "수정되었습니다." : "등록되었습니다.");
|
||||||
|
setIsModalOpen(false);
|
||||||
|
fetchOrders();
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("저장 실패:", err);
|
||||||
|
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 품목 검색 (리피터에서 추가)
|
||||||
|
const searchItems = async () => {
|
||||||
|
setItemSearchLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (itemSearchKeyword) {
|
||||||
|
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||||
|
}
|
||||||
|
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||||
|
page: 1, size: 50,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
|
||||||
|
} catch { /* skip */ } finally {
|
||||||
|
setItemSearchLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSelectedItemsToDetail = async () => {
|
||||||
|
const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id));
|
||||||
|
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
||||||
|
|
||||||
|
// 단가방식에 따라 단가 조회
|
||||||
|
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
|
||||||
|
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||||
|
const partnerId = masterForm.partner_id;
|
||||||
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
|
||||||
|
// 거래처별 단가 조회 (선택된 품목들에 대해)
|
||||||
|
let customerPriceMap: Record<string, string> = {};
|
||||||
|
if (isCustomerPrice && partnerId) {
|
||||||
|
try {
|
||||||
|
const itemIds = selected.map((item) => item.item_number || item.id);
|
||||||
|
const res = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
|
||||||
|
page: 1, size: 500,
|
||||||
|
dataFilter: {
|
||||||
|
enabled: true,
|
||||||
|
filters: [
|
||||||
|
{ columnName: "customer_id", operator: "equals", value: partnerId },
|
||||||
|
{ columnName: "item_id", operator: "in", value: itemIds },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const mappings = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
for (const m of mappings) {
|
||||||
|
// calculated_price 우선, 없으면 current_unit_price
|
||||||
|
const price = m.calculated_price || m.current_unit_price || "";
|
||||||
|
if (price) customerPriceMap[m.item_id] = String(price);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("거래처별 단가 조회 실패:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRows = selected.map((item) => {
|
||||||
|
const itemCode = item.item_number || item.id;
|
||||||
|
let unitPrice = "";
|
||||||
|
|
||||||
|
if (isStandardPrice) {
|
||||||
|
// 기준단가: item_info의 standard_price 또는 selling_price
|
||||||
|
unitPrice = item.standard_price || item.selling_price || "";
|
||||||
|
} else if (isCustomerPrice && partnerId) {
|
||||||
|
// 거래처별 단가
|
||||||
|
unitPrice = customerPriceMap[itemCode] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: `new_${Date.now()}_${Math.random()}`,
|
||||||
|
part_code: itemCode,
|
||||||
|
part_name: item.item_name,
|
||||||
|
spec: item.size || "",
|
||||||
|
material: getCategoryLabel("item_material", item.material) || item.material || "",
|
||||||
|
unit: getCategoryLabel("item_unit", item.unit) || item.unit || "",
|
||||||
|
qty: "",
|
||||||
|
unit_price: unitPrice,
|
||||||
|
amount: "",
|
||||||
|
due_date: "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setDetailRows((prev) => [...prev, ...newRows]);
|
||||||
|
toast.success(`${selected.length}개 품목이 추가되었습니다.`);
|
||||||
|
setItemCheckedIds(new Set());
|
||||||
|
setItemSelectOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateDetailRow = (idx: number, field: string, value: string) => {
|
||||||
|
setDetailRows((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = { ...next[idx], [field]: value };
|
||||||
|
// 수량 × 단가 = 금액 자동 계산
|
||||||
|
if (field === "qty" || field === "unit_price") {
|
||||||
|
const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0;
|
||||||
|
const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0;
|
||||||
|
next[idx].amount = (qty * price).toString();
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDetailRow = (idx: number) => {
|
||||||
|
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
};
|
||||||
|
|
||||||
|
// input_mode 값으로 레이어 판단
|
||||||
|
// 거래처 우선 (구: 공급업체 우선) - 두 코드 모두 지원
|
||||||
|
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
|
||||||
|
const isItemFirst = masterForm.input_mode === "CAT_MLZWPUQC_PB8Z" || masterForm.input_mode === "CAT_MLKG5FZO_HS1B";
|
||||||
|
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
|
||||||
|
|
||||||
|
const handleExcelDownload = async () => {
|
||||||
|
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||||
|
const data = orders.map((o) => {
|
||||||
|
const row: Record<string, any> = {};
|
||||||
|
for (const col of GRID_COLUMNS) row[col.label] = o[col.key] || "";
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
await exportToExcel(data, "수주관리.xlsx", "수주목록");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 필터 (사용자 설정 가능) */}
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={DETAIL_TABLE}
|
||||||
|
filterId="sales-order"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={totalCount}
|
||||||
|
externalFilterConfig={filterConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 메인 테이블 */}
|
||||||
|
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<ClipboardList className="w-5 h-5" /> 수주 목록
|
||||||
|
<Badge variant="secondary" className="font-normal">{totalCount}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||||
|
<FileSpreadsheet className="w-4 h-4 mr-1.5" /> 엑셀 업로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||||
|
<Download className="w-4 h-4 mr-1.5" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={openRegisterModal}>
|
||||||
|
<Plus className="w-4 h-4 mr-1.5" /> 수주 등록
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={checkedIds.length !== 1} onClick={() => {
|
||||||
|
const item = orders.find((o) => o.id === checkedIds[0]);
|
||||||
|
if (item) openEditModal(item.order_no);
|
||||||
|
}}>
|
||||||
|
<Pencil className="w-4 h-4 mr-1.5" /> 수정
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDelete}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1.5" /> 삭제 {checkedIds.length > 0 && `(${checkedIds.length})`}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={checkedIds.length === 0} onClick={() => setShippingPlanOpen(true)}>
|
||||||
|
<Truck className="w-4 h-4 mr-1.5" /> 출하계획 {checkedIds.length > 0 && `(${checkedIds.length})`}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setTableSettingsOpen(true)}>
|
||||||
|
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataGrid
|
||||||
|
gridId="sales-order"
|
||||||
|
columns={gridColumns}
|
||||||
|
data={orders}
|
||||||
|
loading={loading}
|
||||||
|
showCheckbox
|
||||||
|
showRowNumber={false}
|
||||||
|
checkedIds={checkedIds}
|
||||||
|
onCheckedChange={setCheckedIds}
|
||||||
|
onRowDoubleClick={(row) => openEditModal(row.order_no)}
|
||||||
|
tableName={DETAIL_TABLE}
|
||||||
|
emptyMessage="등록된 수주가 없습니다"
|
||||||
|
onCellEdit={() => fetchOrders()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 수주 등록/수정 모달 */}
|
||||||
|
<FullscreenDialog
|
||||||
|
open={isModalOpen}
|
||||||
|
onOpenChange={setIsModalOpen}
|
||||||
|
title={isEditMode ? "수주 수정" : "수주 등록"}
|
||||||
|
description={isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* 기본 레이어 (항상 표시) */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">수주번호 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={masterForm.order_no || ""} onChange={(e) => setMasterForm((p) => ({ ...p, order_no: e.target.value }))}
|
||||||
|
placeholder="수주번호" className="h-9" disabled={isEditMode} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">수주일</Label>
|
||||||
|
<FormDatePicker value={masterForm.order_date || ""} onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} placeholder="수주일" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">판매 유형</Label>
|
||||||
|
<Select value={masterForm.sell_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, sell_mode: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryOptions["sell_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">입력방식</Label>
|
||||||
|
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryOptions["input_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">단가방식</Label>
|
||||||
|
<Select value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryOptions["price_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 레이어 2: 거래처 우선 (거래처, 담당자, 납품처, 납품장소) */}
|
||||||
|
{isSupplierFirst && (
|
||||||
|
<div className="grid grid-cols-4 gap-4 border-t pt-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">거래처</Label>
|
||||||
|
<Select value={masterForm.partner_id || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">담당자</Label>
|
||||||
|
<Select value={masterForm.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryOptions["manager_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">납품처</Label>
|
||||||
|
{deliveryOptions.length > 0 ? (
|
||||||
|
<Select value={masterForm.delivery_partner_id || ""} onValueChange={(v) => {
|
||||||
|
setMasterForm((p) => ({ ...p, delivery_partner_id: v }));
|
||||||
|
// 선택한 납품처의 주소를 자동 입력
|
||||||
|
const found = deliveryOptions.find((o) => o.code === v);
|
||||||
|
if (found) {
|
||||||
|
const addr = found.label.match(/\((.+)\)$/)?.[1] || "";
|
||||||
|
if (addr) setMasterForm((p) => ({ ...p, delivery_partner_id: v, delivery_address: addr }));
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="납품처 선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{deliveryOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input value={masterForm.delivery_partner_id || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))}
|
||||||
|
placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">납품장소</Label>
|
||||||
|
<Input value={masterForm.delivery_address || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))}
|
||||||
|
placeholder="납품장소" className="h-9" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 레이어 4: 해외판매 (인코텀즈, 결제조건, 통화, 선적항, 도착항, HS코드) */}
|
||||||
|
{isOverseas && (
|
||||||
|
<div className="grid grid-cols-3 gap-4 border-t pt-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">인코텀즈</Label>
|
||||||
|
<Select value={masterForm.incoterms || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, incoterms: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryOptions["incoterms"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">결제조건</Label>
|
||||||
|
<Select value={masterForm.payment_term || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, payment_term: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryOptions["payment_term"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">통화</Label>
|
||||||
|
<Input value={masterForm.currency || ""} onChange={(e) => setMasterForm((p) => ({ ...p, currency: e.target.value }))}
|
||||||
|
placeholder="KRW" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">선적항</Label>
|
||||||
|
<Input value={masterForm.port_of_loading || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_loading: e.target.value }))}
|
||||||
|
className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">도착항</Label>
|
||||||
|
<Input value={masterForm.port_of_discharge || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_discharge: e.target.value }))}
|
||||||
|
className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">HS Code</Label>
|
||||||
|
<Input value={masterForm.hs_code || ""} onChange={(e) => setMasterForm((p) => ({ ...p, hs_code: e.target.value }))}
|
||||||
|
className="h-9" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 리피터 그리드 (품목 목록) — 레이어 2,3 공통 */}
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10">
|
||||||
|
<span className="text-sm font-semibold">수주 품목</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" /> 품목 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto max-h-[300px]">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px]"></TableHead>
|
||||||
|
<TableHead className="w-[120px]">품번</TableHead>
|
||||||
|
<TableHead className="min-w-[120px]">품명</TableHead>
|
||||||
|
<TableHead className="w-[80px]">규격</TableHead>
|
||||||
|
<TableHead className="w-[60px]">단위</TableHead>
|
||||||
|
<TableHead className="w-[110px]">수량</TableHead>
|
||||||
|
<TableHead className="w-[120px]">단가</TableHead>
|
||||||
|
<TableHead className="w-[110px]">금액</TableHead>
|
||||||
|
<TableHead className="w-[200px]">납기일</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{detailRows.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8">품목을 추가해주세요</TableCell></TableRow>
|
||||||
|
) : detailRows.map((row, idx) => (
|
||||||
|
<TableRow key={row._id || idx}>
|
||||||
|
<TableCell>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_code}>{row.part_code}</span></TableCell>
|
||||||
|
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
|
||||||
|
<TableCell className="text-xs">{row.spec}</TableCell>
|
||||||
|
<TableCell className="text-xs">{row.unit}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input value={formatNumber(row.qty || "")} onChange={(e) => updateDetailRow(idx, "qty", parseNumber(e.target.value))}
|
||||||
|
className="h-8 text-sm text-right" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
|
||||||
|
className="h-8 text-sm text-right" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-right font-medium">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메모 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">메모</Label>
|
||||||
|
<Input value={masterForm.memo || ""} onChange={(e) => setMasterForm((p) => ({ ...p, memo: e.target.value }))}
|
||||||
|
placeholder="메모" className="h-9" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 선택 모달 (등록 모달 내부에 중첩) */}
|
||||||
|
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[70vh]" onInteractOutside={(e) => e.preventDefault()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>품목 선택</DialogTitle>
|
||||||
|
<DialogDescription>수주에 추가할 품목을 선택 후 하단 버튼을 눌러주세요.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<Input placeholder="품명/품목코드 검색" value={itemSearchKeyword}
|
||||||
|
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && searchItems()}
|
||||||
|
className="h-9 flex-1" />
|
||||||
|
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
|
||||||
|
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={itemSearchResults.length > 0 && itemCheckedIds.size === itemSearchResults.length}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id)));
|
||||||
|
else setItemCheckedIds(new Set());
|
||||||
|
}} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[130px]">품목코드</TableHead>
|
||||||
|
<TableHead className="min-w-[150px]">품명</TableHead>
|
||||||
|
<TableHead className="w-[100px]">규격</TableHead>
|
||||||
|
<TableHead className="w-[100px]">재질</TableHead>
|
||||||
|
<TableHead className="w-[60px]">단위</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{itemSearchResults.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||||
|
) : itemSearchResults.map((item) => (
|
||||||
|
<TableRow key={item.id} className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
|
||||||
|
onClick={() => setItemCheckedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||||
|
return next;
|
||||||
|
})}>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs max-w-[130px]"><span className="block truncate" title={item.item_number}>{item.item_number}</span></TableCell>
|
||||||
|
<TableCell className="text-sm max-w-[150px]"><span className="block truncate" title={item.item_name}>{item.item_name}</span></TableCell>
|
||||||
|
<TableCell className="text-xs">{item.size}</TableCell>
|
||||||
|
<TableCell className="text-xs">{item.material}</TableCell>
|
||||||
|
<TableCell className="text-xs">{item.unit}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex items-center gap-2 w-full justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">{itemCheckedIds.size}개 선택됨</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(false); }}>취소</Button>
|
||||||
|
<Button onClick={addSelectedItemsToDetail} disabled={itemCheckedIds.size === 0}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" /> {itemCheckedIds.size}개 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</FullscreenDialog>
|
||||||
|
|
||||||
|
{/* 출하계획 동시 등록 모달 */}
|
||||||
|
<ShippingPlanBatchModal
|
||||||
|
open={shippingPlanOpen}
|
||||||
|
onOpenChange={setShippingPlanOpen}
|
||||||
|
selectedDetailIds={checkedIds}
|
||||||
|
onSuccess={fetchOrders}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 엑셀 업로드 */}
|
||||||
|
<ExcelUploadModal
|
||||||
|
open={excelUploadOpen}
|
||||||
|
onOpenChange={setExcelUploadOpen}
|
||||||
|
tableName={DETAIL_TABLE}
|
||||||
|
userId={user?.userId}
|
||||||
|
onSuccess={() => fetchOrders()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 테이블 설정 모달 */}
|
||||||
|
<TableSettingsModal
|
||||||
|
open={tableSettingsOpen}
|
||||||
|
onOpenChange={setTableSettingsOpen}
|
||||||
|
tableName={DETAIL_TABLE}
|
||||||
|
settingsId="sales-order"
|
||||||
|
onSave={applyTableSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 공통 확인 다이얼로그 */}
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,917 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 판매품목정보 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* 좌측: 판매품목 목록 (item_info, 판매 관련 필터)
|
||||||
|
* 우측: 선택한 품목의 거래처 정보 (customer_item_mapping → customer_mng 조인)
|
||||||
|
*
|
||||||
|
* 거래처관리와 양방향 연동 (같은 customer_item_mapping 테이블)
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X, Settings2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||||
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||||
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
|
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||||
|
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||||
|
|
||||||
|
const ITEM_TABLE = "item_info";
|
||||||
|
const MAPPING_TABLE = "customer_item_mapping";
|
||||||
|
const CUSTOMER_TABLE = "customer_mng";
|
||||||
|
|
||||||
|
// 좌측: 판매품목 컬럼
|
||||||
|
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "item_number", label: "품번", width: "w-[110px]" },
|
||||||
|
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
|
||||||
|
{ key: "size", label: "규격", width: "w-[90px]" },
|
||||||
|
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||||
|
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||||
|
{ key: "status", label: "상태", width: "w-[60px]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 우측: 거래처 정보 컬럼
|
||||||
|
const RIGHT_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "customer_code", label: "거래처코드", width: "w-[110px]" },
|
||||||
|
{ key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" },
|
||||||
|
{ key: "customer_item_code", label: "거래처품번", width: "w-[100px]" },
|
||||||
|
{ key: "customer_item_name", label: "거래처품명", width: "w-[100px]" },
|
||||||
|
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SalesItemPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||||
|
|
||||||
|
// 좌측: 품목
|
||||||
|
const [items, setItems] = useState<any[]>([]);
|
||||||
|
const [itemLoading, setItemLoading] = useState(false);
|
||||||
|
const [itemCount, setItemCount] = useState(0);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 테이블 설정
|
||||||
|
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||||
|
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||||
|
|
||||||
|
// 우측: 거래처
|
||||||
|
const [customerItems, setCustomerItems] = useState<any[]>([]);
|
||||||
|
const [customerLoading, setCustomerLoading] = useState(false);
|
||||||
|
|
||||||
|
// 카테고리
|
||||||
|
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||||
|
|
||||||
|
// 거래처 추가 모달
|
||||||
|
const [custSelectOpen, setCustSelectOpen] = useState(false);
|
||||||
|
const [custSearchKeyword, setCustSearchKeyword] = useState("");
|
||||||
|
const [custSearchResults, setCustSearchResults] = useState<any[]>([]);
|
||||||
|
const [custSearchLoading, setCustSearchLoading] = useState(false);
|
||||||
|
const [custCheckedIds, setCustCheckedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 품목 수정 모달
|
||||||
|
const [editItemOpen, setEditItemOpen] = useState(false);
|
||||||
|
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 엑셀
|
||||||
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||||
|
|
||||||
|
// 거래처 상세 입력 모달 (거래처 품번/품명 + 단가)
|
||||||
|
const [custDetailOpen, setCustDetailOpen] = useState(false);
|
||||||
|
const [selectedCustsForDetail, setSelectedCustsForDetail] = useState<any[]>([]);
|
||||||
|
const [custMappings, setCustMappings] = useState<Record<string, Array<{ _id: string; customer_item_code: string; customer_item_name: string }>>>({});
|
||||||
|
const [custPrices, setCustPrices] = useState<Record<string, Array<{
|
||||||
|
_id: string; start_date: string; end_date: string; currency_code: string;
|
||||||
|
base_price_type: string; base_price: string; discount_type: string;
|
||||||
|
discount_value: string; rounding_type: string; rounding_unit_value: string;
|
||||||
|
calculated_price: string;
|
||||||
|
}>>>({});
|
||||||
|
const [priceCategoryOptions, setPriceCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||||
|
const [editCustData, setEditCustData] = useState<any>(null);
|
||||||
|
|
||||||
|
// 테이블 설정 적용 (필터)
|
||||||
|
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||||
|
setFilterConfig(settings.filters);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 마운트 시 저장된 설정 복원
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = loadTableSettings("sales-item");
|
||||||
|
if (saved) applyTableSettings(saved);
|
||||||
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 카테고리 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
|
||||||
|
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
setCategoryOptions(optMap);
|
||||||
|
|
||||||
|
// 단가 카테고리
|
||||||
|
const priceOpts: Record<string, { code: string; label: string }[]> = {};
|
||||||
|
for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/table-categories/customer_item_prices/${col}/values`);
|
||||||
|
if (res.data?.success) priceOpts[col] = flatten(res.data.data || []);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
setPriceCategoryOptions(priceOpts);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolve = (col: string, code: string) => {
|
||||||
|
if (!code) return "";
|
||||||
|
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 좌측: 품목 조회
|
||||||
|
const fetchItems = useCallback(async () => {
|
||||||
|
setItemLoading(true);
|
||||||
|
try {
|
||||||
|
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${ITEM_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 || [];
|
||||||
|
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
|
||||||
|
const data = raw.map((r: any) => {
|
||||||
|
const converted = { ...r };
|
||||||
|
for (const col of CATS) {
|
||||||
|
if (converted[col]) converted[col] = resolve(col, converted[col]);
|
||||||
|
}
|
||||||
|
return converted;
|
||||||
|
});
|
||||||
|
setItems(data);
|
||||||
|
setItemCount(res.data?.data?.total || raw.length);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("품목 조회 실패:", err);
|
||||||
|
toast.error("품목 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setItemLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters, categoryOptions]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchItems(); }, [fetchItems]);
|
||||||
|
|
||||||
|
// 선택된 품목
|
||||||
|
const selectedItem = items.find((i) => i.id === selectedItemId);
|
||||||
|
|
||||||
|
// 우측: 거래처 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedItem?.item_number) { setCustomerItems([]); return; }
|
||||||
|
const itemKey = selectedItem.item_number;
|
||||||
|
const fetchCustomerItems = async () => {
|
||||||
|
setCustomerLoading(true);
|
||||||
|
try {
|
||||||
|
// customer_item_mapping에서 해당 품목의 매핑 조회
|
||||||
|
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||||
|
page: 1, size: 500,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||||
|
|
||||||
|
// customer_id → customer_mng 조인 (거래처명)
|
||||||
|
const custIds = [...new Set(mappings.map((m: any) => m.customer_id).filter(Boolean))];
|
||||||
|
let custMap: Record<string, any> = {};
|
||||||
|
if (custIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const custRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
|
||||||
|
page: 1, size: custIds.length + 10,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "in", value: custIds }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
for (const c of (custRes.data?.data?.data || custRes.data?.data?.rows || [])) {
|
||||||
|
custMap[c.customer_code] = c;
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
setCustomerItems(mappings.map((m: any) => ({
|
||||||
|
...m,
|
||||||
|
customer_code: m.customer_id,
|
||||||
|
customer_name: custMap[m.customer_id]?.customer_name || "",
|
||||||
|
})));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("거래처 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setCustomerLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchCustomerItems();
|
||||||
|
}, [selectedItem?.item_number]);
|
||||||
|
|
||||||
|
// 거래처 검색
|
||||||
|
const searchCustomers = async () => {
|
||||||
|
setCustSearchLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (custSearchKeyword) filters.push({ columnName: "customer_name", operator: "contains", value: custSearchKeyword });
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
|
||||||
|
page: 1, size: 50,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const all = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
// 이미 등록된 거래처 제외
|
||||||
|
const existing = new Set(customerItems.map((c: any) => c.customer_id || c.customer_code));
|
||||||
|
setCustSearchResults(all.filter((c: any) => !existing.has(c.customer_code)));
|
||||||
|
} catch { /* skip */ } finally { setCustSearchLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 거래처 선택 → 상세 모달로 이동
|
||||||
|
const goToCustDetail = () => {
|
||||||
|
const selected = custSearchResults.filter((c) => custCheckedIds.has(c.id));
|
||||||
|
if (selected.length === 0) { toast.error("거래처를 선택해주세요."); return; }
|
||||||
|
setSelectedCustsForDetail(selected);
|
||||||
|
const mappings: typeof custMappings = {};
|
||||||
|
const prices: typeof custPrices = {};
|
||||||
|
for (const cust of selected) {
|
||||||
|
const key = cust.customer_code || cust.id;
|
||||||
|
mappings[key] = [];
|
||||||
|
prices[key] = [{
|
||||||
|
_id: `p_${Date.now()}_${Math.random()}`,
|
||||||
|
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
|
||||||
|
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
|
||||||
|
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
|
||||||
|
calculated_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
setCustMappings(mappings);
|
||||||
|
setCustPrices(prices);
|
||||||
|
setCustSelectOpen(false);
|
||||||
|
setCustDetailOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMappingRow = (custKey: string) => {
|
||||||
|
setCustMappings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[custKey]: [...(prev[custKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, customer_item_code: "", customer_item_name: "" }],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMappingRow = (custKey: string, rowId: string) => {
|
||||||
|
setCustMappings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMappingRow = (custKey: string, rowId: string, field: string, value: string) => {
|
||||||
|
setCustMappings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[custKey]: (prev[custKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPriceRow = (custKey: string) => {
|
||||||
|
setCustPrices((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[custKey]: [...(prev[custKey] || []), {
|
||||||
|
_id: `p_${Date.now()}_${Math.random()}`,
|
||||||
|
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
|
||||||
|
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "",
|
||||||
|
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
|
||||||
|
calculated_price: "",
|
||||||
|
}],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removePriceRow = (custKey: string, rowId: string) => {
|
||||||
|
setCustPrices((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePriceRow = (custKey: string, rowId: string, field: string, value: string) => {
|
||||||
|
setCustPrices((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[custKey]: (prev[custKey] || []).map((r) => {
|
||||||
|
if (r._id !== rowId) return r;
|
||||||
|
const updated = { ...r, [field]: value };
|
||||||
|
if (["base_price", "discount_type", "discount_value"].includes(field)) {
|
||||||
|
const bp = Number(updated.base_price) || 0;
|
||||||
|
const dv = Number(updated.discount_value) || 0;
|
||||||
|
const dt = updated.discount_type;
|
||||||
|
let calc = bp;
|
||||||
|
if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100);
|
||||||
|
else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv;
|
||||||
|
updated.calculated_price = String(Math.round(calc));
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditCust = async (row: any) => {
|
||||||
|
const custKey = row.customer_code || row.customer_id;
|
||||||
|
|
||||||
|
// customer_mng에서 거래처 정보 조회
|
||||||
|
let custInfo: any = { customer_code: custKey, customer_name: row.customer_name || "" };
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, {
|
||||||
|
page: 1, size: 1,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: custKey }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const found = (res.data?.data?.data || res.data?.data?.rows || [])[0];
|
||||||
|
if (found) custInfo = found;
|
||||||
|
} catch { /* skip */ }
|
||||||
|
|
||||||
|
const mappingRows = [{
|
||||||
|
_id: `m_existing_${row.id}`,
|
||||||
|
customer_item_code: row.customer_item_code || "",
|
||||||
|
customer_item_name: row.customer_item_name || "",
|
||||||
|
}].filter((m) => m.customer_item_code || m.customer_item_name);
|
||||||
|
|
||||||
|
const priceRows = [{
|
||||||
|
_id: `p_existing_${row.id}`,
|
||||||
|
start_date: row.start_date || "",
|
||||||
|
end_date: row.end_date || "",
|
||||||
|
currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI",
|
||||||
|
base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW",
|
||||||
|
base_price: row.base_price ? String(row.base_price) : "",
|
||||||
|
discount_type: row.discount_type || "",
|
||||||
|
discount_value: row.discount_value ? String(row.discount_value) : "",
|
||||||
|
rounding_type: row.rounding_type || "",
|
||||||
|
rounding_unit_value: row.rounding_unit_value || "",
|
||||||
|
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
|
||||||
|
}].filter((p) => p.base_price || p.start_date);
|
||||||
|
|
||||||
|
if (priceRows.length === 0) {
|
||||||
|
priceRows.push({
|
||||||
|
_id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
|
||||||
|
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "",
|
||||||
|
rounding_type: "", rounding_unit_value: "", calculated_price: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCustsForDetail([custInfo]);
|
||||||
|
setCustMappings({ [custKey]: mappingRows });
|
||||||
|
setCustPrices({ [custKey]: priceRows });
|
||||||
|
setEditCustData(row);
|
||||||
|
setCustDetailOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustDetailSave = async () => {
|
||||||
|
if (!selectedItem) return;
|
||||||
|
const isEditingExisting = !!editCustData;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
for (const cust of selectedCustsForDetail) {
|
||||||
|
const custKey = cust.customer_code || cust.id;
|
||||||
|
const mappingRows = custMappings[custKey] || [];
|
||||||
|
|
||||||
|
if (isEditingExisting && editCustData?.id) {
|
||||||
|
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
|
||||||
|
originalData: { id: editCustData.id },
|
||||||
|
updatedData: {
|
||||||
|
customer_item_code: mappingRows[0]?.customer_item_code || "",
|
||||||
|
customer_item_name: mappingRows[0]?.customer_item_name || "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 기존 prices 삭제 후 재등록
|
||||||
|
try {
|
||||||
|
const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
|
||||||
|
page: 1, size: 100,
|
||||||
|
dataFilter: { enabled: true, filters: [
|
||||||
|
{ columnName: "mapping_id", operator: "equals", value: editCustData.id },
|
||||||
|
]}, autoFilter: true,
|
||||||
|
});
|
||||||
|
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
|
||||||
|
data: existing.map((p: any) => ({ id: p.id })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
|
||||||
|
const priceRows = (custPrices[custKey] || []).filter((p) =>
|
||||||
|
(p.base_price && Number(p.base_price) > 0) || p.start_date
|
||||||
|
);
|
||||||
|
for (const price of priceRows) {
|
||||||
|
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
|
||||||
|
mapping_id: editCustData.id,
|
||||||
|
customer_id: custKey,
|
||||||
|
item_id: selectedItem.item_number,
|
||||||
|
start_date: price.start_date || null, end_date: price.end_date || null,
|
||||||
|
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
|
||||||
|
base_price: price.base_price ? Number(price.base_price) : null,
|
||||||
|
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
|
||||||
|
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
|
||||||
|
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 신규 등록
|
||||||
|
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
||||||
|
customer_id: custKey, item_id: selectedItem.item_number,
|
||||||
|
customer_item_code: mappingRows[0]?.customer_item_code || "",
|
||||||
|
customer_item_name: mappingRows[0]?.customer_item_name || "",
|
||||||
|
});
|
||||||
|
const mappingId = mappingRes.data?.data?.id || null;
|
||||||
|
|
||||||
|
for (let mi = 1; mi < mappingRows.length; mi++) {
|
||||||
|
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
||||||
|
customer_id: custKey, item_id: selectedItem.item_number,
|
||||||
|
customer_item_code: mappingRows[mi].customer_item_code || "",
|
||||||
|
customer_item_name: mappingRows[mi].customer_item_name || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceRows = (custPrices[custKey] || []).filter((p) =>
|
||||||
|
(p.base_price && Number(p.base_price) > 0) || p.start_date
|
||||||
|
);
|
||||||
|
for (const price of priceRows) {
|
||||||
|
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
|
||||||
|
mapping_id: mappingId || "", customer_id: custKey, item_id: selectedItem.item_number,
|
||||||
|
start_date: price.start_date || null, end_date: price.end_date || null,
|
||||||
|
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
|
||||||
|
base_price: price.base_price ? Number(price.base_price) : null,
|
||||||
|
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
|
||||||
|
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
|
||||||
|
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(isEditingExisting ? "수정되었습니다." : `${selectedCustsForDetail.length}개 거래처가 추가되었습니다.`);
|
||||||
|
setCustDetailOpen(false);
|
||||||
|
setEditCustData(null);
|
||||||
|
setCustCheckedIds(new Set());
|
||||||
|
// 우측 새로고침
|
||||||
|
const sid = selectedItemId;
|
||||||
|
setSelectedItemId(null);
|
||||||
|
setTimeout(() => setSelectedItemId(sid), 50);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 품목 수정
|
||||||
|
const openEditItem = () => {
|
||||||
|
if (!selectedItem) return;
|
||||||
|
setEditItemForm({ ...selectedItem });
|
||||||
|
setEditItemOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async () => {
|
||||||
|
if (!editItemForm.id) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
|
||||||
|
originalData: { id: editItemForm.id },
|
||||||
|
updatedData: {
|
||||||
|
selling_price: editItemForm.selling_price || null,
|
||||||
|
standard_price: editItemForm.standard_price || null,
|
||||||
|
currency_code: editItemForm.currency_code || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success("수정되었습니다.");
|
||||||
|
setEditItemOpen(false);
|
||||||
|
fetchItems();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 다운로드
|
||||||
|
const handleExcelDownload = async () => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const data = items.map((i) => ({
|
||||||
|
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
|
||||||
|
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
|
||||||
|
}));
|
||||||
|
await exportToExcel(data, "판매품목정보.xlsx", "판매품목");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={ITEM_TABLE}
|
||||||
|
filterId="sales-item"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={itemCount}
|
||||||
|
externalFilterConfig={filterConfig}
|
||||||
|
extraActions={
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||||
|
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||||
|
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
{/* 좌측: 판매품목 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Package className="w-4 h-4" /> 판매품목 목록
|
||||||
|
<Badge variant="secondary" className="font-normal">{itemCount}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
|
||||||
|
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
gridId="sales-item-left"
|
||||||
|
columns={LEFT_COLUMNS}
|
||||||
|
data={items}
|
||||||
|
loading={itemLoading}
|
||||||
|
selectedId={selectedItemId}
|
||||||
|
onSelect={setSelectedItemId}
|
||||||
|
onRowDoubleClick={() => openEditItem()}
|
||||||
|
emptyMessage="등록된 판매품목이 없습니다"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 거래처 정보 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={25}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Users className="w-4 h-4" /> 거래처 정보
|
||||||
|
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedItemId}
|
||||||
|
onClick={() => { setCustCheckedIds(new Set()); setCustSelectOpen(true); searchCustomers(); }}>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" /> 거래처 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!selectedItemId ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
좌측에서 품목을 선택하세요
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataGrid
|
||||||
|
gridId="sales-item-right"
|
||||||
|
columns={RIGHT_COLUMNS}
|
||||||
|
data={customerItems}
|
||||||
|
loading={customerLoading}
|
||||||
|
showRowNumber={false}
|
||||||
|
emptyMessage="등록된 거래처가 없습니다"
|
||||||
|
onRowDoubleClick={(row) => openEditCust(row)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 수정 모달 */}
|
||||||
|
<FullscreenDialog
|
||||||
|
open={editItemOpen}
|
||||||
|
onOpenChange={setEditItemOpen}
|
||||||
|
title="판매품목 수정"
|
||||||
|
description={`${editItemForm.item_number || ""} — ${editItemForm.item_name || ""}`}
|
||||||
|
defaultMaxWidth="max-w-2xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setEditItemOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleEditSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
{/* 품목 기본정보 (읽기 전용) */}
|
||||||
|
{[
|
||||||
|
{ key: "item_number", label: "품목코드" },
|
||||||
|
{ key: "item_name", label: "품명" },
|
||||||
|
{ key: "size", label: "규격" },
|
||||||
|
{ key: "unit", label: "단위" },
|
||||||
|
{ key: "material", label: "재질" },
|
||||||
|
{ key: "status", label: "상태" },
|
||||||
|
].map((f) => (
|
||||||
|
<div key={f.key} className="space-y-1.5">
|
||||||
|
<Label className="text-sm text-muted-foreground">{f.label}</Label>
|
||||||
|
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="col-span-2 border-t my-2" />
|
||||||
|
|
||||||
|
{/* 판매 설정 (수정 가능) */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">판매가격</Label>
|
||||||
|
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
|
||||||
|
placeholder="판매가격" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">기준단가</Label>
|
||||||
|
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
|
||||||
|
placeholder="기준단가" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">통화</Label>
|
||||||
|
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FullscreenDialog>
|
||||||
|
|
||||||
|
{/* 거래처 추가 모달 */}
|
||||||
|
<Dialog open={custSelectOpen} onOpenChange={setCustSelectOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[70vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>거래처 선택</DialogTitle>
|
||||||
|
<DialogDescription>품목에 추가할 거래처를 선택하세요.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<Input placeholder="거래처명 검색" value={custSearchKeyword}
|
||||||
|
onChange={(e) => setCustSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && searchCustomers()}
|
||||||
|
className="h-9 flex-1" />
|
||||||
|
<Button size="sm" onClick={searchCustomers} disabled={custSearchLoading} className="h-9">
|
||||||
|
{custSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={custSearchResults.length > 0 && custCheckedIds.size === custSearchResults.length}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) setCustCheckedIds(new Set(custSearchResults.map((c) => c.id)));
|
||||||
|
else setCustCheckedIds(new Set());
|
||||||
|
}} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[110px]">거래처코드</TableHead>
|
||||||
|
<TableHead className="min-w-[130px]">거래처명</TableHead>
|
||||||
|
<TableHead className="w-[80px]">거래유형</TableHead>
|
||||||
|
<TableHead className="w-[80px]">담당자</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{custSearchResults.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||||
|
) : custSearchResults.map((c) => (
|
||||||
|
<TableRow key={c.id} className={cn("cursor-pointer", custCheckedIds.has(c.id) && "bg-primary/5")}
|
||||||
|
onClick={() => setCustCheckedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(c.id)) next.delete(c.id); else next.add(c.id);
|
||||||
|
return next;
|
||||||
|
})}>
|
||||||
|
<TableCell className="text-center"><input type="checkbox" checked={custCheckedIds.has(c.id)} readOnly /></TableCell>
|
||||||
|
<TableCell className="text-xs">{c.customer_code}</TableCell>
|
||||||
|
<TableCell className="text-sm">{c.customer_name}</TableCell>
|
||||||
|
<TableCell className="text-xs">{c.division}</TableCell>
|
||||||
|
<TableCell className="text-xs">{c.contact_person}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex items-center gap-2 w-full justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">{custCheckedIds.size}개 선택됨</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setCustSelectOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={goToCustDetail} disabled={custCheckedIds.size === 0}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" /> {custCheckedIds.size}개 다음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 거래처 상세 입력/수정 모달 */}
|
||||||
|
<FullscreenDialog
|
||||||
|
open={custDetailOpen}
|
||||||
|
onOpenChange={setCustDetailOpen}
|
||||||
|
title={`📋 거래처 상세정보 ${editCustData ? "수정" : "입력"} — ${selectedItem?.item_name || ""}`}
|
||||||
|
description={editCustData ? "거래처 품번/품명과 기간별 단가를 수정합니다." : "선택한 거래처의 품번/품명과 기간별 단가를 설정합니다."}
|
||||||
|
defaultMaxWidth="max-w-[1100px]"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => {
|
||||||
|
setCustDetailOpen(false);
|
||||||
|
if (!editCustData) setCustSelectOpen(true);
|
||||||
|
setEditCustData(null);
|
||||||
|
}}>{editCustData ? "취소" : "← 이전"}</Button>
|
||||||
|
<Button onClick={handleCustDetailSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-6 py-2">
|
||||||
|
{selectedCustsForDetail.map((cust, idx) => {
|
||||||
|
const custKey = cust.customer_code || cust.id;
|
||||||
|
const mappingRows = custMappings[custKey] || [];
|
||||||
|
const prices = custPrices[custKey] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={custKey} className="border rounded-xl overflow-hidden bg-card">
|
||||||
|
<div className="px-5 py-3 bg-muted/30 border-b">
|
||||||
|
<div className="font-bold">{idx + 1}. {cust.customer_name || custKey}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{custKey}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 p-4">
|
||||||
|
{/* 좌: 거래처 품번/품명 */}
|
||||||
|
<div className="flex-1 border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/10">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-semibold">거래처 품번/품명 관리</span>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(custKey)}>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> 품번 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mappingRows.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground py-2">입력된 거래처 품번이 없습니다</div>
|
||||||
|
) : mappingRows.map((mRow, mIdx) => (
|
||||||
|
<div key={mRow._id} className="flex gap-2 items-center">
|
||||||
|
<span className="text-xs text-muted-foreground w-4 shrink-0">{mIdx + 1}</span>
|
||||||
|
<Input value={mRow.customer_item_code}
|
||||||
|
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_code", e.target.value)}
|
||||||
|
placeholder="거래처 품번" className="h-8 text-sm flex-1" />
|
||||||
|
<Input value={mRow.customer_item_name}
|
||||||
|
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_name", e.target.value)}
|
||||||
|
placeholder="거래처 품명" className="h-8 text-sm flex-1" />
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive shrink-0"
|
||||||
|
onClick={() => removeMappingRow(custKey, mRow._id)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우: 기간별 단가 */}
|
||||||
|
<div className="flex-1 border rounded-lg p-4 bg-amber-50/30 dark:bg-amber-950/10">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm font-semibold">기간별 단가 설정</span>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(custKey)}>
|
||||||
|
<Plus className="h-3 w-3 mr-1" /> 단가 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{prices.map((price, pIdx) => (
|
||||||
|
<div key={price._id} className="border rounded-lg p-3 bg-background space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">단가 {pIdx + 1}</span>
|
||||||
|
{prices.length > 1 && (
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive"
|
||||||
|
onClick={() => removePriceRow(custKey, price._id)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<FormDatePicker value={price.start_date}
|
||||||
|
onChange={(v) => updatePriceRow(custKey, price._id, "start_date", v)} placeholder="시작일" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">~</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<FormDatePicker value={price.end_date}
|
||||||
|
onChange={(v) => updatePriceRow(custKey, price._id, "end_date", v)} placeholder="종료일" />
|
||||||
|
</div>
|
||||||
|
<div className="w-[80px]">
|
||||||
|
<Select value={price.currency_code} onValueChange={(v) => updatePriceRow(custKey, price._id, "currency_code", v)}>
|
||||||
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="통화" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(priceCategoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className="w-[90px]">
|
||||||
|
<Select value={price.base_price_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "base_price_type", v)}>
|
||||||
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="기준" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(priceCategoryOptions["base_price_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Input value={price.base_price}
|
||||||
|
onChange={(e) => updatePriceRow(custKey, price._id, "base_price", e.target.value)}
|
||||||
|
className="h-8 text-xs text-right flex-1" placeholder="기준가" />
|
||||||
|
<div className="w-[90px]">
|
||||||
|
<Select value={price.discount_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "discount_type", v)}>
|
||||||
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="할인" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">할인없음</SelectItem>
|
||||||
|
{(priceCategoryOptions["discount_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Input value={price.discount_value}
|
||||||
|
onChange={(e) => updatePriceRow(custKey, price._id, "discount_value", e.target.value)}
|
||||||
|
className="h-8 text-xs text-right w-[60px]" placeholder="0" />
|
||||||
|
<div className="w-[90px]">
|
||||||
|
<Select value={price.rounding_unit_value} onValueChange={(v) => updatePriceRow(custKey, price._id, "rounding_unit_value", v)}>
|
||||||
|
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="반올림" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(priceCategoryOptions["rounding_unit_value"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1 border-t">
|
||||||
|
<span className="text-xs text-muted-foreground">계산 단가:</span>
|
||||||
|
<span className="font-bold text-sm">{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</FullscreenDialog>
|
||||||
|
|
||||||
|
{/* 엑셀 업로드 */}
|
||||||
|
<ExcelUploadModal
|
||||||
|
open={excelUploadOpen}
|
||||||
|
onOpenChange={setExcelUploadOpen}
|
||||||
|
tableName={ITEM_TABLE}
|
||||||
|
userId={user?.userId}
|
||||||
|
onSuccess={() => fetchItems()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
|
||||||
|
<TableSettingsModal
|
||||||
|
open={tableSettingsOpen}
|
||||||
|
onOpenChange={setTableSettingsOpen}
|
||||||
|
tableName={ITEM_TABLE}
|
||||||
|
settingsId="sales-item"
|
||||||
|
onSave={applyTableSettings}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2 } from "lucide-react";
|
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,6 +24,8 @@ import {
|
||||||
getSalesOrderSource,
|
getSalesOrderSource,
|
||||||
getItemSource,
|
getItemSource,
|
||||||
} from "@/lib/api/shipping";
|
} from "@/lib/api/shipping";
|
||||||
|
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||||
|
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||||
|
|
||||||
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
|
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
|
||||||
|
|
||||||
|
|
@ -84,6 +86,9 @@ export default function ShippingOrderPage() {
|
||||||
const [searchDateFrom, setSearchDateFrom] = useState("");
|
const [searchDateFrom, setSearchDateFrom] = useState("");
|
||||||
const [searchDateTo, setSearchDateTo] = useState("");
|
const [searchDateTo, setSearchDateTo] = useState("");
|
||||||
|
|
||||||
|
// 엑셀 업로드
|
||||||
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||||
|
|
||||||
// 모달
|
// 모달
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isEditMode, setIsEditMode] = useState(false);
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
|
@ -467,6 +472,9 @@ export default function ShippingOrderPage() {
|
||||||
<Badge variant="secondary" className="font-normal">{orders.length}건</Badge>
|
<Badge variant="secondary" className="font-normal">{orders.length}건</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||||
|
<FileSpreadsheet className="w-4 h-4 mr-1.5" /> 엑셀 업로드
|
||||||
|
</Button>
|
||||||
<Button size="sm" onClick={() => openModal()}>
|
<Button size="sm" onClick={() => openModal()}>
|
||||||
<Plus className="w-4 h-4 mr-1.5" /> 출하지시 등록
|
<Plus className="w-4 h-4 mr-1.5" /> 출하지시 등록
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -577,14 +585,22 @@ export default function ShippingOrderPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 등록/수정 모달 */}
|
{/* 등록/수정 모달 */}
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
<FullscreenDialog
|
||||||
<DialogContent className="max-w-[90vw] max-h-[90vh] w-[1400px] p-0 flex flex-col overflow-hidden">
|
open={isModalOpen}
|
||||||
<DialogHeader className="p-5 pb-4 border-b bg-primary text-primary-foreground shrink-0">
|
onOpenChange={setIsModalOpen}
|
||||||
<DialogTitle className="text-lg">{isEditMode ? "출하지시 수정" : "출하지시 등록"}</DialogTitle>
|
title={isEditMode ? "출하지시 수정" : "출하지시 등록"}
|
||||||
<DialogDescription className="text-primary-foreground/70">
|
description={isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
|
||||||
{isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
|
defaultMaxWidth="max-w-[90vw]"
|
||||||
</DialogDescription>
|
defaultWidth="w-[1400px]"
|
||||||
</DialogHeader>
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<ResizablePanelGroup direction="horizontal">
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
|
@ -813,14 +829,17 @@ export default function ShippingOrderPage() {
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="p-4 border-t bg-muted/30 shrink-0">
|
</FullscreenDialog>
|
||||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
{/* 엑셀 업로드 모달 */}
|
||||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
<ExcelUploadModal
|
||||||
</Button>
|
open={excelUploadOpen}
|
||||||
</DialogFooter>
|
onOpenChange={setExcelUploadOpen}
|
||||||
</DialogContent>
|
tableName="shipment_instruction"
|
||||||
</Dialog>
|
onSuccess={() => {
|
||||||
|
fetchOrders();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -315,15 +315,15 @@ export default function ShippingPlanPage() {
|
||||||
onCheckedChange={handleCheckAll}
|
onCheckedChange={handleCheckAll}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[160px]">수주번호</TableHead>
|
<TableHead className="w-[10%]">수주번호</TableHead>
|
||||||
<TableHead className="w-[100px] text-center">납기일</TableHead>
|
<TableHead className="w-[8%] text-center">납기일</TableHead>
|
||||||
<TableHead className="w-[120px]">거래처</TableHead>
|
<TableHead className="w-[12%]">거래처</TableHead>
|
||||||
<TableHead className="w-[100px]">품목코드</TableHead>
|
<TableHead className="w-[20%]">품목코드</TableHead>
|
||||||
<TableHead>품목명</TableHead>
|
<TableHead className="w-[20%]">품목명</TableHead>
|
||||||
<TableHead className="w-[80px] text-right">수주수량</TableHead>
|
<TableHead className="w-[7%] text-right">수주수량</TableHead>
|
||||||
<TableHead className="w-[80px] text-right">계획수량</TableHead>
|
<TableHead className="w-[7%] text-right">계획수량</TableHead>
|
||||||
<TableHead className="w-[100px] text-center">출하계획일</TableHead>
|
<TableHead className="w-[8%] text-center">출하계획일</TableHead>
|
||||||
<TableHead className="w-[80px] text-center">상태</TableHead>
|
<TableHead className="w-[6%] text-center">상태</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,839 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useCallback, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
RotateCcw,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Calendar,
|
||||||
|
Upload,
|
||||||
|
PointerIcon,
|
||||||
|
Ruler,
|
||||||
|
ClipboardList,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
ResizablePanelGroup,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizableHandle,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
getDesignRequestList,
|
||||||
|
createDesignRequest,
|
||||||
|
updateDesignRequest,
|
||||||
|
deleteDesignRequest,
|
||||||
|
} from "@/lib/api/design";
|
||||||
|
|
||||||
|
// ========== 타입 ==========
|
||||||
|
interface HistoryItem {
|
||||||
|
id?: string;
|
||||||
|
step: string;
|
||||||
|
history_date: string;
|
||||||
|
user_name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DesignRequest {
|
||||||
|
id: string;
|
||||||
|
request_no: string;
|
||||||
|
source_type: string;
|
||||||
|
request_date: string;
|
||||||
|
due_date: string;
|
||||||
|
design_type: string;
|
||||||
|
priority: string;
|
||||||
|
status: string;
|
||||||
|
approval_step: string;
|
||||||
|
target_name: string;
|
||||||
|
customer: string;
|
||||||
|
req_dept: string;
|
||||||
|
requester: string;
|
||||||
|
designer: string;
|
||||||
|
order_no: string;
|
||||||
|
spec: string;
|
||||||
|
change_type: string;
|
||||||
|
drawing_no: string;
|
||||||
|
urgency: string;
|
||||||
|
reason: string;
|
||||||
|
content: string;
|
||||||
|
apply_timing: string;
|
||||||
|
review_memo: string;
|
||||||
|
project_id: string;
|
||||||
|
ecn_no: string;
|
||||||
|
created_date: string;
|
||||||
|
updated_date: string;
|
||||||
|
writer: string;
|
||||||
|
company_code: string;
|
||||||
|
history: HistoryItem[];
|
||||||
|
impact: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 스타일 맵 ==========
|
||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
신규접수: "bg-muted text-foreground",
|
||||||
|
접수대기: "bg-muted text-foreground",
|
||||||
|
검토중: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||||
|
설계진행: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||||
|
설계검토: "bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300",
|
||||||
|
출도완료: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||||
|
반려: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||||
|
종료: "bg-muted text-muted-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_STYLES: Record<string, string> = {
|
||||||
|
신규설계: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||||
|
유사설계: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||||
|
개조설계: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_STYLES: Record<string, string> = {
|
||||||
|
긴급: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
|
||||||
|
높음: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||||
|
보통: "bg-muted text-foreground",
|
||||||
|
낮음: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_PROGRESS: Record<string, number> = {
|
||||||
|
신규접수: 0,
|
||||||
|
접수대기: 0,
|
||||||
|
검토중: 20,
|
||||||
|
설계진행: 50,
|
||||||
|
설계검토: 80,
|
||||||
|
출도완료: 100,
|
||||||
|
반려: 0,
|
||||||
|
종료: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
function getProgressColor(p: number) {
|
||||||
|
if (p >= 100) return "bg-emerald-500";
|
||||||
|
if (p >= 60) return "bg-amber-500";
|
||||||
|
if (p >= 20) return "bg-blue-500";
|
||||||
|
return "bg-muted";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressTextColor(p: number) {
|
||||||
|
if (p >= 100) return "text-emerald-500";
|
||||||
|
if (p >= 60) return "text-amber-500";
|
||||||
|
if (p >= 20) return "text-blue-500";
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_FORM = {
|
||||||
|
request_no: "",
|
||||||
|
request_date: "",
|
||||||
|
due_date: "",
|
||||||
|
design_type: "",
|
||||||
|
priority: "보통",
|
||||||
|
target_name: "",
|
||||||
|
customer: "",
|
||||||
|
req_dept: "",
|
||||||
|
requester: "",
|
||||||
|
designer: "",
|
||||||
|
order_no: "",
|
||||||
|
spec: "",
|
||||||
|
drawing_no: "",
|
||||||
|
content: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== 메인 컴포넌트 ==========
|
||||||
|
export default function DesignRequestPage() {
|
||||||
|
const [requests, setRequests] = useState<DesignRequest[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [filterStatus, setFilterStatus] = useState("");
|
||||||
|
const [filterType, setFilterType] = useState("");
|
||||||
|
const [filterPriority, setFilterPriority] = useState("");
|
||||||
|
const [filterKeyword, setFilterKeyword] = useState("");
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [isEditMode, setIsEditMode] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState(INITIAL_FORM);
|
||||||
|
|
||||||
|
const today = useMemo(() => new Date(), []);
|
||||||
|
|
||||||
|
// 데이터 조회
|
||||||
|
const fetchRequests = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = { source_type: "dr" };
|
||||||
|
if (filterStatus && filterStatus !== "__all__") params.status = filterStatus;
|
||||||
|
if (filterType && filterType !== "__all__") {
|
||||||
|
// design_type은 서버에서 직접 필터링하지 않으므로 클라이언트에서 처리
|
||||||
|
}
|
||||||
|
if (filterPriority && filterPriority !== "__all__") params.priority = filterPriority;
|
||||||
|
if (filterKeyword) params.search = filterKeyword;
|
||||||
|
|
||||||
|
const res = await getDesignRequestList(params);
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setRequests(res.data);
|
||||||
|
} else {
|
||||||
|
setRequests([]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setRequests([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filterStatus, filterPriority, filterKeyword]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRequests();
|
||||||
|
}, [fetchRequests]);
|
||||||
|
|
||||||
|
// 클라이언트 사이드 필터링 (design_type은 서버에서 지원하지 않으므로)
|
||||||
|
const filteredRequests = useMemo(() => {
|
||||||
|
let list = requests;
|
||||||
|
if (filterType && filterType !== "__all__") {
|
||||||
|
list = list.filter((item) => item.design_type === filterType);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [requests, filterType]);
|
||||||
|
|
||||||
|
const selectedItem = useMemo(() => {
|
||||||
|
if (!selectedId) return null;
|
||||||
|
return requests.find((r) => r.id === selectedId) || null;
|
||||||
|
}, [selectedId, requests]);
|
||||||
|
|
||||||
|
const statusCounts = useMemo(() => {
|
||||||
|
return {
|
||||||
|
접수대기: requests.filter((r) => r.status === "접수대기" || r.status === "신규접수").length,
|
||||||
|
설계진행: requests.filter((r) => r.status === "설계진행").length,
|
||||||
|
출도완료: requests.filter((r) => r.status === "출도완료").length,
|
||||||
|
};
|
||||||
|
}, [requests]);
|
||||||
|
|
||||||
|
const handleResetFilter = useCallback(() => {
|
||||||
|
setFilterStatus("");
|
||||||
|
setFilterType("");
|
||||||
|
setFilterPriority("");
|
||||||
|
setFilterKeyword("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 채번: 기존 데이터 기반으로 다음 번호 생성
|
||||||
|
const generateNextNo = useCallback(() => {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const existing = requests.filter((r) => r.request_no?.startsWith(`DR-${year}-`));
|
||||||
|
const maxNum = existing.reduce((max, r) => {
|
||||||
|
const parts = r.request_no?.split("-");
|
||||||
|
const num = parts?.length >= 3 ? parseInt(parts[2]) : 0;
|
||||||
|
return num > max ? num : max;
|
||||||
|
}, 0);
|
||||||
|
return `DR-${year}-${String(maxNum + 1).padStart(4, "0")}`;
|
||||||
|
}, [requests]);
|
||||||
|
|
||||||
|
const handleOpenRegister = useCallback(() => {
|
||||||
|
setIsEditMode(false);
|
||||||
|
setEditingId(null);
|
||||||
|
setForm({
|
||||||
|
...INITIAL_FORM,
|
||||||
|
request_no: generateNextNo(),
|
||||||
|
request_date: new Date().toISOString().split("T")[0],
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}, [generateNextNo]);
|
||||||
|
|
||||||
|
const handleOpenEdit = useCallback(() => {
|
||||||
|
if (!selectedItem) return;
|
||||||
|
setIsEditMode(true);
|
||||||
|
setEditingId(selectedItem.id);
|
||||||
|
setForm({
|
||||||
|
request_no: selectedItem.request_no || "",
|
||||||
|
request_date: selectedItem.request_date || "",
|
||||||
|
due_date: selectedItem.due_date || "",
|
||||||
|
design_type: selectedItem.design_type || "",
|
||||||
|
priority: selectedItem.priority || "보통",
|
||||||
|
target_name: selectedItem.target_name || "",
|
||||||
|
customer: selectedItem.customer || "",
|
||||||
|
req_dept: selectedItem.req_dept || "",
|
||||||
|
requester: selectedItem.requester || "",
|
||||||
|
designer: selectedItem.designer || "",
|
||||||
|
order_no: selectedItem.order_no || "",
|
||||||
|
spec: selectedItem.spec || "",
|
||||||
|
drawing_no: selectedItem.drawing_no || "",
|
||||||
|
content: selectedItem.content || "",
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}, [selectedItem]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!form.target_name.trim()) { alert("설비/제품명을 입력하세요."); return; }
|
||||||
|
if (!form.design_type) { alert("의뢰 유형을 선택하세요."); return; }
|
||||||
|
if (!form.due_date) { alert("납기를 입력하세요."); return; }
|
||||||
|
if (!form.spec.trim()) { alert("요구사양을 입력하세요."); return; }
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
request_no: form.request_no,
|
||||||
|
source_type: "dr",
|
||||||
|
request_date: form.request_date,
|
||||||
|
due_date: form.due_date,
|
||||||
|
design_type: form.design_type,
|
||||||
|
priority: form.priority,
|
||||||
|
target_name: form.target_name,
|
||||||
|
customer: form.customer,
|
||||||
|
req_dept: form.req_dept,
|
||||||
|
requester: form.requester,
|
||||||
|
designer: form.designer,
|
||||||
|
order_no: form.order_no,
|
||||||
|
spec: form.spec,
|
||||||
|
drawing_no: form.drawing_no,
|
||||||
|
content: form.content,
|
||||||
|
};
|
||||||
|
|
||||||
|
let res;
|
||||||
|
if (isEditMode && editingId) {
|
||||||
|
res = await updateDesignRequest(editingId, payload);
|
||||||
|
} else {
|
||||||
|
res = await createDesignRequest({
|
||||||
|
...payload,
|
||||||
|
status: "신규접수",
|
||||||
|
history: [{
|
||||||
|
step: "신규접수",
|
||||||
|
history_date: form.request_date || new Date().toISOString().split("T")[0],
|
||||||
|
user_name: form.requester || "시스템",
|
||||||
|
description: `${form.req_dept || ""}에서 설계의뢰 등록`,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
setModalOpen(false);
|
||||||
|
await fetchRequests();
|
||||||
|
if (isEditMode && editingId) {
|
||||||
|
setSelectedId(editingId);
|
||||||
|
} else if (res.data?.id) {
|
||||||
|
setSelectedId(res.data.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(`저장 실패: ${res.message || "알 수 없는 오류"}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`저장 중 오류가 발생했습니다: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [form, isEditMode, editingId, fetchRequests]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(async () => {
|
||||||
|
if (!selectedId || !selectedItem) return;
|
||||||
|
const displayNo = selectedItem.request_no || selectedId;
|
||||||
|
if (!confirm(`${displayNo} 설계의뢰를 삭제하시겠습니까?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await deleteDesignRequest(selectedId);
|
||||||
|
if (res.success) {
|
||||||
|
setSelectedId(null);
|
||||||
|
await fetchRequests();
|
||||||
|
} else {
|
||||||
|
alert(`삭제 실패: ${res.message || "알 수 없는 오류"}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(`삭제 중 오류가 발생했습니다: ${err.message}`);
|
||||||
|
}
|
||||||
|
}, [selectedId, selectedItem, fetchRequests]);
|
||||||
|
|
||||||
|
const getDueDateInfo = useCallback(
|
||||||
|
(dueDate: string) => {
|
||||||
|
if (!dueDate) return { text: "-", color: "text-muted-foreground" };
|
||||||
|
const due = new Date(dueDate);
|
||||||
|
const diff = Math.ceil((due.getTime() - today.getTime()) / 86400000);
|
||||||
|
if (diff < 0) return { text: `${Math.abs(diff)}일 초과`, color: "text-destructive" };
|
||||||
|
if (diff === 0) return { text: "오늘", color: "text-amber-500" };
|
||||||
|
if (diff <= 7) return { text: `${diff}일 남음`, color: "text-amber-500" };
|
||||||
|
return { text: `${diff}일 남음`, color: "text-emerald-500" };
|
||||||
|
},
|
||||||
|
[today]
|
||||||
|
);
|
||||||
|
|
||||||
|
const getProgress = useCallback((status: string) => {
|
||||||
|
return STATUS_PROGRESS[status] ?? 0;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-2 p-3">
|
||||||
|
{/* 검색 섹션 */}
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center gap-2 rounded-lg border bg-card px-3 py-2">
|
||||||
|
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||||
|
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="상태 전체" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">상태 전체</SelectItem>
|
||||||
|
{["신규접수", "접수대기", "검토중", "설계진행", "설계검토", "출도완료", "반려", "종료"].map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filterType} onValueChange={setFilterType}>
|
||||||
|
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="유형 전체" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">유형 전체</SelectItem>
|
||||||
|
{["신규설계", "유사설계", "개조설계"].map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={filterPriority} onValueChange={setFilterPriority}>
|
||||||
|
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="우선순위 전체" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">우선순위 전체</SelectItem>
|
||||||
|
{["긴급", "높음", "보통", "낮음"].map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={filterKeyword}
|
||||||
|
onChange={(e) => setFilterKeyword(e.target.value)}
|
||||||
|
placeholder="의뢰번호 / 설비명 / 고객명 검색"
|
||||||
|
className="h-7 w-[240px] pl-7 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleResetFilter}>
|
||||||
|
<RotateCcw className="mr-1 h-3 w-3" />초기화
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => fetchRequests()}>
|
||||||
|
<Search className="mr-1 h-3 w-3" />조회
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 영역 */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
|
||||||
|
{/* 왼쪽: 목록 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
|
||||||
|
<span className="text-sm font-bold">
|
||||||
|
<Ruler className="mr-1 inline h-4 w-4" />
|
||||||
|
설계의뢰 목록 (<span className="text-primary">{filteredRequests.length}</span>건)
|
||||||
|
</span>
|
||||||
|
<Button size="sm" className="h-7 text-xs" onClick={handleOpenRegister}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />설계의뢰 등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">불러오는 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px] text-[11px]">의뢰번호</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-center text-[11px]">유형</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-center text-[11px]">상태</TableHead>
|
||||||
|
<TableHead className="w-[60px] text-center text-[11px]">우선순위</TableHead>
|
||||||
|
<TableHead className="text-[11px]">설비/제품명</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-[11px]">고객명</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-[11px]">설계담당</TableHead>
|
||||||
|
<TableHead className="w-[85px] text-[11px]">납기</TableHead>
|
||||||
|
<TableHead className="w-[65px] text-center text-[11px]">진행률</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredRequests.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={9} className="py-12 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-1 text-muted-foreground">
|
||||||
|
<Ruler className="h-8 w-8" />
|
||||||
|
<span className="text-sm">등록된 설계의뢰가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{filteredRequests.map((item) => {
|
||||||
|
const progress = getProgress(item.status);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className={cn("cursor-pointer", selectedId === item.id && "bg-accent")}
|
||||||
|
onClick={() => setSelectedId(item.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-[11px] font-semibold text-primary">{item.request_no || "-"}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{item.design_type ? (
|
||||||
|
<Badge className={cn("text-[9px]", TYPE_STYLES[item.design_type])}>{item.design_type}</Badge>
|
||||||
|
) : "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className={cn("text-[9px]", STATUS_STYLES[item.status])}>{item.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<Badge className={cn("text-[9px]", PRIORITY_STYLES[item.priority])}>{item.priority}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs font-medium">{item.target_name || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[11px]">{item.customer || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[11px]">{item.designer || "-"}</TableCell>
|
||||||
|
<TableCell className="text-[11px]">{item.due_date || "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className={cn("h-full rounded-full transition-all", getProgressColor(progress))} style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-[10px] font-semibold", getProgressTextColor(progress))}>{progress}%</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 오른쪽: 상세 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={25}>
|
||||||
|
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
|
||||||
|
<span className="text-sm font-bold">
|
||||||
|
<ClipboardList className="mr-1 inline h-4 w-4" />
|
||||||
|
상세 정보
|
||||||
|
</span>
|
||||||
|
{selectedItem && (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-[10px]" onClick={handleOpenEdit}>
|
||||||
|
<Pencil className="mr-0.5 h-3 w-3" />수정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-[10px] text-destructive hover:text-destructive" onClick={handleDelete}>
|
||||||
|
<Trash2 className="mr-0.5 h-3 w-3" />삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-3">
|
||||||
|
{/* 상태 카드 */}
|
||||||
|
<div className="mb-3 grid grid-cols-3 gap-2">
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
|
||||||
|
onClick={() => setFilterStatus("접수대기")}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-muted-foreground">접수대기</div>
|
||||||
|
<div className="text-xl font-bold text-blue-500">{statusCounts.접수대기}</div>
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
|
||||||
|
onClick={() => setFilterStatus("설계진행")}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-muted-foreground">설계진행</div>
|
||||||
|
<div className="text-xl font-bold text-amber-500">{statusCounts.설계진행}</div>
|
||||||
|
</Card>
|
||||||
|
<Card
|
||||||
|
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
|
||||||
|
onClick={() => setFilterStatus("출도완료")}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] text-muted-foreground">출도완료</div>
|
||||||
|
<div className="text-xl font-bold text-emerald-500">{statusCounts.출도완료}</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 내용 */}
|
||||||
|
{!selectedItem ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
|
||||||
|
<PointerIcon className="h-8 w-8" />
|
||||||
|
<span className="text-sm">좌측 목록에서 설계의뢰를 선택하세요</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 기본 정보 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs font-bold">
|
||||||
|
<FileText className="mr-1 inline h-3.5 w-3.5" />기본 정보
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-lg border bg-muted/10 p-3">
|
||||||
|
<InfoRow label="의뢰번호" value={<span className="font-semibold text-primary">{selectedItem.request_no || "-"}</span>} />
|
||||||
|
<InfoRow label="상태" value={<Badge className={cn("text-[10px]", STATUS_STYLES[selectedItem.status])}>{selectedItem.status}</Badge>} />
|
||||||
|
<InfoRow label="유형" value={selectedItem.design_type ? <Badge className={cn("text-[10px]", TYPE_STYLES[selectedItem.design_type])}>{selectedItem.design_type}</Badge> : "-"} />
|
||||||
|
<InfoRow label="우선순위" value={<Badge className={cn("text-[10px]", PRIORITY_STYLES[selectedItem.priority])}>{selectedItem.priority}</Badge>} />
|
||||||
|
<InfoRow label="설비/제품명" value={selectedItem.target_name || "-"} />
|
||||||
|
<InfoRow label="고객명" value={selectedItem.customer || "-"} />
|
||||||
|
<InfoRow label="의뢰부서 / 의뢰자" value={`${selectedItem.req_dept || "-"} / ${selectedItem.requester || "-"}`} />
|
||||||
|
<InfoRow label="설계담당" value={selectedItem.designer || "미배정"} />
|
||||||
|
<InfoRow label="의뢰일자" value={selectedItem.request_date || "-"} />
|
||||||
|
<InfoRow
|
||||||
|
label="납기"
|
||||||
|
value={
|
||||||
|
selectedItem.due_date ? (
|
||||||
|
<span>
|
||||||
|
{selectedItem.due_date}{" "}
|
||||||
|
<span className={cn("text-[11px]", getDueDateInfo(selectedItem.due_date).color)}>
|
||||||
|
({getDueDateInfo(selectedItem.due_date).text})
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
) : "-"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoRow label="수주번호" value={selectedItem.order_no || "-"} />
|
||||||
|
<InfoRow
|
||||||
|
label="진행률"
|
||||||
|
value={
|
||||||
|
(() => {
|
||||||
|
const progress = getProgress(selectedItem.status);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className={cn("h-full rounded-full", getProgressColor(progress))} style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={cn("text-xs font-bold", getProgressTextColor(progress))}>{progress}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 요구사양 */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs font-bold">
|
||||||
|
<FileText className="mr-1 inline h-3.5 w-3.5" />요구사양
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border bg-muted/10 p-3">
|
||||||
|
<pre className="whitespace-pre-wrap font-sans text-xs leading-relaxed">{selectedItem.spec || "-"}</pre>
|
||||||
|
{selectedItem.drawing_no && (
|
||||||
|
<div className="mt-2 text-xs">
|
||||||
|
<span className="text-muted-foreground">참조 도면: </span>
|
||||||
|
<span className="text-primary">{selectedItem.drawing_no}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedItem.content && (
|
||||||
|
<div className="mt-1 text-xs">
|
||||||
|
<span className="text-muted-foreground">비고: </span>{selectedItem.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 진행 이력 */}
|
||||||
|
{selectedItem.history && selectedItem.history.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-xs font-bold">
|
||||||
|
<Calendar className="mr-1 inline h-3.5 w-3.5" />진행 이력
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0">
|
||||||
|
{selectedItem.history.map((h, idx) => {
|
||||||
|
const isLast = idx === selectedItem.history.length - 1;
|
||||||
|
const isDone = h.step === "출도완료" || h.step === "종료";
|
||||||
|
return (
|
||||||
|
<div key={h.id || idx} className="flex gap-3">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full border-2",
|
||||||
|
isLast && !isDone
|
||||||
|
? "border-blue-500 bg-blue-500"
|
||||||
|
: isDone || !isLast
|
||||||
|
? "border-emerald-500 bg-emerald-500"
|
||||||
|
: "border-muted-foreground bg-muted-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{!isLast && <div className="w-px flex-1 bg-border" />}
|
||||||
|
</div>
|
||||||
|
<div className="pb-3">
|
||||||
|
<Badge className={cn("text-[9px]", STATUS_STYLES[h.step])}>{h.step}</Badge>
|
||||||
|
<div className="mt-0.5 text-xs">{h.description}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">{h.history_date} · {h.user_name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
|
||||||
|
{/* 등록/수정 모달 */}
|
||||||
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[1100px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-lg">
|
||||||
|
{isEditMode ? <><Pencil className="mr-1.5 inline h-5 w-5" />설계의뢰 수정</> : <><Plus className="mr-1.5 inline h-5 w-5" />설계의뢰 등록</>}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-sm">
|
||||||
|
{isEditMode ? "설계의뢰 정보를 수정합니다." : "새 설계의뢰를 등록합니다."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{/* 좌측: 기본 정보 */}
|
||||||
|
<div className="w-[420px] shrink-0 space-y-4">
|
||||||
|
<div className="text-sm font-bold">
|
||||||
|
<FileText className="mr-1 inline h-4 w-4" />의뢰 기본 정보
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">의뢰번호</Label>
|
||||||
|
<Input value={form.request_no} readOnly className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">의뢰일자</Label>
|
||||||
|
<Input type="date" value={form.request_date} onChange={(e) => setForm((p) => ({ ...p, request_date: e.target.value }))} className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">납기 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input type="date" value={form.due_date} onChange={(e) => setForm((p) => ({ ...p, due_date: e.target.value }))} className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">의뢰 유형 <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={form.design_type} onValueChange={(v) => setForm((p) => ({ ...p, design_type: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["신규설계", "유사설계", "개조설계"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">우선순위 <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={form.priority} onValueChange={(v) => setForm((p) => ({ ...p, priority: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">설비/제품명 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={form.target_name} onChange={(e) => setForm((p) => ({ ...p, target_name: e.target.value }))} placeholder="설비 또는 제품명 입력" className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">의뢰부서</Label>
|
||||||
|
<Select value={form.req_dept} onValueChange={(v) => setForm((p) => ({ ...p, req_dept: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["영업팀", "기획팀", "생산팀", "품질팀"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">의뢰자</Label>
|
||||||
|
<Input value={form.requester} onChange={(e) => setForm((p) => ({ ...p, requester: e.target.value }))} placeholder="의뢰자명" className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">고객명</Label>
|
||||||
|
<Input value={form.customer} onChange={(e) => setForm((p) => ({ ...p, customer: e.target.value }))} placeholder="고객/거래처명" className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">수주번호</Label>
|
||||||
|
<Input value={form.order_no} onChange={(e) => setForm((p) => ({ ...p, order_no: e.target.value }))} placeholder="관련 수주번호" className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">설계담당자</Label>
|
||||||
|
<Select value={form.designer} onValueChange={(v) => setForm((p) => ({ ...p, designer: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{["이설계", "박도면", "최기구", "김전장"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 상세 내용 */}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||||
|
<div className="text-sm font-bold">
|
||||||
|
<FileText className="mr-1 inline h-4 w-4" />요구사양 및 설명
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-sm">요구사양 <span className="text-destructive">*</span></Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.spec}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, spec: e.target.value }))}
|
||||||
|
placeholder={"고객 요구사양 또는 설비 사양을 상세히 기술하세요\n\n예시:\n- 작업 대상: SUS304 Φ20 파이프\n- 가공 방식: 자동 절단 + 면취\n- 생산 속도: 60EA/분\n- 치수 공차: ±0.1mm"}
|
||||||
|
className="min-h-[180px] text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">참조 도면번호</Label>
|
||||||
|
<Input value={form.drawing_no} onChange={(e) => setForm((p) => ({ ...p, drawing_no: e.target.value }))} placeholder="유사 설비명 또는 참조 도면번호" className="h-9 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">비고</Label>
|
||||||
|
<Textarea value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} placeholder="기타 참고 사항" className="min-h-[70px] text-sm" rows={3} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold">
|
||||||
|
<Upload className="mr-1 inline h-4 w-4" />첨부파일
|
||||||
|
</div>
|
||||||
|
<div className="mt-1.5 cursor-pointer rounded-lg border-2 border-dashed p-5 text-center transition-colors hover:border-primary hover:bg-accent/50">
|
||||||
|
<Upload className="mx-auto h-6 w-6 text-muted-foreground" />
|
||||||
|
<div className="mt-1.5 text-sm text-muted-foreground">클릭하여 파일 첨부 (사양서, 도면, 사진 등)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button variant="outline" onClick={() => setModalOpen(false)} className="h-10 px-6 text-sm" disabled={saving}>취소</Button>
|
||||||
|
<Button onClick={handleSave} className="h-10 px-6 text-sm" disabled={saving}>
|
||||||
|
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
|
||||||
|
{saving ? "저장 중..." : "저장"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 정보 행 서브컴포넌트 ==========
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<span className="min-w-[80px] shrink-0 text-[11px] text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-xs font-medium">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,752 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설비정보 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* 좌측: 설비 목록 (equipment_mng)
|
||||||
|
* 우측: 탭 (기본정보 / 점검항목 / 소모품)
|
||||||
|
* 점검항목 복사 기능 포함
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
import {
|
||||||
|
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||||
|
Wrench, ClipboardCheck, Package, Copy, Info, Settings2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||||
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||||
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
|
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||||
|
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||||
|
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
|
||||||
|
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
|
||||||
|
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||||
|
|
||||||
|
const EQUIP_TABLE = "equipment_mng";
|
||||||
|
const INSPECTION_TABLE = "equipment_inspection_item";
|
||||||
|
const CONSUMABLE_TABLE = "equipment_consumable";
|
||||||
|
|
||||||
|
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "equipment_code", label: "설비코드", width: "w-[110px]" },
|
||||||
|
{ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]" },
|
||||||
|
{ key: "equipment_type", label: "설비유형", width: "w-[90px]" },
|
||||||
|
{ key: "manufacturer", label: "제조사", width: "w-[100px]" },
|
||||||
|
{ key: "installation_location", label: "설치장소", width: "w-[100px]" },
|
||||||
|
{ key: "operation_status", label: "가동상태", width: "w-[80px]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const INSPECTION_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "inspection_item", label: "점검항목", minWidth: "min-w-[120px]", editable: true },
|
||||||
|
{ key: "inspection_cycle", label: "점검주기", width: "w-[80px]" },
|
||||||
|
{ key: "inspection_method", label: "점검방법", width: "w-[80px]" },
|
||||||
|
{ key: "lower_limit", label: "하한치", width: "w-[70px]", editable: true },
|
||||||
|
{ key: "upper_limit", label: "상한치", width: "w-[70px]", editable: true },
|
||||||
|
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
|
||||||
|
{ key: "inspection_content", label: "점검내용", minWidth: "min-w-[150px]", editable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONSUMABLE_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "image_path", label: "이미지", width: "w-[50px]", renderType: "image", sortable: false, filterable: false },
|
||||||
|
{ key: "consumable_name", label: "소모품명", minWidth: "min-w-[120px]", editable: true },
|
||||||
|
{ key: "replacement_cycle", label: "교체주기", width: "w-[90px]", editable: true },
|
||||||
|
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
|
||||||
|
{ key: "specification", label: "규격", width: "w-[100px]", editable: true },
|
||||||
|
{ key: "manufacturer", label: "제조사", width: "w-[100px]", editable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EquipmentInfoPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||||
|
|
||||||
|
// 좌측
|
||||||
|
const [equipments, setEquipments] = useState<any[]>([]);
|
||||||
|
const [equipLoading, setEquipLoading] = useState(false);
|
||||||
|
const [equipCount, setEquipCount] = useState(0);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||||
|
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||||
|
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 우측 탭
|
||||||
|
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
|
||||||
|
const [inspections, setInspections] = useState<any[]>([]);
|
||||||
|
const [inspectionLoading, setInspectionLoading] = useState(false);
|
||||||
|
const [consumables, setConsumables] = useState<any[]>([]);
|
||||||
|
const [consumableLoading, setConsumableLoading] = useState(false);
|
||||||
|
|
||||||
|
// 카테고리
|
||||||
|
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||||
|
|
||||||
|
// 모달
|
||||||
|
const [equipModalOpen, setEquipModalOpen] = useState(false);
|
||||||
|
const [equipEditMode, setEquipEditMode] = useState(false);
|
||||||
|
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 기본정보 탭 편집 폼
|
||||||
|
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
|
||||||
|
const [infoSaving, setInfoSaving] = useState(false);
|
||||||
|
|
||||||
|
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
|
||||||
|
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||||
|
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
|
||||||
|
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// 점검항목 복사
|
||||||
|
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||||
|
const [copySourceEquip, setCopySourceEquip] = useState("");
|
||||||
|
const [copyItems, setCopyItems] = useState<any[]>([]);
|
||||||
|
const [copyChecked, setCopyChecked] = useState<Set<string>>(new Set());
|
||||||
|
const [copyLoading, setCopyLoading] = useState(false);
|
||||||
|
|
||||||
|
// 엑셀
|
||||||
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||||
|
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
|
||||||
|
const [excelDetecting, setExcelDetecting] = useState(false);
|
||||||
|
|
||||||
|
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||||
|
setFilterConfig(settings.filters);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = loadTableSettings("equipment-info");
|
||||||
|
if (saved) applyTableSettings(saved);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 카테고리 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||||
|
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<string, any>();
|
||||||
|
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) => (
|
||||||
|
<Select value={value || ""} onValueChange={onChange}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(catOptions[key] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
<DynamicSearchFilter tableName={EQUIP_TABLE} filterId="equipment-info" onFilterChange={setSearchFilters} dataCount={equipCount}
|
||||||
|
externalFilterConfig={filterConfig}
|
||||||
|
extraActions={
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||||
|
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
|
||||||
|
onClick={async () => {
|
||||||
|
setExcelDetecting(true);
|
||||||
|
try {
|
||||||
|
const r = await autoDetectMultiTableConfig(EQUIP_TABLE);
|
||||||
|
if (r.success && r.data) { setExcelChainConfig(r.data); setExcelUploadOpen(true); }
|
||||||
|
else toast.error("테이블 구조 분석 실패");
|
||||||
|
} catch { toast.error("오류"); } finally { setExcelDetecting(false); }
|
||||||
|
}}>
|
||||||
|
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />} 엑셀 업로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
{/* 좌측: 설비 목록 */}
|
||||||
|
<ResizablePanel defaultSize={40} minSize={25}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Wrench className="w-4 h-4" /> 설비 목록 <Badge variant="secondary" className="font-normal">{equipCount}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button size="sm" onClick={openEquipRegister}><Plus className="w-3.5 h-3.5 mr-1" /> 등록</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> 수정</Button>
|
||||||
|
<Button variant="destructive" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataGrid gridId="equip-left" columns={LEFT_COLUMNS} data={equipments} loading={equipLoading}
|
||||||
|
selectedId={selectedEquipId} onSelect={setSelectedEquipId} onRowDoubleClick={() => openEquipEdit()}
|
||||||
|
emptyMessage="등록된 설비가 없습니다" />
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 탭 */}
|
||||||
|
<ResizablePanel defaultSize={60} minSize={30}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-2 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
|
||||||
|
<button key={tab} onClick={() => setRightTab(tab)}
|
||||||
|
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1",
|
||||||
|
rightTab === tab ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />{label}
|
||||||
|
{tab === "inspection" && inspections.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{inspections.length}</Badge>}
|
||||||
|
{tab === "consumable" && consumables.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{consumables.length}</Badge>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{selectedEquip && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedEquip.equipment_name}</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{rightTab === "inspection" && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionModalOpen(true); }}>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
|
||||||
|
<Copy className="w-3.5 h-3.5 mr-1" /> 복사
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{rightTab === "consumable" && (
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); loadConsumableItems(); setConsumableModalOpen(true); }}>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedEquipId ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">좌측에서 설비를 선택하세요</div>
|
||||||
|
) : rightTab === "info" ? (
|
||||||
|
<div className="p-4 overflow-auto">
|
||||||
|
<div className="flex justify-end mb-3">
|
||||||
|
<Button size="sm" onClick={handleInfoSave} disabled={infoSaving}>
|
||||||
|
{infoSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm text-muted-foreground">설비코드</Label>
|
||||||
|
<Input value={infoForm.equipment_code || ""} className="h-9 bg-muted/50" disabled />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">설비명</Label>
|
||||||
|
<Input value={infoForm.equipment_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">설비유형</Label>
|
||||||
|
{catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">설치장소</Label>
|
||||||
|
<Input value={infoForm.installation_location || ""} onChange={(e) => setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">제조사</Label>
|
||||||
|
<Input value={infoForm.manufacturer || ""} onChange={(e) => setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">모델명</Label>
|
||||||
|
<Input value={infoForm.model_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">도입일자</Label>
|
||||||
|
<FormDatePicker value={infoForm.introduction_date || ""} onChange={(v) => setInfoForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">가동상태</Label>
|
||||||
|
{catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<Label className="text-sm">비고</Label>
|
||||||
|
<Input value={infoForm.remarks || ""} onChange={(e) => setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 col-span-2">
|
||||||
|
<Label className="text-sm">이미지</Label>
|
||||||
|
<ImageUpload value={infoForm.image_path} onChange={(v) => setInfoForm((p) => ({ ...p, image_path: v }))}
|
||||||
|
tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : rightTab === "inspection" ? (
|
||||||
|
<DataGrid gridId="equip-inspection" columns={INSPECTION_COLUMNS} data={inspections} loading={inspectionLoading}
|
||||||
|
showRowNumber={false} tableName={INSPECTION_TABLE} emptyMessage="점검항목이 없습니다"
|
||||||
|
onCellEdit={() => refreshRight()} />
|
||||||
|
) : (
|
||||||
|
<DataGrid gridId="equip-consumable" columns={CONSUMABLE_COLUMNS} data={consumables} loading={consumableLoading}
|
||||||
|
showRowNumber={false} tableName={CONSUMABLE_TABLE} emptyMessage="소모품이 없습니다"
|
||||||
|
onCellEdit={() => refreshRight()} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설비 등록/수정 모달 */}
|
||||||
|
<FullscreenDialog open={equipModalOpen} onOpenChange={setEquipModalOpen}
|
||||||
|
title={equipEditMode ? "설비 수정" : "설비 등록"} description={equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}
|
||||||
|
defaultMaxWidth="max-w-2xl"
|
||||||
|
footer={<><Button variant="outline" onClick={() => setEquipModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button></>}>
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">설비코드</Label>
|
||||||
|
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">설비명 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">설비유형</Label>
|
||||||
|
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">가동상태</Label>
|
||||||
|
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">설치장소</Label>
|
||||||
|
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">제조사</Label>
|
||||||
|
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">모델명</Label>
|
||||||
|
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">도입일자</Label>
|
||||||
|
<FormDatePicker value={equipForm.introduction_date || ""} onChange={(v) => setEquipForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" /></div>
|
||||||
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">비고</Label>
|
||||||
|
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">이미지</Label>
|
||||||
|
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
|
||||||
|
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
|
||||||
|
</div>
|
||||||
|
</FullscreenDialog>
|
||||||
|
|
||||||
|
{/* 점검항목 추가 모달 */}
|
||||||
|
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader><DialogTitle>점검항목 추가</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 점검항목을 추가합니다.</DialogDescription></DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">점검항목 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" /></div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">점검주기</Label>
|
||||||
|
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">점검방법</Label>
|
||||||
|
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">하한치</Label>
|
||||||
|
<Input value={inspectionForm.lower_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">상한치</Label>
|
||||||
|
<Input value={inspectionForm.upper_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">단위</Label>
|
||||||
|
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">점검내용</Label>
|
||||||
|
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter><Button variant="outline" onClick={() => setInspectionModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 소모품 추가 모달 */}
|
||||||
|
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader><DialogTitle>소모품 추가</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 소모품을 추가합니다.</DialogDescription></DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">소모품명 <span className="text-destructive">*</span></Label>
|
||||||
|
{consumableItemOptions.length > 0 ? (
|
||||||
|
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
|
||||||
|
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
|
||||||
|
setConsumableForm((p) => ({
|
||||||
|
...p,
|
||||||
|
consumable_name: v,
|
||||||
|
specification: item?.size || p.specification || "",
|
||||||
|
unit: item?.unit || p.unit || "",
|
||||||
|
manufacturer: item?.manufacturer || p.manufacturer || "",
|
||||||
|
}));
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="소모품 선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{consumableItemOptions.map((item) => (
|
||||||
|
<SelectItem key={item.id} value={item.item_name || item.item_number}>
|
||||||
|
{item.item_name}{item.size ? ` (${item.size})` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Input value={consumableForm.consumable_name || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))}
|
||||||
|
placeholder="소모품명 직접 입력" className="h-9" />
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">품목정보에 소모품 타입 품목을 등록하면 선택 가능합니다</p>
|
||||||
|
</div>
|
||||||
|
)}</div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">교체주기</Label>
|
||||||
|
<Input value={consumableForm.replacement_cycle || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">단위</Label>
|
||||||
|
<Input value={consumableForm.unit || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">규격</Label>
|
||||||
|
<Input value={consumableForm.specification || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5"><Label className="text-sm">제조사</Label>
|
||||||
|
<Input value={consumableForm.manufacturer || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
|
||||||
|
<div className="space-y-1.5 col-span-2"><Label className="text-sm">이미지</Label>
|
||||||
|
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
|
||||||
|
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter><Button variant="outline" onClick={() => setConsumableModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button></DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 점검항목 복사 모달 */}
|
||||||
|
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[70vh]">
|
||||||
|
<DialogHeader><DialogTitle>점검항목 복사</DialogTitle>
|
||||||
|
<DialogDescription>다른 설비의 점검항목을 선택하여 {selectedEquip?.equipment_name}에 복사합니다.</DialogDescription></DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">소스 설비 선택</Label>
|
||||||
|
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="복사할 설비 선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{equipments.filter((e) => e.equipment_code !== selectedEquip?.equipment_code).map((e) => (
|
||||||
|
<SelectItem key={e.equipment_code} value={e.equipment_code}>{e.equipment_name} ({e.equipment_code})</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg overflow-auto max-h-[300px]">
|
||||||
|
{copyLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||||
|
) : copyItems.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없습니다" : "설비를 선택하세요"}</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
|
||||||
|
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>점검항목</TableHead><TableHead className="w-[80px]">점검주기</TableHead>
|
||||||
|
<TableHead className="w-[80px]">점검방법</TableHead><TableHead className="w-[70px]">하한</TableHead>
|
||||||
|
<TableHead className="w-[70px]">상한</TableHead><TableHead className="w-[60px]">단위</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{copyItems.map((item) => (
|
||||||
|
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
|
||||||
|
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
|
||||||
|
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
|
||||||
|
<TableCell className="text-sm">{item.inspection_item}</TableCell>
|
||||||
|
<TableCell className="text-xs">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
||||||
|
<TableCell className="text-xs">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
||||||
|
<TableCell className="text-xs">{item.lower_limit || "-"}</TableCell>
|
||||||
|
<TableCell className="text-xs">{item.upper_limit || "-"}</TableCell>
|
||||||
|
<TableCell className="text-xs">{item.unit || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex items-center gap-2 w-full justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">{copyChecked.size}개 선택됨</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setCopyModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleCopyApply} disabled={saving || copyChecked.size === 0}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Copy className="w-4 h-4 mr-1.5" />} 복사 적용
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 엑셀 업로드 (멀티테이블) */}
|
||||||
|
{excelChainConfig && (
|
||||||
|
<MultiTableExcelUploadModal open={excelUploadOpen}
|
||||||
|
onOpenChange={(open) => { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }}
|
||||||
|
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TableSettingsModal
|
||||||
|
open={tableSettingsOpen}
|
||||||
|
onOpenChange={setTableSettingsOpen}
|
||||||
|
tableName={EQUIP_TABLE}
|
||||||
|
settingsId="equipment-info"
|
||||||
|
onSave={applyTableSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,597 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useMemo, 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 { Card, CardContent } from "@/components/ui/card";
|
||||||
|
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,
|
||||||
|
RotateCcw,
|
||||||
|
Package,
|
||||||
|
ClipboardList,
|
||||||
|
Factory,
|
||||||
|
MapPin,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
getWorkOrders,
|
||||||
|
getMaterialStatus,
|
||||||
|
getWarehouses,
|
||||||
|
type WorkOrder,
|
||||||
|
type MaterialData,
|
||||||
|
type WarehouseData,
|
||||||
|
} from "@/lib/api/materialStatus";
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
planned: "계획",
|
||||||
|
in_progress: "진행중",
|
||||||
|
completed: "완료",
|
||||||
|
pending: "대기",
|
||||||
|
cancelled: "취소",
|
||||||
|
};
|
||||||
|
return map[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusStyle = (status: string) => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
planned: "bg-amber-100 text-amber-700 border-amber-200",
|
||||||
|
pending: "bg-amber-100 text-amber-700 border-amber-200",
|
||||||
|
in_progress: "bg-blue-100 text-blue-700 border-blue-200",
|
||||||
|
completed: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||||
|
cancelled: "bg-gray-100 text-gray-500 border-gray-200",
|
||||||
|
};
|
||||||
|
return map[status] || "bg-gray-100 text-gray-500 border-gray-200";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MaterialStatusPage() {
|
||||||
|
const today = new Date();
|
||||||
|
const monthAgo = new Date(today);
|
||||||
|
monthAgo.setMonth(today.getMonth() - 1);
|
||||||
|
|
||||||
|
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
|
||||||
|
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
|
||||||
|
const [searchItemCode, setSearchItemCode] = useState("");
|
||||||
|
const [searchItemName, setSearchItemName] = useState("");
|
||||||
|
|
||||||
|
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||||
|
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
|
||||||
|
const [checkedWoIds, setCheckedWoIds] = useState<number[]>([]);
|
||||||
|
const [selectedWoId, setSelectedWoId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
|
||||||
|
const [warehouse, setWarehouse] = useState("");
|
||||||
|
const [materialSearch, setMaterialSearch] = useState("");
|
||||||
|
const [showShortageOnly, setShowShortageOnly] = useState(false);
|
||||||
|
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
||||||
|
const [materialsLoading, setMaterialsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 창고 목록 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const res = await getWarehouses();
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setWarehouses(res.data);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 작업지시 검색
|
||||||
|
const handleSearch = useCallback(async () => {
|
||||||
|
setWorkOrdersLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getWorkOrders({
|
||||||
|
dateFrom: searchDateFrom,
|
||||||
|
dateTo: searchDateTo,
|
||||||
|
itemCode: searchItemCode || undefined,
|
||||||
|
itemName: searchItemName || undefined,
|
||||||
|
});
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setWorkOrders(res.data);
|
||||||
|
setCheckedWoIds([]);
|
||||||
|
setSelectedWoId(null);
|
||||||
|
setMaterials([]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setWorkOrdersLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchDateFrom, searchDateTo, searchItemCode, searchItemName]);
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
handleSearch();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isAllChecked =
|
||||||
|
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
|
||||||
|
|
||||||
|
const handleCheckAll = useCallback(
|
||||||
|
(checked: boolean) => {
|
||||||
|
setCheckedWoIds(checked ? workOrders.map((wo) => wo.id) : []);
|
||||||
|
},
|
||||||
|
[workOrders]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCheckWo = useCallback((id: number, checked: boolean) => {
|
||||||
|
setCheckedWoIds((prev) =>
|
||||||
|
checked ? [...prev, id] : prev.filter((i) => i !== id)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectWo = useCallback((id: number) => {
|
||||||
|
setSelectedWoId((prev) => (prev === id ? null : id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 선택된 작업지시의 자재 조회
|
||||||
|
const handleLoadSelectedMaterials = useCallback(async () => {
|
||||||
|
if (checkedWoIds.length === 0) {
|
||||||
|
alert("자재를 조회할 작업지시를 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMaterialsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getMaterialStatus({
|
||||||
|
planIds: checkedWoIds,
|
||||||
|
warehouseCode: warehouse || undefined,
|
||||||
|
});
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setMaterials(res.data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setMaterialsLoading(false);
|
||||||
|
}
|
||||||
|
}, [checkedWoIds, warehouse]);
|
||||||
|
|
||||||
|
const handleResetSearch = useCallback(() => {
|
||||||
|
const t = new Date();
|
||||||
|
const m = new Date(t);
|
||||||
|
m.setMonth(t.getMonth() - 1);
|
||||||
|
setSearchDateFrom(formatDate(m));
|
||||||
|
setSearchDateTo(formatDate(t));
|
||||||
|
setSearchItemCode("");
|
||||||
|
setSearchItemName("");
|
||||||
|
setMaterialSearch("");
|
||||||
|
setShowShortageOnly(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredMaterials = useMemo(() => {
|
||||||
|
return materials.filter((m) => {
|
||||||
|
const searchLower = materialSearch.toLowerCase();
|
||||||
|
const matchesSearch =
|
||||||
|
!materialSearch ||
|
||||||
|
m.code.toLowerCase().includes(searchLower) ||
|
||||||
|
m.name.toLowerCase().includes(searchLower);
|
||||||
|
const matchesShortage = !showShortageOnly || m.current < m.required;
|
||||||
|
return matchesSearch && matchesShortage;
|
||||||
|
});
|
||||||
|
}, [materials, materialSearch, showShortageOnly]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-4rem)] flex-col gap-4 bg-muted/30 p-4">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Package className="h-7 w-7 text-primary" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">자재현황</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
작업지시 대비 원자재 재고 현황
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 영역 */}
|
||||||
|
<Card className="shrink-0">
|
||||||
|
<CardContent className="flex flex-wrap items-end gap-3 p-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">기간</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-9 w-[140px]"
|
||||||
|
value={searchDateFrom}
|
||||||
|
onChange={(e) => setSearchDateFrom(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">~</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="h-9 w-[140px]"
|
||||||
|
value={searchDateTo}
|
||||||
|
onChange={(e) => setSearchDateTo(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">품목코드</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="품목코드"
|
||||||
|
className="h-9 w-[140px]"
|
||||||
|
value={searchItemCode}
|
||||||
|
onChange={(e) => setSearchItemCode(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">품목명</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="품목명"
|
||||||
|
className="h-9 w-[140px]"
|
||||||
|
value={searchItemName}
|
||||||
|
onChange={(e) => setSearchItemName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-9"
|
||||||
|
onClick={handleResetSearch}
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-9"
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={workOrdersLoading}
|
||||||
|
>
|
||||||
|
{workOrdersLoading ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
검색
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 (좌우 분할) */}
|
||||||
|
<div className="flex-1 overflow-hidden rounded-lg border bg-background shadow-sm">
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
{/* 왼쪽: 작업지시 리스트 */}
|
||||||
|
<ResizablePanel defaultSize={35} minSize={25}>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 패널 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b bg-muted/10 p-3 shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={isAllChecked}
|
||||||
|
onCheckedChange={handleCheckAll}
|
||||||
|
/>
|
||||||
|
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-semibold">작업지시 리스트</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="font-normal">
|
||||||
|
{workOrders.length}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={handleLoadSelectedMaterials}
|
||||||
|
disabled={materialsLoading}
|
||||||
|
>
|
||||||
|
{materialsLoading ? (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
자재조회
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업지시 목록 */}
|
||||||
|
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||||||
|
{workOrdersLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
작업지시를 조회하고 있습니다...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : workOrders.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<ClipboardList className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
작업지시가 없습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
workOrders.map((wo) => (
|
||||||
|
<div
|
||||||
|
key={wo.id}
|
||||||
|
className={cn(
|
||||||
|
"flex gap-3 rounded-lg border-2 p-3 transition-all cursor-pointer",
|
||||||
|
"hover:border-primary hover:shadow-md hover:-translate-y-0.5",
|
||||||
|
selectedWoId === wo.id
|
||||||
|
? "border-primary bg-primary/5 shadow-md"
|
||||||
|
: "border-border"
|
||||||
|
)}
|
||||||
|
onClick={() => handleSelectWo(wo.id)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-start pt-0.5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checkedWoIds.includes(wo.id)}
|
||||||
|
onCheckedChange={(c) =>
|
||||||
|
handleCheckWo(wo.id, c as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-primary">
|
||||||
|
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
|
||||||
|
getStatusStyle(wo.status)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getStatusLabel(wo.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{wo.item_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({wo.item_code})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<span>수량:</span>
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{Number(wo.plan_qty).toLocaleString()}개
|
||||||
|
</span>
|
||||||
|
<span className="mx-1">|</span>
|
||||||
|
<span>일자:</span>
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{wo.plan_date
|
||||||
|
? new Date(wo.plan_date)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10)
|
||||||
|
: "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 오른쪽: 원자재 현황 */}
|
||||||
|
<ResizablePanel defaultSize={65} minSize={35}>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 패널 헤더 */}
|
||||||
|
<div className="flex items-center gap-2 border-b bg-muted/10 p-3 shrink-0">
|
||||||
|
<Factory className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-semibold">원자재 재고 현황</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/5 px-4 py-3 shrink-0">
|
||||||
|
<Input
|
||||||
|
placeholder="원자재 검색"
|
||||||
|
className="h-9 min-w-[150px] flex-1"
|
||||||
|
value={materialSearch}
|
||||||
|
onChange={(e) => setMaterialSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select value={warehouse} onValueChange={setWarehouse}>
|
||||||
|
<SelectTrigger className="h-9 w-[200px]">
|
||||||
|
<SelectValue placeholder="전체 창고" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">전체 창고</SelectItem>
|
||||||
|
{warehouses.map((wh) => (
|
||||||
|
<SelectItem
|
||||||
|
key={wh.warehouse_code}
|
||||||
|
value={wh.warehouse_code}
|
||||||
|
>
|
||||||
|
{wh.warehouse_name}
|
||||||
|
{wh.warehouse_type
|
||||||
|
? ` (${wh.warehouse_type})`
|
||||||
|
: ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium">
|
||||||
|
<Checkbox
|
||||||
|
checked={showShortageOnly}
|
||||||
|
onCheckedChange={(c) => setShowShortageOnly(c as boolean)}
|
||||||
|
/>
|
||||||
|
<span>부족한 것만 보기</span>
|
||||||
|
</label>
|
||||||
|
<span className="ml-auto text-sm font-semibold text-muted-foreground">
|
||||||
|
{filteredMaterials.length}개 품목
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 원자재 목록 */}
|
||||||
|
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||||||
|
{materialsLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
자재현황을 조회하고 있습니다...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : materials.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
작업지시를 선택하고 자재조회 버튼을 클릭해주세요
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : filteredMaterials.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
조회된 원자재가 없습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredMaterials.map((material) => {
|
||||||
|
const shortage = material.required - material.current;
|
||||||
|
const isShortage = shortage > 0;
|
||||||
|
const percentage =
|
||||||
|
material.required > 0
|
||||||
|
? Math.min(
|
||||||
|
(material.current / material.required) * 100,
|
||||||
|
100
|
||||||
|
)
|
||||||
|
: 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={material.code}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border-2 p-3 transition-all hover:shadow-md hover:-translate-y-0.5",
|
||||||
|
isShortage
|
||||||
|
? "border-destructive/40 bg-destructive/2"
|
||||||
|
: "border-emerald-300/50 bg-emerald-50/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 메인 정보 라인 */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-bold">
|
||||||
|
{material.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({material.code})
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
필요:
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-blue-600">
|
||||||
|
{material.required.toLocaleString()}
|
||||||
|
{material.unit}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
현재:
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-semibold",
|
||||||
|
isShortage
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{material.current.toLocaleString()}
|
||||||
|
{material.unit}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{isShortage ? "부족:" : "여유:"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-semibold",
|
||||||
|
isShortage
|
||||||
|
? "text-destructive"
|
||||||
|
: "text-emerald-600"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Math.abs(shortage).toLocaleString()}
|
||||||
|
{material.unit}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">
|
||||||
|
({percentage.toFixed(0)}%)
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{isShortage ? (
|
||||||
|
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-destructive bg-destructive/10 px-2 py-0.5 text-[11px] font-semibold text-destructive">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
부족
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-emerald-500 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
충분
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 위치별 재고 */}
|
||||||
|
{material.locations.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{material.locations.map((loc, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
<span className="font-semibold font-mono text-primary">
|
||||||
|
{loc.location || loc.warehouse}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{loc.qty.toLocaleString()}
|
||||||
|
{material.unit}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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<string, string> = {
|
||||||
|
BOX: "박스", PACK: "팩", CANBOARD: "캔보드", AIRCAP: "에어캡",
|
||||||
|
ZIPCOS: "집코스", CYLINDER: "원통형", POLYCARTON: "포리/카톤",
|
||||||
|
};
|
||||||
|
const LOADING_TYPE_LABEL: Record<string, string> = {
|
||||||
|
PALLET: "파렛트", WOOD_PALLET: "목재파렛트", PLASTIC_PALLET: "플라스틱파렛트",
|
||||||
|
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
|
||||||
|
CAGE: "케이지", ETC: "기타",
|
||||||
|
};
|
||||||
|
const STATUS_LABEL: Record<string, string> = { 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<PkgUnit[]>([]);
|
||||||
|
const [pkgLoading, setPkgLoading] = useState(false);
|
||||||
|
const [selectedPkg, setSelectedPkg] = useState<PkgUnit | null>(null);
|
||||||
|
const [pkgItems, setPkgItems] = useState<PkgUnitItem[]>([]);
|
||||||
|
const [pkgItemsLoading, setPkgItemsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 적재함 데이터
|
||||||
|
const [loadingUnits, setLoadingUnits] = useState<LoadingUnit[]>([]);
|
||||||
|
const [loadingLoading, setLoadingLoading] = useState(false);
|
||||||
|
const [selectedLoading, setSelectedLoading] = useState<LoadingUnit | null>(null);
|
||||||
|
const [loadingPkgs, setLoadingPkgs] = useState<LoadingUnitPkg[]>([]);
|
||||||
|
const [loadingPkgsLoading, setLoadingPkgsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 모달
|
||||||
|
const [pkgModalOpen, setPkgModalOpen] = useState(false);
|
||||||
|
const [pkgModalMode, setPkgModalMode] = useState<"create" | "edit">("create");
|
||||||
|
const [pkgForm, setPkgForm] = useState<Record<string, any>>({});
|
||||||
|
const [pkgItemOptions, setPkgItemOptions] = useState<ItemInfoForPkg[]>([]);
|
||||||
|
const [pkgItemPopoverOpen, setPkgItemPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||||
|
const [loadModalMode, setLoadModalMode] = useState<"create" | "edit">("create");
|
||||||
|
const [loadForm, setLoadForm] = useState<Record<string, any>>({});
|
||||||
|
const [loadItemOptions, setLoadItemOptions] = useState<ItemInfoForPkg[]>([]);
|
||||||
|
const [loadItemPopoverOpen, setLoadItemPopoverOpen] = useState(false);
|
||||||
|
|
||||||
|
const [itemMatchModalOpen, setItemMatchModalOpen] = useState(false);
|
||||||
|
const [itemMatchKeyword, setItemMatchKeyword] = useState("");
|
||||||
|
const [itemMatchResults, setItemMatchResults] = useState<ItemInfoForPkg[]>([]);
|
||||||
|
const [itemMatchSelected, setItemMatchSelected] = useState<ItemInfoForPkg | null>(null);
|
||||||
|
const [itemMatchQty, setItemMatchQty] = useState(1);
|
||||||
|
|
||||||
|
const [pkgMatchModalOpen, setPkgMatchModalOpen] = useState(false);
|
||||||
|
const [pkgMatchQty, setPkgMatchQty] = useState(1);
|
||||||
|
const [pkgMatchMethod, setPkgMatchMethod] = useState("");
|
||||||
|
const [pkgMatchSelected, setPkgMatchSelected] = useState<PkgUnit | null>(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 (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-4">
|
||||||
|
{/* 검색 바 */}
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border bg-card p-3">
|
||||||
|
<Input
|
||||||
|
placeholder="포장코드 / 포장명 / 적재함명 검색"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
className="h-9 w-[280px] text-xs"
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setSearchKeyword("")} className="h-9">
|
||||||
|
<RotateCcw className="mr-1 h-4 w-4" /> 초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 */}
|
||||||
|
<div className="flex gap-1 border-b">
|
||||||
|
{([["packing", "포장재 관리", filteredPkgUnits.length] as const, ["loading", "적재함 관리", filteredLoadingUnits.length] as const]).map(([tab, label, count]) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
|
||||||
|
activeTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab === "packing" ? <Package className="h-4 w-4" /> : <Box className="h-4 w-4" />}
|
||||||
|
{label}
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5">{count}</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 탭 콘텐츠 */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
{activeTab === "packing" ? (
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||||
|
{/* 좌측: 포장재 목록 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={30}>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||||
|
<span className="text-sm font-semibold">포장재 목록 <span className="text-muted-foreground font-normal">({filteredPkgUnits.length}건)</span></span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("create")}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" /> 등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-[11px] bg-muted/50">
|
||||||
|
<TableHead className="p-2">품목코드</TableHead>
|
||||||
|
<TableHead className="p-2">포장명</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px]">유형</TableHead>
|
||||||
|
<TableHead className="p-2 w-[90px]">크기(mm)</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px] text-right">최대중량</TableHead>
|
||||||
|
<TableHead className="p-2 w-[55px] text-center">상태</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pkgLoading ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
||||||
|
) : filteredPkgUnits.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs">등록된 포장재가 없습니다</TableCell></TableRow>
|
||||||
|
) : filteredPkgUnits.map((p) => (
|
||||||
|
<TableRow
|
||||||
|
key={p.id}
|
||||||
|
className={cn("cursor-pointer text-xs", selectedPkg?.id === p.id && "bg-primary/5")}
|
||||||
|
onClick={() => selectPkg(p)}
|
||||||
|
>
|
||||||
|
<TableCell className="p-2 font-medium truncate max-w-[100px]">{p.pkg_code}</TableCell>
|
||||||
|
<TableCell className="p-2 truncate max-w-[120px]">{p.pkg_name}</TableCell>
|
||||||
|
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
|
||||||
|
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-center">
|
||||||
|
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(p.status))}>{STATUS_LABEL[p.status] || p.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
{/* 우측: 상세 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
{!selectedPkg ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<Package className="h-12 w-12 opacity-20 mb-2" />
|
||||||
|
<p className="text-sm">좌측 목록에서 포장재를 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 요약 헤더 */}
|
||||||
|
<div className="flex items-center justify-between border-b bg-blue-50 dark:bg-blue-950/20 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Package className="h-5 w-5 text-blue-600" />
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-sm">{selectedPkg.pkg_name}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{selectedPkg.pkg_code} · {PKG_TYPE_LABEL[selectedPkg.pkg_type] || selectedPkg.pkg_type} · {fmtSize(selectedPkg.width_mm, selectedPkg.length_mm, selectedPkg.height_mm)}mm</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("edit")}>
|
||||||
|
<Edit2 className="mr-1 h-3 w-3" /> 수정
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeletePkg(selectedPkg)}>
|
||||||
|
<Trash2 className="mr-1 h-3 w-3" /> 삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 매칭 품목 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">매칭 품목 ({pkgItems.length}건)</span>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openItemMatchModal}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" /> 품목 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{pkgItemsLoading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
|
||||||
|
) : pkgItems.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs">매칭된 품목이 없습니다</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-[11px]">
|
||||||
|
<TableHead className="p-2">품목코드</TableHead>
|
||||||
|
<TableHead className="p-2">품목명</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px]">규격</TableHead>
|
||||||
|
<TableHead className="p-2 w-[50px]">단위</TableHead>
|
||||||
|
<TableHead className="p-2 w-[80px] text-right">포장수량</TableHead>
|
||||||
|
<TableHead className="p-2 w-[40px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{pkgItems.map((item) => (
|
||||||
|
<TableRow key={item.id} className="text-xs">
|
||||||
|
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
|
||||||
|
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2">{item.spec || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="p-2 text-center">
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
) : (
|
||||||
|
/* 적재함 관리 탭 */
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||||
|
<ResizablePanel defaultSize={45} minSize={30}>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||||
|
<span className="text-sm font-semibold">적재함 목록 <span className="text-muted-foreground font-normal">({filteredLoadingUnits.length}건)</span></span>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("create")}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" /> 등록
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-[11px] bg-muted/50">
|
||||||
|
<TableHead className="p-2">품목코드</TableHead>
|
||||||
|
<TableHead className="p-2">적재함명</TableHead>
|
||||||
|
<TableHead className="p-2 w-[80px]">유형</TableHead>
|
||||||
|
<TableHead className="p-2 w-[90px]">크기(mm)</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px] text-right">최대적재</TableHead>
|
||||||
|
<TableHead className="p-2 w-[55px] text-center">상태</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loadingLoading ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
||||||
|
) : filteredLoadingUnits.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs">등록된 적재함이 없습니다</TableCell></TableRow>
|
||||||
|
) : filteredLoadingUnits.map((l) => (
|
||||||
|
<TableRow
|
||||||
|
key={l.id}
|
||||||
|
className={cn("cursor-pointer text-xs", selectedLoading?.id === l.id && "bg-primary/5")}
|
||||||
|
onClick={() => selectLoading(l)}
|
||||||
|
>
|
||||||
|
<TableCell className="p-2 font-medium truncate max-w-[100px]">{l.loading_code}</TableCell>
|
||||||
|
<TableCell className="p-2 truncate max-w-[120px]">{l.loading_name}</TableCell>
|
||||||
|
<TableCell className="p-2">{LOADING_TYPE_LABEL[l.loading_type] || l.loading_type || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-[10px]">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
|
||||||
|
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-center">
|
||||||
|
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(l.status))}>{STATUS_LABEL[l.status] || l.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
{!selectedLoading ? (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||||
|
<Box className="h-12 w-12 opacity-20 mb-2" />
|
||||||
|
<p className="text-sm">좌측 목록에서 적재함을 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between border-b bg-green-50 dark:bg-green-950/20 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Box className="h-5 w-5 text-green-600" />
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-sm">{selectedLoading.loading_name}</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground">{selectedLoading.loading_code} · {LOADING_TYPE_LABEL[selectedLoading.loading_type] || selectedLoading.loading_type} · {fmtSize(selectedLoading.width_mm, selectedLoading.length_mm, selectedLoading.height_mm)}mm</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("edit")}><Edit2 className="mr-1 h-3 w-3" /> 수정</Button>
|
||||||
|
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeleteLoading(selectedLoading)}><Trash2 className="mr-1 h-3 w-3" /> 삭제</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">적재 가능 포장단위 ({loadingPkgs.length}건)</span>
|
||||||
|
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openPkgMatchModal}><Plus className="mr-1 h-3 w-3" /> 포장단위 추가</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loadingPkgsLoading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
|
||||||
|
) : loadingPkgs.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs">등록된 포장단위가 없습니다</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-[11px]">
|
||||||
|
<TableHead className="p-2">포장코드</TableHead>
|
||||||
|
<TableHead className="p-2">포장명</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px]">유형</TableHead>
|
||||||
|
<TableHead className="p-2 w-[80px] text-right">최대수량</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px]">적재방향</TableHead>
|
||||||
|
<TableHead className="p-2 w-[40px]" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loadingPkgs.map((lp) => (
|
||||||
|
<TableRow key={lp.id} className="text-xs">
|
||||||
|
<TableCell className="p-2 font-medium">{lp.pkg_code}</TableCell>
|
||||||
|
<TableCell className="p-2">{lp.pkg_name || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2">{PKG_TYPE_LABEL[lp.pkg_type || ""] || lp.pkg_type || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-right font-semibold">{Number(lp.max_load_qty).toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="p-2">{lp.load_method || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 text-center">
|
||||||
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeleteLoadPkg(lp)}><X className="h-3 w-3" /></Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 포장재 등록/수정 모달 */}
|
||||||
|
<FullscreenDialog open={pkgModalOpen} onOpenChange={setPkgModalOpen}
|
||||||
|
title={pkgModalMode === "create" ? "포장재 등록" : "포장재 수정"}
|
||||||
|
description="품목정보에서 포장재를 선택하면 코드와 이름이 자동 연동됩니다."
|
||||||
|
footer={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setPkgModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={savePkgUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} 저장</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
{/* 품목정보 연결 */}
|
||||||
|
{pkgModalMode === "create" && (
|
||||||
|
<div className="rounded-lg border bg-blue-50 dark:bg-blue-950/20 p-4">
|
||||||
|
<Label className="text-xs font-semibold mb-2 block">품목정보 연결 (구분: 포장재)</Label>
|
||||||
|
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
|
||||||
|
{pkgForm.pkg_code
|
||||||
|
? `${pkgForm.pkg_name} (${pkgForm.pkg_code})`
|
||||||
|
: "품목정보에서 포장재를 선택하세요"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<Command filter={(value, search) => {
|
||||||
|
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;
|
||||||
|
}}>
|
||||||
|
<CommandInput placeholder="품목코드 / 품목명 검색..." />
|
||||||
|
<CommandList className="max-h-[200px]">
|
||||||
|
<CommandEmpty>검색 결과가 없습니다</CommandEmpty>
|
||||||
|
{pkgItemOptions.map((item) => (
|
||||||
|
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="font-medium">{item.item_name}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
|
||||||
|
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><Label className="text-xs">품목코드</Label><Input value={pkgForm.pkg_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||||
|
<div><Label className="text-xs">포장명</Label><Input value={pkgForm.pkg_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">포장유형 <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={pkgForm.pkg_type || ""} onValueChange={(v) => setPkgForm((p) => ({ ...p, pkg_type: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>{Object.entries(PKG_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">상태</Label>
|
||||||
|
<Select value={pkgForm.status || "ACTIVE"} onValueChange={(v) => setPkgForm((p) => ({ ...p, status: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-semibold">규격정보</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mt-2">
|
||||||
|
<div><Label className="text-[10px]">가로(mm)</Label><Input type="number" value={pkgForm.width_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
<div><Label className="text-[10px]">세로(mm)</Label><Input type="number" value={pkgForm.length_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
<div><Label className="text-[10px]">높이(mm)</Label><Input type="number" value={pkgForm.height_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
<div><Label className="text-[10px]">자체중량(kg)</Label><Input type="number" value={pkgForm.self_weight_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||||
|
<div><Label className="text-[10px]">최대적재중량(kg)</Label><Input type="number" value={pkgForm.max_load_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||||
|
<div><Label className="text-[10px]">내용적(L)</Label><Input type="number" value={pkgForm.volume_l || ""} onChange={(e) => setPkgForm((p) => ({ ...p, volume_l: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><Label className="text-xs">비고</Label><Input value={pkgForm.remarks || ""} onChange={(e) => setPkgForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
|
||||||
|
</div>
|
||||||
|
</FullscreenDialog>
|
||||||
|
|
||||||
|
{/* 적재함 등록/수정 모달 */}
|
||||||
|
<FullscreenDialog open={loadModalOpen} onOpenChange={setLoadModalOpen}
|
||||||
|
title={loadModalMode === "create" ? "적재함 등록" : "적재함 수정"}
|
||||||
|
description="품목정보에서 적재함을 선택하면 코드와 이름이 자동 연동됩니다."
|
||||||
|
footer={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setLoadModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={saveLoadingUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} 저장</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4 p-6">
|
||||||
|
{loadModalMode === "create" && (
|
||||||
|
<div className="rounded-lg border bg-green-50 dark:bg-green-950/20 p-4">
|
||||||
|
<Label className="text-xs font-semibold mb-2 block">품목정보 연결 (구분: 적재함)</Label>
|
||||||
|
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
|
||||||
|
{loadForm.loading_code
|
||||||
|
? `${loadForm.loading_name} (${loadForm.loading_code})`
|
||||||
|
: "품목정보에서 적재함을 선택하세요"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||||
|
<Command filter={(value, search) => {
|
||||||
|
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;
|
||||||
|
}}>
|
||||||
|
<CommandInput placeholder="품목코드 / 품목명 검색..." />
|
||||||
|
<CommandList className="max-h-[200px]">
|
||||||
|
<CommandEmpty>검색 결과가 없습니다</CommandEmpty>
|
||||||
|
{loadItemOptions.map((item) => (
|
||||||
|
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
|
||||||
|
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
|
||||||
|
<span className="font-medium">{item.item_name}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
|
||||||
|
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><Label className="text-xs">적재함코드</Label><Input value={loadForm.loading_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||||
|
<div><Label className="text-xs">적재함명</Label><Input value={loadForm.loading_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">적재유형 <span className="text-destructive">*</span></Label>
|
||||||
|
<Select value={loadForm.loading_type || ""} onValueChange={(v) => setLoadForm((p) => ({ ...p, loading_type: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||||
|
<SelectContent>{Object.entries(LOADING_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">상태</Label>
|
||||||
|
<Select value={loadForm.status || "ACTIVE"} onValueChange={(v) => setLoadForm((p) => ({ ...p, status: v }))}>
|
||||||
|
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs font-semibold">규격정보</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mt-2">
|
||||||
|
<div><Label className="text-[10px]">가로(mm)</Label><Input type="number" value={loadForm.width_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
<div><Label className="text-[10px]">세로(mm)</Label><Input type="number" value={loadForm.length_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
<div><Label className="text-[10px]">높이(mm)</Label><Input type="number" value={loadForm.height_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
<div><Label className="text-[10px]">자체중량(kg)</Label><Input type="number" value={loadForm.self_weight_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||||
|
<div><Label className="text-[10px]">최대적재중량(kg)</Label><Input type="number" value={loadForm.max_load_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||||
|
<div><Label className="text-[10px]">최대단수</Label><Input type="number" value={loadForm.max_stack || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_stack: e.target.value }))} className="h-8 text-xs" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><Label className="text-xs">비고</Label><Input value={loadForm.remarks || ""} onChange={(e) => setLoadForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
|
||||||
|
</div>
|
||||||
|
</FullscreenDialog>
|
||||||
|
|
||||||
|
{/* 품목 추가 모달 (포장재 매칭) */}
|
||||||
|
<Dialog open={itemMatchModalOpen} onOpenChange={setItemMatchModalOpen}>
|
||||||
|
<DialogContent className="max-w-[900px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>품목 추가 — {selectedPkg?.pkg_name}</DialogTitle>
|
||||||
|
<DialogDescription>포장재에 매칭할 품목을 검색하여 추가합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input placeholder="품목코드 / 품목명 검색 (입력 시 자동 검색)" value={itemMatchKeyword}
|
||||||
|
onChange={(e) => {
|
||||||
|
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" />
|
||||||
|
<div className="max-h-[300px] overflow-auto border rounded">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-[11px]">
|
||||||
|
<TableHead className="p-2 w-[30px]" />
|
||||||
|
<TableHead className="p-2 w-[130px]">품목코드</TableHead>
|
||||||
|
<TableHead className="p-2">품목명</TableHead>
|
||||||
|
<TableHead className="p-2 w-[100px]">규격</TableHead>
|
||||||
|
<TableHead className="p-2 w-[80px]">재질</TableHead>
|
||||||
|
<TableHead className="p-2 w-[50px]">단위</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16">검색 결과가 없습니다</TableCell></TableRow>
|
||||||
|
) : itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).map((item) => (
|
||||||
|
<TableRow key={item.id} className={cn("cursor-pointer text-xs", itemMatchSelected?.id === item.id && "bg-primary/10")}
|
||||||
|
onClick={() => setItemMatchSelected(item)}>
|
||||||
|
<TableCell className="p-2 text-center">{itemMatchSelected?.id === item.id ? "✓" : ""}</TableCell>
|
||||||
|
<TableCell className="p-2 font-medium truncate max-w-[130px]">{item.item_number}</TableCell>
|
||||||
|
<TableCell className="p-2 truncate max-w-[200px]">{item.item_name}</TableCell>
|
||||||
|
<TableCell className="p-2 truncate">{item.spec || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2 truncate">{item.material || "-"}</TableCell>
|
||||||
|
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-xs">선택된 품목</Label>
|
||||||
|
<Input value={itemMatchSelected ? `${itemMatchSelected.item_name} (${itemMatchSelected.item_number})` : ""} readOnly className="h-9 bg-muted text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="w-[120px]">
|
||||||
|
<Label htmlFor="pkg-item-match-qty" className="text-xs">포장수량(EA) <span className="text-destructive">*</span></Label>
|
||||||
|
<Input id="pkg-item-match-qty" type="number" value={itemMatchQty} onChange={(e) => setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setItemMatchModalOpen(false)}>취소</Button>
|
||||||
|
<Button type="button" data-action-type="custom" onClick={saveItemMatch} disabled={saving || !itemMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} 추가</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 포장단위 추가 모달 (적재함 구성) */}
|
||||||
|
<Dialog open={pkgMatchModalOpen} onOpenChange={setPkgMatchModalOpen}>
|
||||||
|
<DialogContent className="max-w-[800px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>포장단위 추가 — {selectedLoading?.loading_name}</DialogTitle>
|
||||||
|
<DialogDescription>적재함에 적재할 포장단위를 선택합니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Input
|
||||||
|
placeholder="포장코드 / 포장명 검색"
|
||||||
|
value={pkgMatchSearchKw}
|
||||||
|
onChange={(e) => setPkgMatchSearchKw(e.target.value)}
|
||||||
|
className="h-9 text-xs"
|
||||||
|
/>
|
||||||
|
<div className="max-h-[300px] overflow-auto border rounded">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="text-[11px]">
|
||||||
|
<TableHead className="p-2 w-[30px]" />
|
||||||
|
<TableHead className="p-2 w-[120px]">포장코드</TableHead>
|
||||||
|
<TableHead className="p-2">포장명</TableHead>
|
||||||
|
<TableHead className="p-2 w-[70px]">유형</TableHead>
|
||||||
|
<TableHead className="p-2 w-[100px]">크기(mm)</TableHead>
|
||||||
|
<TableHead className="p-2 w-[80px] text-right">최대중량</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(() => {
|
||||||
|
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 ? (
|
||||||
|
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16">추가 가능한 포장단위가 없습니다</TableCell></TableRow>
|
||||||
|
) : filtered.map((p) => (
|
||||||
|
<TableRow key={p.id} className={cn("cursor-pointer text-xs", pkgMatchSelected?.id === p.id && "bg-primary/10")}
|
||||||
|
onClick={() => setPkgMatchSelected(p)}>
|
||||||
|
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
|
||||||
|
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
|
||||||
|
<TableCell className="p-2">{p.pkg_name}</TableCell>
|
||||||
|
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
|
||||||
|
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
|
||||||
|
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<div className="w-[150px]">
|
||||||
|
<Label htmlFor="loading-pkg-match-qty" className="text-xs">최대적재수량 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input id="loading-pkg-match-qty" type="number" value={pkgMatchQty} onChange={(e) => setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label className="text-xs">적재방향</Label>
|
||||||
|
<Input value={pkgMatchMethod} onChange={(e) => setPkgMatchMethod(e.target.value)} placeholder="수직/수평/혼합" className="h-9 text-xs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setPkgMatchModalOpen(false)}>취소</Button>
|
||||||
|
<Button type="button" data-action-type="custom" onClick={savePkgMatch} disabled={saving || !pkgMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} 추가</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,498 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서관리 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* 좌측: 부서 목록 (dept_info)
|
||||||
|
* 우측: 선택한 부서의 인원 목록 (user_info)
|
||||||
|
*
|
||||||
|
* 모달: 부서 등록(dept_info), 사원 추가(user_info)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
import {
|
||||||
|
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||||
|
Building2, Users, Settings2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||||
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||||
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
|
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||||
|
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
|
||||||
|
|
||||||
|
const DEPT_TABLE = "dept_info";
|
||||||
|
const USER_TABLE = "user_info";
|
||||||
|
|
||||||
|
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
|
||||||
|
{ key: "dept_name", label: "부서명", minWidth: "min-w-[150px]" },
|
||||||
|
{ key: "parent_dept_code", label: "상위부서", width: "w-[100px]" },
|
||||||
|
{ key: "status", label: "상태", width: "w-[70px]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RIGHT_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "sabun", label: "사번", width: "w-[80px]" },
|
||||||
|
{ key: "user_name", label: "이름", width: "w-[90px]" },
|
||||||
|
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
|
||||||
|
{ key: "position_name", label: "직급", width: "w-[80px]" },
|
||||||
|
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]" },
|
||||||
|
{ key: "email", label: "이메일", minWidth: "min-w-[150px]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DepartmentPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||||
|
|
||||||
|
// 좌측: 부서
|
||||||
|
const [depts, setDepts] = useState<any[]>([]);
|
||||||
|
const [deptLoading, setDeptLoading] = useState(false);
|
||||||
|
const [deptCount, setDeptCount] = useState(0);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
|
||||||
|
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||||
|
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||||
|
|
||||||
|
// 우측: 사원
|
||||||
|
const [members, setMembers] = useState<any[]>([]);
|
||||||
|
const [memberLoading, setMemberLoading] = useState(false);
|
||||||
|
|
||||||
|
// 부서 모달
|
||||||
|
const [deptModalOpen, setDeptModalOpen] = useState(false);
|
||||||
|
const [deptEditMode, setDeptEditMode] = useState(false);
|
||||||
|
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 사원 모달
|
||||||
|
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||||
|
const [userEditMode, setUserEditMode] = useState(false);
|
||||||
|
const [userForm, setUserForm] = useState<Record<string, any>>({});
|
||||||
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// 엑셀
|
||||||
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||||
|
|
||||||
|
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||||
|
setFilterConfig(settings.filters);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = loadTableSettings("department");
|
||||||
|
if (saved) applyTableSettings(saved);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 부서 조회
|
||||||
|
const fetchDepts = useCallback(async () => {
|
||||||
|
setDeptLoading(true);
|
||||||
|
try {
|
||||||
|
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
|
||||||
|
page: 1, size: 500,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
// dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑
|
||||||
|
const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code }));
|
||||||
|
setDepts(data);
|
||||||
|
setDeptCount(res.data?.data?.total || data.length);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("부서 조회 실패:", err);
|
||||||
|
toast.error("부서 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setDeptLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchDepts(); }, [fetchDepts]);
|
||||||
|
|
||||||
|
// 선택된 부서
|
||||||
|
const selectedDept = depts.find((d) => d.id === selectedDeptId);
|
||||||
|
const selectedDeptCode = selectedDept?.dept_code || null;
|
||||||
|
|
||||||
|
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
|
||||||
|
const fetchMembers = useCallback(async () => {
|
||||||
|
setMemberLoading(true);
|
||||||
|
try {
|
||||||
|
const filters = selectedDeptCode
|
||||||
|
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
|
||||||
|
: [];
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
|
||||||
|
page: 1, size: 500,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
|
||||||
|
} catch { setMembers([]); } finally { setMemberLoading(false); }
|
||||||
|
}, [selectedDeptCode]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||||||
|
|
||||||
|
// 부서 등록
|
||||||
|
const openDeptRegister = () => {
|
||||||
|
setDeptForm({});
|
||||||
|
setDeptEditMode(false);
|
||||||
|
setDeptModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDeptEdit = () => {
|
||||||
|
if (!selectedDept) return;
|
||||||
|
setDeptForm({ ...selectedDept });
|
||||||
|
setDeptEditMode(true);
|
||||||
|
setDeptModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeptSave = async () => {
|
||||||
|
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (deptEditMode && deptForm.dept_code) {
|
||||||
|
await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, {
|
||||||
|
originalData: { dept_code: deptForm.dept_code },
|
||||||
|
updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null },
|
||||||
|
});
|
||||||
|
toast.success("수정되었습니다.");
|
||||||
|
} else {
|
||||||
|
await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, {
|
||||||
|
dept_code: deptForm.dept_code || "",
|
||||||
|
dept_name: deptForm.dept_name,
|
||||||
|
parent_dept_code: deptForm.parent_dept_code || null,
|
||||||
|
});
|
||||||
|
toast.success("등록되었습니다.");
|
||||||
|
}
|
||||||
|
setDeptModalOpen(false);
|
||||||
|
fetchDepts();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 부서 삭제
|
||||||
|
const handleDeptDelete = async () => {
|
||||||
|
if (!selectedDeptCode) return;
|
||||||
|
const ok = await confirm("부서를 삭제하시겠습니까?", {
|
||||||
|
description: "해당 부서에 소속된 사원 정보는 유지됩니다.",
|
||||||
|
variant: "destructive", confirmText: "삭제",
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, {
|
||||||
|
data: [{ dept_code: selectedDeptCode }],
|
||||||
|
});
|
||||||
|
toast.success("삭제되었습니다.");
|
||||||
|
setSelectedDeptId(null);
|
||||||
|
fetchDepts();
|
||||||
|
} catch { toast.error("삭제에 실패했습니다."); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사원 추가
|
||||||
|
const openUserModal = (editData?: any) => {
|
||||||
|
if (editData) {
|
||||||
|
setUserEditMode(true);
|
||||||
|
setUserForm({ ...editData, user_password: "" });
|
||||||
|
} else {
|
||||||
|
setUserEditMode(false);
|
||||||
|
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
|
||||||
|
}
|
||||||
|
setFormErrors({});
|
||||||
|
setUserModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserFormChange = (field: string, value: string) => {
|
||||||
|
const formatted = formatField(field, value);
|
||||||
|
setUserForm((prev) => ({ ...prev, [field]: formatted }));
|
||||||
|
const error = validateField(field, formatted);
|
||||||
|
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserSave = async () => {
|
||||||
|
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
|
||||||
|
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
|
||||||
|
const errors = validateForm(userForm, ["cell_phone", "email"]);
|
||||||
|
setFormErrors(errors);
|
||||||
|
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// 비밀번호 미입력 시 기본값 (신규만)
|
||||||
|
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
|
||||||
|
|
||||||
|
await apiClient.post("/admin/users/with-dept", {
|
||||||
|
userInfo: {
|
||||||
|
user_id: userForm.user_id,
|
||||||
|
user_name: userForm.user_name,
|
||||||
|
user_name_eng: userForm.user_name_eng || undefined,
|
||||||
|
user_password: password || undefined,
|
||||||
|
email: userForm.email || undefined,
|
||||||
|
tel: userForm.tel || undefined,
|
||||||
|
cell_phone: userForm.cell_phone || undefined,
|
||||||
|
sabun: userForm.sabun || undefined,
|
||||||
|
position_name: userForm.position_name || undefined,
|
||||||
|
dept_code: userForm.dept_code || undefined,
|
||||||
|
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
|
||||||
|
status: userForm.status || "active",
|
||||||
|
},
|
||||||
|
mainDept: userForm.dept_code ? {
|
||||||
|
dept_code: userForm.dept_code,
|
||||||
|
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
|
||||||
|
position_name: userForm.position_name || undefined,
|
||||||
|
} : undefined,
|
||||||
|
isUpdate: userEditMode,
|
||||||
|
});
|
||||||
|
toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다.");
|
||||||
|
setUserModalOpen(false);
|
||||||
|
fetchMembers();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 다운로드
|
||||||
|
const handleExcelDownload = async () => {
|
||||||
|
if (depts.length === 0) return;
|
||||||
|
const data = depts.map((d) => ({
|
||||||
|
부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status,
|
||||||
|
}));
|
||||||
|
await exportToExcel(data, "부서관리.xlsx", "부서");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={DEPT_TABLE}
|
||||||
|
filterId="department"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={deptCount}
|
||||||
|
externalFilterConfig={filterConfig}
|
||||||
|
extraActions={
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||||
|
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||||
|
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
{/* 좌측: 부서 */}
|
||||||
|
<ResizablePanel defaultSize={40} minSize={25}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Building2 className="w-4 h-4" /> 부서
|
||||||
|
<Badge variant="secondary" className="font-normal">{deptCount}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button size="sm" onClick={openDeptRegister}><Plus className="w-3.5 h-3.5 mr-1" /> 등록</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> 수정</Button>
|
||||||
|
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={handleDeptDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
gridId="dept-left"
|
||||||
|
columns={LEFT_COLUMNS}
|
||||||
|
data={depts}
|
||||||
|
loading={deptLoading}
|
||||||
|
selectedId={selectedDeptId}
|
||||||
|
onSelect={(id) => {
|
||||||
|
setSelectedDeptId((prev) => (prev === id ? null : id));
|
||||||
|
}}
|
||||||
|
onRowDoubleClick={() => openDeptEdit()}
|
||||||
|
emptyMessage="등록된 부서가 없습니다"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 사원 */}
|
||||||
|
<ResizablePanel defaultSize={60} minSize={30}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
{selectedDept ? "부서 인원" : "전체 사원"}
|
||||||
|
{selectedDept && <Badge variant="outline" className="font-normal">{selectedDept.dept_name}</Badge>}
|
||||||
|
{members.length > 0 && <Badge variant="secondary" className="font-normal">{members.length}명</Badge>}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" /> 사원 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
gridId="dept-right"
|
||||||
|
columns={RIGHT_COLUMNS}
|
||||||
|
data={members}
|
||||||
|
loading={memberLoading}
|
||||||
|
showRowNumber={false}
|
||||||
|
tableName={USER_TABLE}
|
||||||
|
emptyMessage={selectedDeptCode ? "소속 사원이 없습니다" : "등록된 사원이 없습니다"}
|
||||||
|
onRowDoubleClick={(row) => openUserModal(row)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 부서 등록/수정 모달 */}
|
||||||
|
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
|
||||||
|
<DialogDescription>{deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">부서코드</Label>
|
||||||
|
<Input value={deptForm.dept_code || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_code: e.target.value }))}
|
||||||
|
placeholder="부서코드" className="h-9" disabled={deptEditMode} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">부서명 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={deptForm.dept_name || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
|
||||||
|
placeholder="부서명" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">상위부서</Label>
|
||||||
|
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">없음</SelectItem>
|
||||||
|
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
|
||||||
|
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeptModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleDeptSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 사원 추가 모달 */}
|
||||||
|
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
|
||||||
|
<DialogDescription>{userEditMode ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">사용자 ID <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
|
||||||
|
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">이름 <span className="text-destructive">*</span></Label>
|
||||||
|
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
|
||||||
|
placeholder="이름" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">사번</Label>
|
||||||
|
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
|
||||||
|
placeholder="사번" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">비밀번호</Label>
|
||||||
|
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
|
||||||
|
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">직급</Label>
|
||||||
|
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
|
||||||
|
placeholder="직급" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">부서</Label>
|
||||||
|
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">휴대폰</Label>
|
||||||
|
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
|
||||||
|
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
|
||||||
|
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">이메일</Label>
|
||||||
|
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
|
||||||
|
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
|
||||||
|
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">입사일</Label>
|
||||||
|
<FormDatePicker value={userForm.regdate || ""} onChange={(v) => setUserForm((p) => ({ ...p, regdate: v }))} placeholder="입사일" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">퇴사일</Label>
|
||||||
|
<FormDatePicker value={userForm.end_date || ""} onChange={(v) => setUserForm((p) => ({ ...p, end_date: v }))} placeholder="퇴사일" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setUserModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleUserSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 엑셀 업로드 */}
|
||||||
|
<ExcelUploadModal
|
||||||
|
open={excelUploadOpen}
|
||||||
|
onOpenChange={setExcelUploadOpen}
|
||||||
|
tableName={DEPT_TABLE}
|
||||||
|
userId={user?.userId}
|
||||||
|
onSuccess={() => fetchDepts()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
|
||||||
|
<TableSettingsModal
|
||||||
|
open={tableSettingsOpen}
|
||||||
|
onOpenChange={setTableSettingsOpen}
|
||||||
|
tableName={DEPT_TABLE}
|
||||||
|
settingsId="department"
|
||||||
|
onSave={applyTableSettings}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<any[]>([]);
|
||||||
|
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<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
// 엑셀 업로드
|
||||||
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||||
|
|
||||||
|
// 카테고리 옵션 (API에서 로드)
|
||||||
|
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||||
|
|
||||||
|
// 선택된 행
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(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<string, { code: string; label: string }[]> = {};
|
||||||
|
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<string, any> = {};
|
||||||
|
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 (
|
||||||
|
<Select
|
||||||
|
value={formData[field.key] || ""}
|
||||||
|
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 text-sm">
|
||||||
|
<SelectValue placeholder={`${field.label} 선택`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<SelectItem key={opt.code} value={opt.code}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<Card className="shrink-0">
|
||||||
|
<CardContent className="p-4 flex flex-wrap items-end gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">품명/품목코드</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="검색"
|
||||||
|
className="w-[180px] h-9"
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && fetchItems()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">관리품목</Label>
|
||||||
|
<Select value={searchDivision} onValueChange={setSearchDivision}>
|
||||||
|
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{(categoryOptions["division"] || []).map((o) => (
|
||||||
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">품목구분</Label>
|
||||||
|
<Select value={searchType} onValueChange={setSearchType}>
|
||||||
|
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{(categoryOptions["type"] || []).map((o) => (
|
||||||
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">상태</Label>
|
||||||
|
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||||||
|
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
{(categoryOptions["status"] || []).map((o) => (
|
||||||
|
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-2" /> 초기화
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 메인 테이블 */}
|
||||||
|
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Package className="w-5 h-5" /> 품목 목록
|
||||||
|
<Badge variant="secondary" className="font-normal">{totalCount}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||||
|
<FileSpreadsheet className="w-4 h-4 mr-1.5" /> 엑셀 업로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||||
|
<Download className="w-4 h-4 mr-1.5" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={openRegisterModal}>
|
||||||
|
<Plus className="w-4 h-4 mr-1.5" /> 품목 등록
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
|
||||||
|
const item = items.find((i) => i.id === selectedId);
|
||||||
|
if (item) openCopyModal(item);
|
||||||
|
}}>
|
||||||
|
<Copy className="w-4 h-4 mr-1.5" /> 복사
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
|
||||||
|
const item = items.find((i) => i.id === selectedId);
|
||||||
|
if (item) openEditModal(item);
|
||||||
|
}}>
|
||||||
|
<Pencil className="w-4 h-4 mr-1.5" /> 수정
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
|
||||||
|
<Trash2 className="w-4 h-4 mr-1.5" /> 삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground gap-2">
|
||||||
|
<Package className="w-8 h-8 opacity-50" />
|
||||||
|
<span>등록된 품목이 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px] text-center">No</TableHead>
|
||||||
|
{TABLE_COLUMNS.map((col) => (
|
||||||
|
<TableHead key={col.key} className={col.width}>{col.label}</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className={cn("cursor-pointer", selectedId === item.id && "bg-primary/5")}
|
||||||
|
onClick={() => setSelectedId(item.id)}
|
||||||
|
onDoubleClick={() => openEditModal(item)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||||
|
{TABLE_COLUMNS.map((col) => (
|
||||||
|
<TableCell key={col.key} className="text-sm">
|
||||||
|
{["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] || ""}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 등록/수정 모달 */}
|
||||||
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEditMode ? "품목 정보를 수정합니다." : "새로운 품목을 등록합니다."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
{FORM_FIELDS.map((field) => (
|
||||||
|
<div key={field.key} className={cn("space-y-1.5", field.type === "textarea" && "col-span-2")}>
|
||||||
|
<Label className="text-sm">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</Label>
|
||||||
|
{field.type === "category" ? (
|
||||||
|
renderCategorySelect(field)
|
||||||
|
) : field.type === "textarea" ? (
|
||||||
|
<Textarea
|
||||||
|
value={formData[field.key] || ""}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
placeholder={field.label}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={formData[field.key] || ""}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||||
|
placeholder={field.disabled ? field.placeholder : field.label}
|
||||||
|
disabled={field.disabled && !isEditMode}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 엑셀 업로드 모달 */}
|
||||||
|
<ExcelUploadModal
|
||||||
|
open={excelUploadOpen}
|
||||||
|
onOpenChange={setExcelUploadOpen}
|
||||||
|
tableName={TABLE_NAME}
|
||||||
|
userId={user?.userId}
|
||||||
|
onSuccess={() => {
|
||||||
|
fetchItems();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,534 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외주품목정보 — 하드코딩 페이지
|
||||||
|
*
|
||||||
|
* 좌측: 품목 목록 (subcontractor_item_mapping 기반 품목, item_info 조인)
|
||||||
|
* 우측: 선택한 품목의 외주업체 정보 (subcontractor_item_mapping → subcontractor_mng 조인)
|
||||||
|
*
|
||||||
|
* 외주업체관리와 양방향 연동 (같은 subcontractor_item_mapping 테이블)
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
|
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, Settings2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||||
|
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||||
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
|
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||||
|
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||||
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
|
const ITEM_TABLE = "item_info";
|
||||||
|
const MAPPING_TABLE = "subcontractor_item_mapping";
|
||||||
|
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
|
||||||
|
|
||||||
|
// 좌측: 품목 컬럼
|
||||||
|
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "item_number", label: "품번", width: "w-[110px]" },
|
||||||
|
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
|
||||||
|
{ key: "size", label: "규격", width: "w-[90px]" },
|
||||||
|
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||||
|
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||||
|
{ key: "status", label: "상태", width: "w-[60px]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 우측: 외주업체 정보 컬럼
|
||||||
|
const RIGHT_COLUMNS: DataGridColumn[] = [
|
||||||
|
{ key: "subcontractor_code", label: "외주업체코드", width: "w-[110px]" },
|
||||||
|
{ key: "subcontractor_name", label: "외주업체명", minWidth: "min-w-[120px]" },
|
||||||
|
{ key: "subcontractor_item_code", label: "외주품번", width: "w-[100px]" },
|
||||||
|
{ key: "subcontractor_item_name", label: "외주품명", width: "w-[100px]" },
|
||||||
|
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||||
|
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SubcontractorItemPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||||
|
|
||||||
|
// 좌측: 품목
|
||||||
|
const [items, setItems] = useState<any[]>([]);
|
||||||
|
const [itemLoading, setItemLoading] = useState(false);
|
||||||
|
const [itemCount, setItemCount] = useState(0);
|
||||||
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||||
|
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||||
|
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 우측: 외주업체
|
||||||
|
const [subcontractorItems, setSubcontractorItems] = useState<any[]>([]);
|
||||||
|
const [subcontractorLoading, setSubcontractorLoading] = useState(false);
|
||||||
|
|
||||||
|
// 카테고리
|
||||||
|
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||||
|
|
||||||
|
// 외주업체 추가 모달
|
||||||
|
const [subSelectOpen, setSubSelectOpen] = useState(false);
|
||||||
|
const [subSearchKeyword, setSubSearchKeyword] = useState("");
|
||||||
|
const [subSearchResults, setSubSearchResults] = useState<any[]>([]);
|
||||||
|
const [subSearchLoading, setSubSearchLoading] = useState(false);
|
||||||
|
const [subCheckedIds, setSubCheckedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 품목 수정 모달
|
||||||
|
const [editItemOpen, setEditItemOpen] = useState(false);
|
||||||
|
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
// 엑셀
|
||||||
|
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||||
|
|
||||||
|
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||||
|
setFilterConfig(settings.filters);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = loadTableSettings("subcontractor-item");
|
||||||
|
if (saved) applyTableSettings(saved);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 카테고리 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
|
||||||
|
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
setCategoryOptions(optMap);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resolve = (col: string, code: string) => {
|
||||||
|
if (!code) return "";
|
||||||
|
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
|
||||||
|
const outsourcingDivisionCode = categoryOptions["division"]?.find(
|
||||||
|
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
|
||||||
|
)?.code;
|
||||||
|
|
||||||
|
const fetchItems = useCallback(async () => {
|
||||||
|
setItemLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||||
|
// division = 외주관리 필터 추가
|
||||||
|
if (outsourcingDivisionCode) {
|
||||||
|
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
|
||||||
|
}
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${ITEM_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 || [];
|
||||||
|
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
|
||||||
|
const data = raw.map((r: any) => {
|
||||||
|
const converted = { ...r };
|
||||||
|
for (const col of CATS) {
|
||||||
|
if (converted[col]) converted[col] = resolve(col, converted[col]);
|
||||||
|
}
|
||||||
|
return converted;
|
||||||
|
});
|
||||||
|
setItems(data);
|
||||||
|
setItemCount(res.data?.data?.total || raw.length);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("품목 조회 실패:", err);
|
||||||
|
toast.error("품목 목록을 불러오는데 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setItemLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchFilters, categoryOptions, outsourcingDivisionCode]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchItems(); }, [fetchItems]);
|
||||||
|
|
||||||
|
// 선택된 품목
|
||||||
|
const selectedItem = items.find((i) => i.id === selectedItemId);
|
||||||
|
|
||||||
|
// 우측: 외주업체 목록 조회
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedItem?.item_number) { setSubcontractorItems([]); return; }
|
||||||
|
const itemKey = selectedItem.item_number;
|
||||||
|
const fetchSubcontractorItems = async () => {
|
||||||
|
setSubcontractorLoading(true);
|
||||||
|
try {
|
||||||
|
// subcontractor_item_mapping에서 해당 품목의 매핑 조회
|
||||||
|
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||||
|
page: 1, size: 500,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||||
|
|
||||||
|
// subcontractor_id → subcontractor_mng 조인 (외주업체명)
|
||||||
|
const subIds = [...new Set(mappings.map((m: any) => m.subcontractor_id).filter(Boolean))];
|
||||||
|
let subMap: Record<string, any> = {};
|
||||||
|
if (subIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const subRes = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
|
||||||
|
page: 1, size: subIds.length + 10,
|
||||||
|
dataFilter: { enabled: true, filters: [{ columnName: "subcontractor_code", operator: "in", value: subIds }] },
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
for (const s of (subRes.data?.data?.data || subRes.data?.data?.rows || [])) {
|
||||||
|
subMap[s.subcontractor_code] = s;
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubcontractorItems(mappings.map((m: any) => ({
|
||||||
|
...m,
|
||||||
|
subcontractor_code: m.subcontractor_id,
|
||||||
|
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
|
||||||
|
})));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("외주업체 조회 실패:", err);
|
||||||
|
} finally {
|
||||||
|
setSubcontractorLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchSubcontractorItems();
|
||||||
|
}, [selectedItem?.item_number]);
|
||||||
|
|
||||||
|
// 외주업체 검색
|
||||||
|
const searchSubcontractors = async () => {
|
||||||
|
setSubSearchLoading(true);
|
||||||
|
try {
|
||||||
|
const filters: any[] = [];
|
||||||
|
if (subSearchKeyword) filters.push({ columnName: "subcontractor_name", operator: "contains", value: subSearchKeyword });
|
||||||
|
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
|
||||||
|
page: 1, size: 50,
|
||||||
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||||
|
autoFilter: true,
|
||||||
|
});
|
||||||
|
const all = res.data?.data?.data || res.data?.data?.rows || [];
|
||||||
|
// 이미 등록된 외주업체 제외
|
||||||
|
const existing = new Set(subcontractorItems.map((s: any) => s.subcontractor_id || s.subcontractor_code));
|
||||||
|
setSubSearchResults(all.filter((s: any) => !existing.has(s.subcontractor_code)));
|
||||||
|
} catch { /* skip */ } finally { setSubSearchLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 외주업체 추가 저장
|
||||||
|
const addSelectedSubcontractors = async () => {
|
||||||
|
const selected = subSearchResults.filter((s) => subCheckedIds.has(s.id));
|
||||||
|
if (selected.length === 0 || !selectedItem) return;
|
||||||
|
try {
|
||||||
|
for (const sub of selected) {
|
||||||
|
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
||||||
|
subcontractor_id: sub.subcontractor_code,
|
||||||
|
item_id: selectedItem.item_number,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
toast.success(`${selected.length}개 외주업체가 추가되었습니다.`);
|
||||||
|
setSubCheckedIds(new Set());
|
||||||
|
setSubSelectOpen(false);
|
||||||
|
// 우측 새로고침
|
||||||
|
const sid = selectedItemId;
|
||||||
|
setSelectedItemId(null);
|
||||||
|
setTimeout(() => setSelectedItemId(sid), 50);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.response?.data?.message || "외주업체 추가에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 품목 수정
|
||||||
|
const openEditItem = () => {
|
||||||
|
if (!selectedItem) return;
|
||||||
|
setEditItemForm({ ...selectedItem });
|
||||||
|
setEditItemOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async () => {
|
||||||
|
if (!editItemForm.id) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
|
||||||
|
originalData: { id: editItemForm.id },
|
||||||
|
updatedData: {
|
||||||
|
selling_price: editItemForm.selling_price || null,
|
||||||
|
standard_price: editItemForm.standard_price || null,
|
||||||
|
currency_code: editItemForm.currency_code || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success("수정되었습니다.");
|
||||||
|
setEditItemOpen(false);
|
||||||
|
fetchItems();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엑셀 다운로드
|
||||||
|
const handleExcelDownload = async () => {
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const data = items.map((i) => ({
|
||||||
|
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
|
||||||
|
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
|
||||||
|
}));
|
||||||
|
await exportToExcel(data, "외주품목정보.xlsx", "외주품목");
|
||||||
|
toast.success("다운로드 완료");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
{/* 검색 */}
|
||||||
|
<DynamicSearchFilter
|
||||||
|
tableName={ITEM_TABLE}
|
||||||
|
filterId="subcontractor-item"
|
||||||
|
onFilterChange={setSearchFilters}
|
||||||
|
dataCount={itemCount}
|
||||||
|
externalFilterConfig={filterConfig}
|
||||||
|
extraActions={
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||||
|
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||||
|
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 분할 패널 */}
|
||||||
|
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||||
|
<ResizablePanelGroup direction="horizontal">
|
||||||
|
{/* 좌측: 외주품목 목록 */}
|
||||||
|
<ResizablePanel defaultSize={55} minSize={30}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Package className="w-4 h-4" /> 외주품목 목록
|
||||||
|
<Badge variant="secondary" className="font-normal">{itemCount}건</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
|
||||||
|
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DataGrid
|
||||||
|
gridId="subcontractor-item-left"
|
||||||
|
columns={LEFT_COLUMNS}
|
||||||
|
data={items}
|
||||||
|
loading={itemLoading}
|
||||||
|
selectedId={selectedItemId}
|
||||||
|
onSelect={setSelectedItemId}
|
||||||
|
onRowDoubleClick={() => openEditItem()}
|
||||||
|
emptyMessage="등록된 외주품목이 없습니다"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
{/* 우측: 외주업체 정보 */}
|
||||||
|
<ResizablePanel defaultSize={45} minSize={25}>
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||||
|
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||||
|
<Users className="w-4 h-4" /> 외주업체 정보
|
||||||
|
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" disabled={!selectedItemId}
|
||||||
|
onClick={() => { setSubCheckedIds(new Set()); setSubSelectOpen(true); searchSubcontractors(); }}>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1" /> 외주업체 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!selectedItemId ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
좌측에서 품목을 선택하세요
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataGrid
|
||||||
|
gridId="subcontractor-item-right"
|
||||||
|
columns={RIGHT_COLUMNS}
|
||||||
|
data={subcontractorItems}
|
||||||
|
loading={subcontractorLoading}
|
||||||
|
showRowNumber={false}
|
||||||
|
emptyMessage="등록된 외주업체가 없습니다"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 수정 모달 */}
|
||||||
|
<FullscreenDialog
|
||||||
|
open={editItemOpen}
|
||||||
|
onOpenChange={setEditItemOpen}
|
||||||
|
title="외주품목 수정"
|
||||||
|
description={`${editItemForm.item_number || ""} — ${editItemForm.item_name || ""}`}
|
||||||
|
defaultMaxWidth="max-w-2xl"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setEditItemOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={handleEditSave} disabled={saving}>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-4 py-4">
|
||||||
|
{[
|
||||||
|
{ key: "item_number", label: "품목코드" },
|
||||||
|
{ key: "item_name", label: "품명" },
|
||||||
|
{ key: "size", label: "규격" },
|
||||||
|
{ key: "unit", label: "단위" },
|
||||||
|
{ key: "material", label: "재질" },
|
||||||
|
{ key: "status", label: "상태" },
|
||||||
|
].map((f) => (
|
||||||
|
<div key={f.key} className="space-y-1.5">
|
||||||
|
<Label className="text-sm text-muted-foreground">{f.label}</Label>
|
||||||
|
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="col-span-2 border-t my-2" />
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">판매가격</Label>
|
||||||
|
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
|
||||||
|
placeholder="판매가격" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">기준단가</Label>
|
||||||
|
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
|
||||||
|
placeholder="기준단가" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm">통화</Label>
|
||||||
|
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FullscreenDialog>
|
||||||
|
|
||||||
|
{/* 외주업체 추가 모달 */}
|
||||||
|
<Dialog open={subSelectOpen} onOpenChange={setSubSelectOpen}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[70vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>외주업체 선택</DialogTitle>
|
||||||
|
<DialogDescription>품목에 추가할 외주업체를 선택하세요.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<Input placeholder="외주업체명 검색" value={subSearchKeyword}
|
||||||
|
onChange={(e) => setSubSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && searchSubcontractors()}
|
||||||
|
className="h-9 flex-1" />
|
||||||
|
<Button size="sm" onClick={searchSubcontractors} disabled={subSearchLoading} className="h-9">
|
||||||
|
{subSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 bg-background z-10">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[40px] text-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={subSearchResults.length > 0 && subCheckedIds.size === subSearchResults.length}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) setSubCheckedIds(new Set(subSearchResults.map((s) => s.id)));
|
||||||
|
else setSubCheckedIds(new Set());
|
||||||
|
}} />
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[110px]">외주업체코드</TableHead>
|
||||||
|
<TableHead className="min-w-[130px]">외주업체명</TableHead>
|
||||||
|
<TableHead className="w-[80px]">거래유형</TableHead>
|
||||||
|
<TableHead className="w-[80px]">담당자</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{subSearchResults.length === 0 ? (
|
||||||
|
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||||
|
) : subSearchResults.map((s) => (
|
||||||
|
<TableRow key={s.id} className={cn("cursor-pointer", subCheckedIds.has(s.id) && "bg-primary/5")}
|
||||||
|
onClick={() => setSubCheckedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(s.id)) next.delete(s.id); else next.add(s.id);
|
||||||
|
return next;
|
||||||
|
})}>
|
||||||
|
<TableCell className="text-center"><input type="checkbox" checked={subCheckedIds.has(s.id)} readOnly /></TableCell>
|
||||||
|
<TableCell className="text-xs">{s.subcontractor_code}</TableCell>
|
||||||
|
<TableCell className="text-sm">{s.subcontractor_name}</TableCell>
|
||||||
|
<TableCell className="text-xs">{s.division}</TableCell>
|
||||||
|
<TableCell className="text-xs">{s.contact_person}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<div className="flex items-center gap-2 w-full justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">{subCheckedIds.size}개 선택됨</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setSubSelectOpen(false)}>취소</Button>
|
||||||
|
<Button onClick={addSelectedSubcontractors} disabled={subCheckedIds.size === 0}>
|
||||||
|
<Plus className="w-4 h-4 mr-1" /> {subCheckedIds.size}개 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 엑셀 업로드 */}
|
||||||
|
<ExcelUploadModal
|
||||||
|
open={excelUploadOpen}
|
||||||
|
onOpenChange={setExcelUploadOpen}
|
||||||
|
tableName={ITEM_TABLE}
|
||||||
|
userId={user?.userId}
|
||||||
|
onSuccess={() => fetchItems()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TableSettingsModal
|
||||||
|
open={tableSettingsOpen}
|
||||||
|
onOpenChange={setTableSettingsOpen}
|
||||||
|
tableName={ITEM_TABLE}
|
||||||
|
settingsId="subcontractor-item"
|
||||||
|
onSave={applyTableSettings}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{ConfirmDialogComponent}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,845 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Settings,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Search,
|
||||||
|
RotateCcw,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
ResizablePanelGroup,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizableHandle,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
getProcessList,
|
||||||
|
createProcess,
|
||||||
|
updateProcess,
|
||||||
|
deleteProcesses,
|
||||||
|
getProcessEquipments,
|
||||||
|
addProcessEquipment,
|
||||||
|
removeProcessEquipment,
|
||||||
|
getEquipmentList,
|
||||||
|
type ProcessMaster,
|
||||||
|
type ProcessEquipment,
|
||||||
|
type Equipment,
|
||||||
|
} from "@/lib/api/processInfo";
|
||||||
|
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||||
|
|
||||||
|
const ALL_VALUE = "__all__";
|
||||||
|
|
||||||
|
export function ProcessMasterTab() {
|
||||||
|
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
|
||||||
|
const [equipmentMaster, setEquipmentMaster] = useState<Equipment[]>([]);
|
||||||
|
const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
|
||||||
|
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||||
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
|
const [loadingEquipments, setLoadingEquipments] = useState(false);
|
||||||
|
|
||||||
|
const [filterCode, setFilterCode] = useState("");
|
||||||
|
const [filterName, setFilterName] = useState("");
|
||||||
|
const [filterType, setFilterType] = useState<string>(ALL_VALUE);
|
||||||
|
const [filterUseYn, setFilterUseYn] = useState<string>(ALL_VALUE);
|
||||||
|
|
||||||
|
const [selectedProcess, setSelectedProcess] = useState<ProcessMaster | null>(null);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
|
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
|
||||||
|
const [equipmentPick, setEquipmentPick] = useState<string>("");
|
||||||
|
const [addingEquipment, setAddingEquipment] = useState(false);
|
||||||
|
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [formMode, setFormMode] = useState<"add" | "edit">("add");
|
||||||
|
const [savingForm, setSavingForm] = useState(false);
|
||||||
|
const [formProcessCode, setFormProcessCode] = useState("");
|
||||||
|
const [formProcessName, setFormProcessName] = useState("");
|
||||||
|
const [formProcessType, setFormProcessType] = useState<string>("");
|
||||||
|
const [formStandardTime, setFormStandardTime] = useState("");
|
||||||
|
const [formWorkerCount, setFormWorkerCount] = useState("");
|
||||||
|
const [formUseYn, setFormUseYn] = useState("");
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const processTypeMap = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
processTypeOptions.forEach((o) => m.set(o.valueCode, o.valueLabel));
|
||||||
|
return m;
|
||||||
|
}, [processTypeOptions]);
|
||||||
|
|
||||||
|
const getProcessTypeLabel = useCallback(
|
||||||
|
(code: string) => processTypeMap.get(code) ?? code,
|
||||||
|
[processTypeMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadProcesses = useCallback(async () => {
|
||||||
|
setLoadingList(true);
|
||||||
|
try {
|
||||||
|
const res = await getProcessList({
|
||||||
|
processCode: filterCode.trim() || undefined,
|
||||||
|
processName: filterName.trim() || undefined,
|
||||||
|
processType: filterType === ALL_VALUE ? undefined : filterType,
|
||||||
|
useYn: filterUseYn === ALL_VALUE ? undefined : filterUseYn,
|
||||||
|
});
|
||||||
|
if (!res.success) {
|
||||||
|
toast.error(res.message || "공정 목록을 불러오지 못했습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProcesses(res.data ?? []);
|
||||||
|
} finally {
|
||||||
|
setLoadingList(false);
|
||||||
|
}
|
||||||
|
}, [filterCode, filterName, filterType, filterUseYn]);
|
||||||
|
|
||||||
|
const loadInitial = useCallback(async () => {
|
||||||
|
setLoadingInitial(true);
|
||||||
|
try {
|
||||||
|
const [procRes, eqRes] = await Promise.all([getProcessList(), getEquipmentList()]);
|
||||||
|
if (!procRes.success) {
|
||||||
|
toast.error(procRes.message || "공정 목록을 불러오지 못했습니다.");
|
||||||
|
} else {
|
||||||
|
setProcesses(procRes.data ?? []);
|
||||||
|
}
|
||||||
|
if (!eqRes.success) {
|
||||||
|
toast.error(eqRes.message || "설비 목록을 불러오지 못했습니다.");
|
||||||
|
} else {
|
||||||
|
setEquipmentMaster(eqRes.data ?? []);
|
||||||
|
}
|
||||||
|
const ptRes = await getCategoryValues("process_mng", "process_type");
|
||||||
|
if (ptRes.success && "data" in ptRes && Array.isArray(ptRes.data)) {
|
||||||
|
const activeValues = ptRes.data.filter((v: any) => v.isActive !== false);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const unique = activeValues.filter((v: any) => {
|
||||||
|
if (seen.has(v.valueCode)) return false;
|
||||||
|
seen.add(v.valueCode);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingInitial(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadInitial();
|
||||||
|
}, [loadInitial]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedProcess((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
if (!processes.some((p) => p.id === prev.id)) return null;
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [processes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEquipmentPick("");
|
||||||
|
}, [selectedProcess?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedProcess) {
|
||||||
|
setProcessEquipments([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingEquipments(true);
|
||||||
|
void (async () => {
|
||||||
|
const res = await getProcessEquipments(selectedProcess.process_code);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!res.success) {
|
||||||
|
toast.error(res.message || "공정 설비를 불러오지 못했습니다.");
|
||||||
|
setProcessEquipments([]);
|
||||||
|
} else {
|
||||||
|
setProcessEquipments(res.data ?? []);
|
||||||
|
}
|
||||||
|
setLoadingEquipments(false);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [selectedProcess?.process_code]);
|
||||||
|
|
||||||
|
const allSelected = useMemo(() => {
|
||||||
|
if (processes.length === 0) return false;
|
||||||
|
return processes.every((p) => selectedIds.has(p.id));
|
||||||
|
}, [processes, selectedIds]);
|
||||||
|
|
||||||
|
const toggleAll = (checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedIds(new Set(processes.map((p) => p.id)));
|
||||||
|
} else {
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOne = (id: string, checked: boolean) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (checked) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetFilters = () => {
|
||||||
|
setFilterCode("");
|
||||||
|
setFilterName("");
|
||||||
|
setFilterType(ALL_VALUE);
|
||||||
|
setFilterUseYn(ALL_VALUE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
void loadProcesses();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAdd = () => {
|
||||||
|
setFormMode("add");
|
||||||
|
setEditingId(null);
|
||||||
|
setFormProcessCode("");
|
||||||
|
setFormProcessName("");
|
||||||
|
setFormProcessType(processTypeOptions[0]?.valueCode ?? "");
|
||||||
|
setFormStandardTime("");
|
||||||
|
setFormWorkerCount("");
|
||||||
|
setFormUseYn("Y");
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = () => {
|
||||||
|
if (!selectedProcess) {
|
||||||
|
toast.message("수정할 공정을 좌측 목록에서 선택하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFormMode("edit");
|
||||||
|
setEditingId(selectedProcess.id);
|
||||||
|
setFormProcessCode(selectedProcess.process_code);
|
||||||
|
setFormProcessName(selectedProcess.process_name);
|
||||||
|
setFormProcessType(selectedProcess.process_type);
|
||||||
|
setFormStandardTime(selectedProcess.standard_time ?? "");
|
||||||
|
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
||||||
|
setFormUseYn(selectedProcess.use_yn);
|
||||||
|
setFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitForm = async () => {
|
||||||
|
if (!formProcessName.trim()) {
|
||||||
|
toast.error("공정명을 입력하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingForm(true);
|
||||||
|
try {
|
||||||
|
if (formMode === "add") {
|
||||||
|
const res = await createProcess({
|
||||||
|
process_name: formProcessName.trim(),
|
||||||
|
process_type: formProcessType,
|
||||||
|
standard_time: formStandardTime.trim() || "0",
|
||||||
|
worker_count: formWorkerCount.trim() || "0",
|
||||||
|
use_yn: formUseYn,
|
||||||
|
});
|
||||||
|
if (!res.success || !res.data) {
|
||||||
|
toast.error(res.message || "등록에 실패했습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("공정이 등록되었습니다.");
|
||||||
|
setFormOpen(false);
|
||||||
|
await loadProcesses();
|
||||||
|
setSelectedProcess(res.data);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
} else if (editingId) {
|
||||||
|
const res = await updateProcess(editingId, {
|
||||||
|
process_name: formProcessName.trim(),
|
||||||
|
process_type: formProcessType,
|
||||||
|
standard_time: formStandardTime.trim() || "0",
|
||||||
|
worker_count: formWorkerCount.trim() || "0",
|
||||||
|
use_yn: formUseYn,
|
||||||
|
});
|
||||||
|
if (!res.success || !res.data) {
|
||||||
|
toast.error(res.message || "수정에 실패했습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("공정이 수정되었습니다.");
|
||||||
|
setFormOpen(false);
|
||||||
|
await loadProcesses();
|
||||||
|
setSelectedProcess(res.data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSavingForm(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDelete = () => {
|
||||||
|
if (selectedIds.size === 0) {
|
||||||
|
toast.message("삭제할 공정을 체크박스로 선택하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
const ids = Array.from(selectedIds);
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
const res = await deleteProcesses(ids);
|
||||||
|
if (!res.success) {
|
||||||
|
toast.error(res.message || "삭제에 실패했습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`${ids.length}건 삭제되었습니다.`);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
if (selectedProcess && ids.includes(selectedProcess.id)) {
|
||||||
|
setSelectedProcess(null);
|
||||||
|
}
|
||||||
|
await loadProcesses();
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableEquipments = useMemo(() => {
|
||||||
|
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
||||||
|
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
||||||
|
}, [equipmentMaster, processEquipments]);
|
||||||
|
|
||||||
|
const handleAddEquipment = async () => {
|
||||||
|
if (!selectedProcess) return;
|
||||||
|
if (!equipmentPick) {
|
||||||
|
toast.message("추가할 설비를 선택하세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAddingEquipment(true);
|
||||||
|
try {
|
||||||
|
const res = await addProcessEquipment({
|
||||||
|
process_code: selectedProcess.process_code,
|
||||||
|
equipment_code: equipmentPick,
|
||||||
|
});
|
||||||
|
if (!res.success) {
|
||||||
|
toast.error(res.message || "설비 추가에 실패했습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("설비가 등록되었습니다.");
|
||||||
|
setEquipmentPick("");
|
||||||
|
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||||
|
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||||
|
} finally {
|
||||||
|
setAddingEquipment(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveEquipment = async (row: ProcessEquipment) => {
|
||||||
|
const res = await removeProcessEquipment(row.id);
|
||||||
|
if (!res.success) {
|
||||||
|
toast.error(res.message || "설비 제거에 실패했습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success("설비가 제거되었습니다.");
|
||||||
|
if (selectedProcess) {
|
||||||
|
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||||
|
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listBusy = loadingInitial || loadingList;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
|
||||||
|
<ResizablePanel defaultSize={50} minSize={30}>
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
|
||||||
|
<div className="flex shrink-0 flex-col gap-2 border-b bg-muted/30 p-3 sm:p-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
<span className="text-sm font-semibold sm:text-base">공정 마스터</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs sm:text-sm">공정코드</Label>
|
||||||
|
<Input
|
||||||
|
value={filterCode}
|
||||||
|
onChange={(e) => setFilterCode(e.target.value)}
|
||||||
|
placeholder="코드"
|
||||||
|
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[140px] sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs sm:text-sm">공정명</Label>
|
||||||
|
<Input
|
||||||
|
value={filterName}
|
||||||
|
onChange={(e) => setFilterName(e.target.value)}
|
||||||
|
placeholder="이름"
|
||||||
|
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[160px] sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs sm:text-sm">공정유형</Label>
|
||||||
|
<Select value={filterType} onValueChange={setFilterType}>
|
||||||
|
<SelectTrigger className="h-8 w-[120px] text-xs sm:h-10 sm:w-[130px] sm:text-sm">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
|
||||||
|
전체
|
||||||
|
</SelectItem>
|
||||||
|
{processTypeOptions.map((o, idx) => (
|
||||||
|
<SelectItem key={`pt-filter-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
|
||||||
|
{o.valueLabel}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs sm:text-sm">사용여부</Label>
|
||||||
|
<Select value={filterUseYn} onValueChange={setFilterUseYn}>
|
||||||
|
<SelectTrigger className="h-8 w-[100px] text-xs sm:h-10 sm:w-[110px] sm:text-sm">
|
||||||
|
<SelectValue placeholder="전체" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
|
||||||
|
전체
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="Y" className="text-xs sm:text-sm">사용</SelectItem>
|
||||||
|
<SelectItem value="N" className="text-xs sm:text-sm">미사용</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
onClick={handleResetFilters}
|
||||||
|
>
|
||||||
|
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
||||||
|
초기화
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={listBusy}
|
||||||
|
>
|
||||||
|
<Search className="mr-1 h-3.5 w-3.5" />
|
||||||
|
조회
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
onClick={openAdd}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
공정 추가
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
onClick={openEdit}
|
||||||
|
>
|
||||||
|
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
onClick={openDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
<div className="p-2 sm:p-3">
|
||||||
|
{listBusy ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
<p className="mt-2 text-xs sm:text-sm">불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-10 text-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={(v) => toggleAll(v === true)}
|
||||||
|
aria-label="전체 선택"
|
||||||
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-xs sm:text-sm">공정코드</TableHead>
|
||||||
|
<TableHead className="text-xs sm:text-sm">공정명</TableHead>
|
||||||
|
<TableHead className="text-xs sm:text-sm">공정유형</TableHead>
|
||||||
|
<TableHead className="text-right text-xs sm:text-sm">표준시간(분)</TableHead>
|
||||||
|
<TableHead className="text-right text-xs sm:text-sm">작업인원</TableHead>
|
||||||
|
<TableHead className="text-center text-xs sm:text-sm">사용여부</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{processes.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="py-12 text-center text-muted-foreground">
|
||||||
|
<p className="text-xs sm:text-sm">조회된 공정이 없습니다.</p>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
processes.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer transition-colors",
|
||||||
|
selectedProcess?.id === row.id && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedProcess(row)}
|
||||||
|
>
|
||||||
|
<TableCell
|
||||||
|
className="text-center"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(row.id)}
|
||||||
|
onCheckedChange={(v) => toggleOne(row.id, v === true)}
|
||||||
|
aria-label={`${row.process_code} 선택`}
|
||||||
|
className="mx-auto"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs font-medium sm:text-sm">
|
||||||
|
{row.process_code}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs sm:text-sm">{row.process_name}</TableCell>
|
||||||
|
<TableCell className="text-xs sm:text-sm">
|
||||||
|
<Badge variant="secondary" className="text-[10px] sm:text-xs">
|
||||||
|
{getProcessTypeLabel(row.process_type)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-xs sm:text-sm">
|
||||||
|
{row.standard_time ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-xs sm:text-sm">
|
||||||
|
{row.worker_count ?? "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-xs sm:text-sm">
|
||||||
|
<Badge
|
||||||
|
variant={row.use_yn === "N" ? "outline" : "default"}
|
||||||
|
className="text-[10px] sm:text-xs"
|
||||||
|
>
|
||||||
|
{row.use_yn === "Y" ? "사용" : "미사용"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle withHandle />
|
||||||
|
|
||||||
|
<ResizablePanel defaultSize={50} minSize={30}>
|
||||||
|
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
|
||||||
|
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-2 sm:px-4 sm:py-3">
|
||||||
|
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold sm:text-base">공정별 사용설비</p>
|
||||||
|
{selectedProcess ? (
|
||||||
|
<p className="truncate text-xs text-muted-foreground sm:text-sm">
|
||||||
|
{selectedProcess.process_name}{" "}
|
||||||
|
<span className="text-muted-foreground/80">({selectedProcess.process_code})</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground sm:text-sm">공정 미선택</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedProcess ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-center text-muted-foreground">
|
||||||
|
<Settings className="h-10 w-10 opacity-40" />
|
||||||
|
<p className="text-sm font-medium text-foreground">좌측에서 공정을 선택하세요</p>
|
||||||
|
<p className="max-w-xs text-xs sm:text-sm">
|
||||||
|
목록 행을 클릭하면 이 공정에 연결된 설비를 관리할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4">
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="min-w-0 flex-1 space-y-1 sm:max-w-xs">
|
||||||
|
<Label className="text-xs sm:text-sm">설비 선택</Label>
|
||||||
|
<Select
|
||||||
|
key={selectedProcess.id}
|
||||||
|
value={equipmentPick || undefined}
|
||||||
|
onValueChange={setEquipmentPick}
|
||||||
|
disabled={addingEquipment || availableEquipments.length === 0}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="설비를 선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableEquipments.map((eq) => (
|
||||||
|
<SelectItem
|
||||||
|
key={eq.id}
|
||||||
|
value={eq.equipment_code}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{eq.equipment_code} · {eq.equipment_name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
onClick={() => void handleAddEquipment()}
|
||||||
|
disabled={addingEquipment || !equipmentPick}
|
||||||
|
>
|
||||||
|
{addingEquipment ? (
|
||||||
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
{loadingEquipments ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||||
|
<Loader2 className="h-7 w-7 animate-spin" />
|
||||||
|
<p className="mt-2 text-xs sm:text-sm">설비 목록 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
) : processEquipments.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-xs text-muted-foreground sm:text-sm">
|
||||||
|
등록된 설비가 없습니다. 상단에서 설비를 추가하세요.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="h-[min(420px,calc(100vh-20rem))] pr-3">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{processEquipments.map((pe) => (
|
||||||
|
<li key={pe.id}>
|
||||||
|
<Card className="rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||||
|
<CardContent className="flex items-center gap-3 p-3 sm:p-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-xs font-medium sm:text-sm">
|
||||||
|
{pe.equipment_code}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-muted-foreground sm:text-sm">
|
||||||
|
{pe.equipment_name || "설비명 없음"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0 text-xs sm:h-9 sm:text-sm"
|
||||||
|
onClick={() => void handleRemoveEquipment(pe)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||||
|
제거
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
|
||||||
|
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
{formMode === "add" ? "공정 추가" : "공정 수정"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
공정 마스터 정보를 입력합니다. 표준시간과 작업인원은 숫자로 입력하세요.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="pm-process-name" className="text-xs sm:text-sm">
|
||||||
|
공정명 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="pm-process-name"
|
||||||
|
value={formProcessName}
|
||||||
|
onChange={(e) => setFormProcessName(e.target.value)}
|
||||||
|
placeholder="공정명"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">공정유형</Label>
|
||||||
|
<Select value={formProcessType} onValueChange={setFormProcessType}>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{processTypeOptions.map((o, idx) => (
|
||||||
|
<SelectItem key={`pt-form-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
|
||||||
|
{o.valueLabel}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="pm-standard-time" className="text-xs sm:text-sm">
|
||||||
|
표준작업시간(분)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="pm-standard-time"
|
||||||
|
value={formStandardTime}
|
||||||
|
onChange={(e) => setFormStandardTime(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="pm-worker-count" className="text-xs sm:text-sm">
|
||||||
|
작업인원수
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="pm-worker-count"
|
||||||
|
value={formWorkerCount}
|
||||||
|
onChange={(e) => setFormWorkerCount(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">사용여부</Label>
|
||||||
|
<Select value={formUseYn} onValueChange={setFormUseYn}>
|
||||||
|
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
<SelectValue placeholder="선택하세요" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Y" className="text-xs sm:text-sm">사용</SelectItem>
|
||||||
|
<SelectItem value="N" className="text-xs sm:text-sm">미사용</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setFormOpen(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
disabled={savingForm}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void submitForm()}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
disabled={savingForm}
|
||||||
|
>
|
||||||
|
{savingForm ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">공정 삭제</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
선택한 {selectedIds.size}건의 공정을 삭제합니다. 연결된 공정-설비 매핑도 함께 삭제됩니다. 이 작업은
|
||||||
|
되돌릴 수 없습니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteOpen(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void confirmDelete()}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ProcessWorkStandardComponent } from "@/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent";
|
||||||
|
|
||||||
|
export function ProcessWorkStandardTab() {
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-12rem)]">
|
||||||
|
<ProcessWorkStandardComponent
|
||||||
|
config={{
|
||||||
|
itemListMode: "registered",
|
||||||
|
screenCode: "screen_1599",
|
||||||
|
leftPanelTitle: "등록 품목 및 공정",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Settings, GitBranch, ClipboardList } from "lucide-react";
|
||||||
|
import { ProcessMasterTab } from "./ProcessMasterTab";
|
||||||
|
import { ItemRoutingTab } from "./ItemRoutingTab";
|
||||||
|
import { ProcessWorkStandardTab } from "./ProcessWorkStandardTab";
|
||||||
|
|
||||||
|
export default function ProcessInfoPage() {
|
||||||
|
const [activeTab, setActiveTab] = useState("process");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
|
||||||
|
<div className="shrink-0 border-b bg-background px-4">
|
||||||
|
<TabsList className="h-12 bg-transparent gap-1">
|
||||||
|
<TabsTrigger
|
||||||
|
value="process"
|
||||||
|
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
공정 마스터
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="routing"
|
||||||
|
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
|
||||||
|
>
|
||||||
|
<GitBranch className="mr-2 h-4 w-4" />
|
||||||
|
품목별 라우팅
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="workstandard"
|
||||||
|
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
|
||||||
|
>
|
||||||
|
<ClipboardList className="mr-2 h-4 w-4" />
|
||||||
|
공정 작업기준
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="process" className="flex-1 overflow-hidden mt-0">
|
||||||
|
<ProcessMasterTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="routing" className="flex-1 overflow-hidden mt-0">
|
||||||
|
<ItemRoutingTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="workstandard" className="flex-1 overflow-hidden mt-0">
|
||||||
|
<ProcessWorkStandardTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue