Merge pull request 'jskim-node' (#5) from jskim-node into main

Reviewed-on: https://g.wace.me/jskim/vexplor_dev/pulls/5
This commit is contained in:
jskim
2026-04-02 09:30:04 +00:00
166 changed files with 38944 additions and 1891 deletions
+4
View File
@@ -107,6 +107,9 @@ settings/
*.crt *.crt
*.cert *.cert
secrets/ secrets/
# oh-my-claudecode 로컬 세션/상태 파일
.omc/
secrets.json secrets.json
secrets.yaml secrets.yaml
secrets.yml secrets.yml
@@ -231,3 +234,4 @@ test-results/
frontend/playwright.config.ts frontend/playwright.config.ts
frontend/tests/ frontend/tests/
frontend/test-results/ frontend/test-results/
db/checkpoints/
-447
View File
@@ -1,447 +0,0 @@
{
"version": "1.0.0",
"lastScanned": 1774313213052,
"projectRoot": "/Users/kimjuseok/ERP-node",
"techStack": {
"languages": [
{
"name": "JavaScript/TypeScript",
"version": null,
"confidence": "high",
"markers": [
"package.json"
]
}
],
"frameworks": [
{
"name": "playwright",
"version": "1.58.2",
"category": "testing"
}
],
"packageManager": "npm",
"runtime": null
},
"build": {
"buildCommand": null,
"testCommand": null,
"lintCommand": null,
"devCommand": null,
"scripts": {}
},
"conventions": {
"namingStyle": null,
"importStyle": null,
"testPattern": null,
"fileOrganization": null
},
"structure": {
"isMonorepo": false,
"workspaces": [],
"mainDirectories": [
"docs",
"scripts"
],
"gitBranches": {
"defaultBranch": "main",
"branchingStrategy": null
}
},
"customNotes": [],
"directoryMap": {
"_local": {
"path": "_local",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1774313213033,
"keyFiles": [
"pipeline-progress.json"
]
},
"ai-assistant": {
"path": "ai-assistant",
"purpose": null,
"fileCount": 5,
"lastAccessed": 1774313213036,
"keyFiles": [
"Dockerfile.win",
"README.md",
"package-lock.json",
"package.json"
]
},
"backend": {
"path": "backend",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313213038,
"keyFiles": []
},
"backend-node": {
"path": "backend-node",
"purpose": null,
"fileCount": 17,
"lastAccessed": 1774313213039,
"keyFiles": [
"API_연동_가이드.md",
"API_키_정리.md",
"Dockerfile.win",
"PHASE1_USAGE_GUIDE.md",
"README.md"
]
},
"backup": {
"path": "backup",
"purpose": null,
"fileCount": 6,
"lastAccessed": 1774313213040,
"keyFiles": [
"Dockerfile",
"README.md",
"backup.py",
"docker-compose.backup.yml"
]
},
"db": {
"path": "db",
"purpose": null,
"fileCount": 14,
"lastAccessed": 1774313213041,
"keyFiles": [
"00-create-roles.sh",
"check_category_values.sql",
"check_numbering_rules.sql",
"cleanup_duplicate_screens_daejin.sql",
"company7_screen_backup.sql"
]
},
"deploy": {
"path": "deploy",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313213041,
"keyFiles": []
},
"digitalTwin": {
"path": "digitalTwin",
"purpose": null,
"fileCount": 4,
"lastAccessed": 1774313213041,
"keyFiles": [
"architecture-v4.md",
"fleet-management-plan.md",
"디지털트윈 아키텍쳐_v3.png",
"디지털트윈 아키텍쳐_v4.png"
]
},
"docker": {
"path": "docker",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313213042,
"keyFiles": []
},
"docs": {
"path": "docs",
"purpose": "Documentation",
"fileCount": 35,
"lastAccessed": 1774313213042,
"keyFiles": [
"AI_화면생성_시스템_설계서.md",
"BOM_개발_현황.md",
"DB_ARCHITECTURE_ANALYSIS.md",
"DB_STRUCTURE_DIAGRAM.html",
"DB_WORKFLOW_ANALYSIS.md"
]
},
"frontend": {
"path": "frontend",
"purpose": null,
"fileCount": 17,
"lastAccessed": 1774313213043,
"keyFiles": [
"MODAL_REPEATER_TABLE_DEBUG.md",
"README.md",
"approval-box-result.png",
"components.json",
"eslint.config.mjs"
]
},
"k8s": {
"path": "k8s",
"purpose": null,
"fileCount": 7,
"lastAccessed": 1774313213043,
"keyFiles": [
"local-path-provisioner.yaml",
"namespace.yaml",
"vexplor-backend-deployment.yaml",
"vexplor-config.yaml",
"vexplor-frontend-deployment.yaml"
]
},
"mcp-agent-orchestrator": {
"path": "mcp-agent-orchestrator",
"purpose": null,
"fileCount": 4,
"lastAccessed": 1774313213043,
"keyFiles": [
"README.md",
"package-lock.json",
"package.json",
"tsconfig.json"
]
},
"mcp-task-queue": {
"path": "mcp-task-queue",
"purpose": null,
"fileCount": 4,
"lastAccessed": 1774313213043,
"keyFiles": [
"package-lock.json",
"package.json",
"tsconfig.json"
]
},
"mcp-task-server": {
"path": "mcp-task-server",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313213043,
"keyFiles": []
},
"scripts": {
"path": "scripts",
"purpose": "Build/utility scripts",
"fileCount": 11,
"lastAccessed": 1774313213044,
"keyFiles": [
"add-modal-ids.py",
"analyze-company-info-layout.js",
"browser-test-admin-switch-button.js",
"browser-test-customer-crud.js",
"browser-test-customer-via-menu.js"
]
},
"test-output": {
"path": "test-output",
"purpose": null,
"fileCount": 2,
"lastAccessed": 1774313213044,
"keyFiles": [
"screen-149-field-type-verification-guide.md",
"unified-field-type-config-panel-test-guide.md"
]
},
"test-results": {
"path": "test-results",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1774313213044,
"keyFiles": []
},
"ai-assistant/src": {
"path": "ai-assistant/src",
"purpose": "Source code",
"fileCount": 1,
"lastAccessed": 1774313213045,
"keyFiles": [
"app.js"
]
},
"frontend/app": {
"path": "frontend/app",
"purpose": "Application code",
"fileCount": 5,
"lastAccessed": 1774313213046,
"keyFiles": [
"favicon.ico",
"globals.css",
"layout.tsx"
]
},
"frontend/components": {
"path": "frontend/components",
"purpose": "UI components",
"fileCount": 1,
"lastAccessed": 1774313213046,
"keyFiles": [
"GlobalFileViewer.tsx"
]
},
"mcp-agent-orchestrator/src": {
"path": "mcp-agent-orchestrator/src",
"purpose": "Source code",
"fileCount": 1,
"lastAccessed": 1774313213047,
"keyFiles": [
"index.ts"
]
},
"mcp-task-queue/data": {
"path": "mcp-task-queue/data",
"purpose": "Data files",
"fileCount": 2,
"lastAccessed": 1774313213047,
"keyFiles": [
"knowledge.json",
"tasks.json"
]
},
"mcp-task-queue/dist": {
"path": "mcp-task-queue/dist",
"purpose": "Distribution/build output",
"fileCount": 28,
"lastAccessed": 1774313213048,
"keyFiles": [
"agent-runner.d.ts",
"agent-runner.d.ts.map",
"agent-runner.js"
]
},
"mcp-task-queue/node_modules": {
"path": "mcp-task-queue/node_modules",
"purpose": "Dependencies",
"fileCount": 1,
"lastAccessed": 1774313213049,
"keyFiles": []
},
"mcp-task-queue/src": {
"path": "mcp-task-queue/src",
"purpose": "Source code",
"fileCount": 7,
"lastAccessed": 1774313213049,
"keyFiles": [
"agent-runner.ts",
"index.ts",
"knowledge-store.ts"
]
},
"mcp-task-server/data": {
"path": "mcp-task-server/data",
"purpose": "Data files",
"fileCount": 0,
"lastAccessed": 1774313213049,
"keyFiles": []
},
"mcp-task-server/dist": {
"path": "mcp-task-server/dist",
"purpose": "Distribution/build output",
"fileCount": 6,
"lastAccessed": 1774313213050,
"keyFiles": [
"index.d.ts",
"index.js",
"taskStore.d.ts"
]
},
"mcp-task-server/node_modules": {
"path": "mcp-task-server/node_modules",
"purpose": "Dependencies",
"fileCount": 1,
"lastAccessed": 1774313213050,
"keyFiles": []
},
"mcp-task-server/src": {
"path": "mcp-task-server/src",
"purpose": "Source code",
"fileCount": 0,
"lastAccessed": 1774313213052,
"keyFiles": []
}
},
"hotPaths": [
{
"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": []
}
@@ -1,8 +0,0 @@
{
"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": []
}
@@ -1,8 +0,0 @@
{
"session_id": "591d357c-df9d-4bbc-8dfa-1b98a9184e23",
"ended_at": "2026-03-04T08:10:16.810Z",
"reason": "other",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}
@@ -1,8 +0,0 @@
{
"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": []
}
@@ -1,8 +0,0 @@
{
"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": []
}
@@ -1,8 +0,0 @@
{
"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": []
}
-3
View File
@@ -1,3 +0,0 @@
{
"lastSentAt": "2026-03-24T02:36:44.477Z"
}
-53
View File
@@ -1,53 +0,0 @@
{
"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"
}
]
}
]
}
+339 -1
View File
@@ -26,6 +26,7 @@
"http-proxy-middleware": "^3.0.5", "http-proxy-middleware": "^3.0.5",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.0",
"imap": "^0.8.19", "imap": "^0.8.19",
"imapflow": "^1.2.18",
"joi": "^17.11.0", "joi": "^17.11.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mailparser": "^3.7.5", "mailparser": "^3.7.5",
@@ -34,6 +35,7 @@
"mysql2": "^3.15.0", "mysql2": "^3.15.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"node-pop3": "^0.11.0",
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"oracledb": "^6.9.0", "oracledb": "^6.9.0",
"pg": "^8.16.3", "pg": "^8.16.3",
@@ -41,6 +43,7 @@
"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",
"socket.io": "^4.8.3",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
@@ -2361,6 +2364,12 @@
"@noble/hashes": "^1.1.5" "@noble/hashes": "^1.1.5"
} }
}, },
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@redis/bloom": { "node_modules/@redis/bloom": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
@@ -3122,6 +3131,12 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@tediousjs/connection-string": { "node_modules/@tediousjs/connection-string": {
"version": "0.5.0", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz", "resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz",
@@ -3261,7 +3276,6 @@
"version": "2.8.19", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
@@ -3664,6 +3678,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.33", "version": "17.0.33",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
@@ -3900,6 +3923,17 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1"
}
},
"node_modules/abort-controller": { "node_modules/abort-controller": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -4120,6 +4154,15 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/aws-ssl-profiles": { "node_modules/aws-ssl-profiles": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@@ -4293,6 +4336,15 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.7", "version": "2.8.7",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.7.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.7.tgz",
@@ -5673,6 +5725,45 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/engine.io": {
"version": "6.6.6",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz",
"integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"@types/ws": "^8.5.12",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"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",
@@ -7215,6 +7306,48 @@
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/imapflow": {
"version": "1.2.18",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.2.18.tgz",
"integrity": "sha512-zxYvcG9ckj/UcTRs+ZDT+wJzW8DqkjgWZwc1z4Q28R/4C/1YvJieVETOuR/9ztCXcycURC50PJShMimITvz5wQ==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.2",
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1",
"nodemailer": "8.0.4",
"pino": "10.3.1",
"socks": "2.8.7"
}
},
"node_modules/imapflow/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/imapflow/node_modules/nodemailer": {
"version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/immediate": { "node_modules/immediate": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
@@ -7291,6 +7424,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -9039,6 +9181,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-pop3": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/node-pop3/-/node-pop3-0.11.0.tgz",
"integrity": "sha512-M5qRCamSTxu5lIVBW9q6XyC6nH30fZxTdTQDzfHRSaLl8CCiZMSh80rDnIysB6ECvh9j8sf8+KveEQpLDRmMYg==",
"license": "MIT",
"bin": {
"pop": "bin/pop.js"
},
"engines": {
"node": "^20.11.0 || >= 22.0.0"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.21", "version": "2.0.21",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz",
@@ -9212,6 +9366,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -9641,6 +9804,43 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/pirates": { "node_modules/pirates": {
"version": "4.0.7", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
@@ -9872,6 +10072,22 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/prompts": { "node_modules/prompts": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@@ -9993,6 +10209,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/quill": { "node_modules/quill": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
@@ -10187,6 +10409,15 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/redis": { "node_modules/redis": {
"version": "4.7.1", "version": "4.7.1",
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz", "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
@@ -10725,6 +10956,80 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.18.3"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -11098,6 +11403,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/thread-stream": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
"integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==",
"license": "MIT",
"dependencies": {
"real-require": "^0.2.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tlds": { "node_modules/tlds": {
"version": "1.260.0", "version": "1.260.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz",
@@ -11738,6 +12055,27 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0" "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
} }
}, },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/wsl-utils": { "node_modules/wsl-utils": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
+3
View File
@@ -40,6 +40,7 @@
"http-proxy-middleware": "^3.0.5", "http-proxy-middleware": "^3.0.5",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.0",
"imap": "^0.8.19", "imap": "^0.8.19",
"imapflow": "^1.2.18",
"joi": "^17.11.0", "joi": "^17.11.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mailparser": "^3.7.5", "mailparser": "^3.7.5",
@@ -48,6 +49,7 @@
"mysql2": "^3.15.0", "mysql2": "^3.15.0",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"node-pop3": "^0.11.0",
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"oracledb": "^6.9.0", "oracledb": "^6.9.0",
"pg": "^8.16.3", "pg": "^8.16.3",
@@ -55,6 +57,7 @@
"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",
"socket.io": "^4.8.3",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"winston": "^3.11.0" "winston": "^3.11.0"
}, },
+30
View File
@@ -44,6 +44,8 @@ process.on("SIGTERM", () => {
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작..."); logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
const { stopAiAssistant } = require("./utils/startAiAssistant"); const { stopAiAssistant } = require("./utils/startAiAssistant");
stopAiAssistant(); stopAiAssistant();
const { imapConnectionPool } = require("./services/imapConnectionPool");
imapConnectionPool.destroyAll();
process.exit(0); process.exit(0);
}); });
@@ -52,6 +54,8 @@ process.on("SIGINT", () => {
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작..."); logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
const { stopAiAssistant } = require("./utils/startAiAssistant"); const { stopAiAssistant } = require("./utils/startAiAssistant");
stopAiAssistant(); stopAiAssistant();
const { imapConnectionPool } = require("./services/imapConnectionPool");
imapConnectionPool.destroyAll();
process.exit(0); process.exit(0);
}); });
@@ -131,6 +135,8 @@ import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산
import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // 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 userMailRoutes from "./routes/userMailRoutes"; // 사용자 메일 계정
import messengerRoutes from "./routes/messengerRoutes"; // 메신저
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리 import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리 import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리 import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
@@ -156,6 +162,7 @@ import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리 import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리 import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import quoteRoutes from "./routes/quoteRoutes"; // 견적관리
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -375,10 +382,13 @@ 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/outbound", outboundRoutes); // 출고관리
app.use("/api/quotes", quoteRoutes); // 견적관리
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); // 차량 운행 이력 관리
app.use("/api/approval", approvalRoutes); // 결재 시스템 app.use("/api/approval", approvalRoutes); // 결재 시스템
app.use("/api/user-mail", userMailRoutes); // 사용자 메일 계정
app.use("/api/messenger", messengerRoutes); // 메신저
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);
@@ -406,6 +416,22 @@ const server = app.listen(PORT, HOST, async () => {
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`); logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`); logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
// Socket.IO initialization
try {
const { Server: SocketIOServer } = await import("socket.io");
const { initMessengerSocket } = await import("./socket/messengerSocket");
const { setIo } = await import("./socket/socketManager");
const io = new SocketIOServer(server, {
cors: { origin: "*", methods: ["GET", "POST"] },
path: "/socket.io",
});
setIo(io);
initMessengerSocket(io);
logger.info("💬 Socket.IO messenger initialized");
} catch (error) {
logger.error("❌ Socket.IO initialization failed:", error);
}
// 비동기 초기화 작업 (에러가 발생해도 서버는 유지) // 비동기 초기화 작업 (에러가 발생해도 서버는 유지)
initializeServices().catch(err => { initializeServices().catch(err => {
logger.error('❌ 서비스 초기화 중 치명적 에러 발생:', err); logger.error('❌ 서비스 초기화 중 치명적 에러 발생:', err);
@@ -421,12 +447,16 @@ async function initializeServices() {
runTableHistoryActionMigration, runTableHistoryActionMigration,
runDtgManagementLogMigration, runDtgManagementLogMigration,
runApprovalSystemMigration, runApprovalSystemMigration,
runUserMailAccountsMigration,
runMessengerMigration,
} = await import("./database/runMigration"); } = await import("./database/runMigration");
await runDashboardMigration(); await runDashboardMigration();
await runTableHistoryActionMigration(); await runTableHistoryActionMigration();
await runDtgManagementLogMigration(); await runDtgManagementLogMigration();
await runApprovalSystemMigration(); await runApprovalSystemMigration();
await runUserMailAccountsMigration();
await runMessengerMigration();
} catch (error) { } catch (error) {
logger.error(`❌ 마이그레이션 실패:`, error); logger.error(`❌ 마이그레이션 실패:`, error);
} }
@@ -0,0 +1,63 @@
import multer from 'multer';
import path from 'path';
import fs from 'fs';
// Upload directory
const UPLOAD_DIR = process.env.NODE_ENV === 'production'
? '/app/uploads/messenger-files'
: path.join(process.cwd(), 'uploads', 'messenger-files');
// Create directory if not exists
try {
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
} catch (error) {
console.error('Messenger file upload directory creation failed:', error);
}
// File storage config
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, UPLOAD_DIR);
},
filename: (req, file, cb) => {
try {
file.originalname = file.originalname.normalize('NFC');
const uniqueId = Date.now() + '-' + Math.round(Math.random() * 1e9);
const ext = path.extname(file.originalname);
cb(null, `${uniqueId}${ext}`);
} catch (error) {
console.error('Filename processing error:', error);
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}_error.tmp`);
}
},
});
// File filter - block dangerous extensions
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
try {
file.originalname = file.originalname.normalize('NFC');
} catch (error) {
// ignore normalization failure
}
const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.msi'];
const ext = path.extname(file.originalname).toLowerCase();
if (dangerousExtensions.includes(ext)) {
cb(new Error(`Security: ${ext} files are not allowed.`));
return;
}
cb(null, true);
};
export const uploadMessengerFile = multer({
storage,
fileFilter,
limits: {
fileSize: 20 * 1024 * 1024, // 20MB
files: 10,
},
});
+90 -77
View File
@@ -237,7 +237,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
// 회사 코드 필터 (권한 그룹 멤버 관리 시 사용) // 회사 코드 필터 (권한 그룹 멤버 관리 시 사용)
if (companyCode && typeof companyCode === "string" && companyCode.trim()) { if (companyCode && typeof companyCode === "string" && companyCode.trim()) {
whereConditions.push(`company_code = $${paramIndex}`); whereConditions.push(`u.company_code = $${paramIndex}`);
queryParams.push(companyCode.trim()); queryParams.push(companyCode.trim());
paramIndex++; paramIndex++;
logger.info("회사 코드 필터 적용", { companyCode }); logger.info("회사 코드 필터 적용", { companyCode });
@@ -246,7 +246,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
// 최고 관리자 필터링 (회사 관리자와 일반 사용자는 최고 관리자를 볼 수 없음) // 최고 관리자 필터링 (회사 관리자와 일반 사용자는 최고 관리자를 볼 수 없음)
if (req.user && req.user.companyCode !== "*") { if (req.user && req.user.companyCode !== "*") {
// 최고 관리자가 아닌 경우, company_code가 "*"인 사용자는 제외 // 최고 관리자가 아닌 경우, company_code가 "*"인 사용자는 제외
whereConditions.push(`company_code != '*'`); whereConditions.push(`u.company_code != '*'`);
logger.info("최고 관리자 필터링 적용", { logger.info("최고 관리자 필터링 적용", {
userCompanyCode: req.user.companyCode, userCompanyCode: req.user.companyCode,
}); });
@@ -259,15 +259,15 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
const searchTerm = search.trim(); const searchTerm = search.trim();
whereConditions.push(`( whereConditions.push(`(
sabun ILIKE $${paramIndex} OR u.sabun ILIKE $${paramIndex} OR
user_type_name ILIKE $${paramIndex} OR u.user_type_name ILIKE $${paramIndex} OR
dept_name ILIKE $${paramIndex} OR u.dept_name ILIKE $${paramIndex} OR
position_name ILIKE $${paramIndex} OR u.position_name ILIKE $${paramIndex} OR
user_id ILIKE $${paramIndex} OR u.user_id ILIKE $${paramIndex} OR
user_name ILIKE $${paramIndex} OR u.user_name ILIKE $${paramIndex} OR
tel ILIKE $${paramIndex} OR u.tel ILIKE $${paramIndex} OR
cell_phone ILIKE $${paramIndex} OR u.cell_phone ILIKE $${paramIndex} OR
email ILIKE $${paramIndex} u.email ILIKE $${paramIndex}
)`); )`);
queryParams.push(`%${searchTerm}%`); queryParams.push(`%${searchTerm}%`);
paramIndex++; paramIndex++;
@@ -277,21 +277,21 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
// 단일 필드 검색 // 단일 필드 검색
searchType = "single"; searchType = "single";
const fieldMap: { [key: string]: string } = { const fieldMap: { [key: string]: string } = {
sabun: "sabun", sabun: "u.sabun",
companyName: "user_type_name", companyName: "u.user_type_name",
deptName: "dept_name", deptName: "u.dept_name",
positionName: "position_name", positionName: "u.position_name",
userId: "user_id", userId: "u.user_id",
userName: "user_name", userName: "u.user_name",
tel: "tel", tel: "u.tel",
cellPhone: "cell_phone", cellPhone: "u.cell_phone",
email: "email", email: "u.email",
}; };
if (fieldMap[searchField as string]) { if (fieldMap[searchField as string]) {
if (searchField === "tel") { if (searchField === "tel") {
whereConditions.push( whereConditions.push(
`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})` `(u.tel ILIKE $${paramIndex} OR u.cell_phone ILIKE $${paramIndex})`
); );
queryParams.push(`%${searchValue}%`); queryParams.push(`%${searchValue}%`);
paramIndex++; paramIndex++;
@@ -307,13 +307,13 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
} else { } else {
// 고급 검색 (개별 필드별 AND 조건) // 고급 검색 (개별 필드별 AND 조건)
const advancedSearchFields = [ const advancedSearchFields = [
{ param: search_sabun, field: "sabun" }, { param: search_sabun, field: "u.sabun" },
{ param: search_companyName, field: "user_type_name" }, { param: search_companyName, field: "u.user_type_name" },
{ param: search_deptName, field: "dept_name" }, { param: search_deptName, field: "u.dept_name" },
{ param: search_positionName, field: "position_name" }, { param: search_positionName, field: "u.position_name" },
{ param: search_userId, field: "user_id" }, { param: search_userId, field: "u.user_id" },
{ param: search_userName, field: "user_name" }, { param: search_userName, field: "u.user_name" },
{ param: search_email, field: "email" }, { param: search_email, field: "u.email" },
]; ];
let hasAdvancedSearch = false; let hasAdvancedSearch = false;
@@ -330,7 +330,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
// 전화번호 검색 // 전화번호 검색
if (search_tel && typeof search_tel === "string" && search_tel.trim()) { if (search_tel && typeof search_tel === "string" && search_tel.trim()) {
whereConditions.push( whereConditions.push(
`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})` `(u.tel ILIKE $${paramIndex} OR u.cell_phone ILIKE $${paramIndex})`
); );
queryParams.push(`%${search_tel.trim()}%`); queryParams.push(`%${search_tel.trim()}%`);
paramIndex++; paramIndex++;
@@ -354,7 +354,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
// 현재 로그인한 사용자의 회사 코드 필터 (슈퍼관리자가 아닌 경우) // 현재 로그인한 사용자의 회사 코드 필터 (슈퍼관리자가 아닌 경우)
if (req.user && req.user.companyCode !== "*" && !companyCode) { if (req.user && req.user.companyCode !== "*" && !companyCode) {
whereConditions.push(`company_code = $${paramIndex}`); whereConditions.push(`u.company_code = $${paramIndex}`);
queryParams.push(req.user.companyCode); queryParams.push(req.user.companyCode);
paramIndex++; paramIndex++;
logger.info("사용자 회사 코드 필터 적용", { logger.info("사용자 회사 코드 필터 적용", {
@@ -364,13 +364,13 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
// 기존 필터들 // 기존 필터들
if (deptCode) { if (deptCode) {
whereConditions.push(`dept_code = $${paramIndex}`); whereConditions.push(`u.dept_code = $${paramIndex}`);
queryParams.push(deptCode); queryParams.push(deptCode);
paramIndex++; paramIndex++;
} }
if (status) { if (status) {
whereConditions.push(`status = $${paramIndex}`); whereConditions.push(`u.status = $${paramIndex}`);
queryParams.push(status); queryParams.push(status);
paramIndex++; paramIndex++;
} }
@@ -383,7 +383,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
// 총 개수 조회 // 총 개수 조회
const countQuery = ` const countQuery = `
SELECT COUNT(*) as total SELECT COUNT(*) as total
FROM user_info FROM user_info u
${whereClause} ${whereClause}
`; `;
const countResult = await query<{ total: string }>(countQuery, queryParams); const countResult = await query<{ total: string }>(countQuery, queryParams);
@@ -394,26 +394,28 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
const offset = (Number(page) - 1) * limit; const offset = (Number(page) - 1) * limit;
const usersQuery = ` const usersQuery = `
SELECT SELECT
sabun, u.sabun,
user_id, u.user_id,
user_name, u.user_name,
user_name_eng, u.user_name_eng,
dept_code, u.dept_code,
dept_name, u.dept_name,
position_code, u.position_code,
position_name, u.position_name,
email, u.email,
tel, u.tel,
cell_phone, u.cell_phone,
user_type, u.user_type,
user_type_name, u.user_type_name,
regdate, u.regdate,
status, u.status,
company_code, u.company_code,
locale u.locale,
FROM user_info c.company_name
FROM user_info u
LEFT JOIN company_mng c ON u.company_code = c.company_code
${whereClause} ${whereClause}
ORDER BY regdate DESC, user_name ASC ORDER BY u.regdate DESC, u.user_name ASC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`; `;
@@ -436,6 +438,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
userTypeName: user.user_type_name || null, userTypeName: user.user_type_name || null,
status: user.status || "active", status: user.status || "active",
companyCode: user.company_code || null, companyCode: user.company_code || null,
companyName: user.company_name || null,
locale: user.locale || null, locale: user.locale || null,
regDate: user.regdate regDate: user.regdate
? new Date(user.regdate).toISOString().split("T")[0] ? new Date(user.regdate).toISOString().split("T")[0]
@@ -1402,10 +1405,11 @@ export async function updateMenu(
] ]
); );
// menu_url이 비어있면 화면 할당 해제 (screen_menu_assignments의 is_active를 'N'으로) // menu_url이 비어있거나 화면관리 URL이 아니면 화면 할당 해제
if (!menuUrl) { const isScreenUrl = menuUrl && (menuUrl.startsWith("/screens/") || menuUrl.startsWith("/screen/"));
if (!menuUrl || !isScreenUrl) {
await query( await query(
`UPDATE screen_menu_assignments `UPDATE screen_menu_assignments
SET is_active = 'N' SET is_active = 'N'
WHERE menu_objid = $1 AND company_code = $2`, WHERE menu_objid = $1 AND company_code = $2`,
[Number(menuId), companyCode] [Number(menuId), companyCode]
@@ -2696,6 +2700,35 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
}); });
return; return;
} }
// SUPER_ADMIN 권한 부여는 최고관리자만 가능
const requestUser = req.user;
const isRequesterSuperAdmin = requestUser?.companyCode === "*" && requestUser?.userType === "SUPER_ADMIN";
if (userData.userType.trim() === "SUPER_ADMIN" && !isRequesterSuperAdmin) {
res.status(403).json({
success: false,
message: "최고 관리자 권한은 최고 관리자만 부여할 수 있습니다.",
error: { code: "FORBIDDEN_SUPER_ADMIN_GRANT" },
});
return;
}
// 기존 SUPER_ADMIN 사용자의 권한은 최고관리자만 변경 가능
if (isUpdate && !isRequesterSuperAdmin) {
const targetUser = await queryOne<{ user_type: string }>(
`SELECT user_type FROM user_info WHERE user_id = $1`,
[userData.userId?.trim()]
);
if (targetUser?.user_type === "SUPER_ADMIN") {
res.status(403).json({
success: false,
message: "최고 관리자의 권한은 다른 최고 관리자만 변경할 수 있습니다.",
error: { code: "FORBIDDEN_SUPER_ADMIN_MODIFY" },
});
return;
}
}
} }
// 4. 비밀번호 최소 길이 검증 (신규 등록 시) // 4. 비밀번호 최소 길이 검증 (신규 등록 시)
@@ -3696,17 +3729,6 @@ export const resetUserPassword = async (
return; return;
} }
// 비밀번호 길이 검증 (최소 4자)
if (newPassword.length < 4) {
res.status(400).json({
success: false,
result: false,
message: "비밀번호는 최소 4자 이상이어야 합니다.",
msg: "비밀번호는 최소 4자 이상이어야 합니다.",
});
return;
}
try { try {
// 1. Raw Query로 사용자 존재 여부 확인 // 1. Raw Query로 사용자 존재 여부 확인
const currentUser = await queryOne<any>( const currentUser = await queryOne<any>(
@@ -3724,19 +3746,10 @@ export const resetUserPassword = async (
return; return;
} }
// 2. 비밀번호 암호화 (기존 Java 로직과 동일) // 2. 비밀번호 암호화 (EncryptUtil 사용)
let encryptedPassword: string; let encryptedPassword: string;
try { try {
// EncryptUtil과 동일한 암호화 사용 encryptedPassword = EncryptUtil.encrypt(newPassword);
const crypto = require("crypto");
const keyName = "ILJIAESSECRETKEY";
const algorithm = "aes-128-ecb";
// AES-128-ECB 암호화
const cipher = crypto.createCipher(algorithm, keyName);
let encrypted = cipher.update(newPassword, "utf8", "hex");
encrypted += cipher.final("hex");
encryptedPassword = encrypted.toUpperCase();
} catch (encryptError) { } catch (encryptError) {
logger.error("비밀번호 암호화 중 오류 발생", { logger.error("비밀번호 암호화 중 오류 발생", {
error: encryptError, error: encryptError,
@@ -0,0 +1,220 @@
import { Request, Response } from 'express';
import { messengerService } from '../services/messengerService';
import { AuthenticatedRequest } from '../types/auth';
import { getIo } from '../socket/socketManager';
import path from 'path';
class MessengerController {
async getRooms(req: Request, res: Response) {
try {
const user = (req as AuthenticatedRequest).user!;
const rooms = await messengerService.getRooms(user.userId, user.companyCode!);
res.json({ success: true, data: rooms });
} catch (error) {
const err = error as Error;
console.error('getRooms error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
}
async createRoom(req: Request, res: Response) {
try {
const user = (req as AuthenticatedRequest).user!;
const room_type = req.body.room_type ?? req.body.type;
const room_name = req.body.room_name ?? req.body.name;
const participant_ids = req.body.participant_ids ?? req.body.participantIds;
if (!room_type || !participant_ids || !Array.isArray(participant_ids)) {
return res.status(400).json({ success: false, message: 'room_type and participant_ids are required.' });
}
const room = await messengerService.createRoom(user.userId, user.companyCode!, {
room_type,
room_name,
participant_ids,
});
res.json({ success: true, data: room });
} catch (error) {
const err = error as Error;
console.error('createRoom error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
}
async getMessages(req: Request, res: Response) {
try {
const user = (req as AuthenticatedRequest).user!;
const roomId = parseInt(req.params.roomId, 10);
const limit = parseInt(req.query.limit as string, 10) || 50;
const before = req.query.before ? parseInt(req.query.before as string, 10) : undefined;
const messages = await messengerService.getMessages(roomId, user.userId, user.companyCode!, limit, before);
res.json({ success: true, data: messages });
} catch (error) {
const err = error as Error;
console.error('getMessages error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
}
async sendMessage(req: Request, res: Response) {
try {
const user = (req as AuthenticatedRequest).user!;
const roomId = parseInt(req.params.roomId, 10);
const content = req.body.content;
const messageType = req.body.type ?? req.body.message_type ?? 'text';
const parentId = req.body.parentId ?? req.body.parent_message_id ?? null;
if (!content) {
return res.status(400).json({ success: false, message: 'content is required.' });
}
const message = await messengerService.sendMessage(roomId, user.userId, user.companyCode!, content, messageType, parentId);
// Broadcast to all room participants via Socket.IO
const io = getIo();
if (io) {
io.to(`${user.companyCode}:${roomId}`).emit('new_message', message);
}
res.json({ success: true, data: message });
} catch (error) {
const err = error as Error;
console.error('sendMessage error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
}
async markAsRead(req: Request, res: Response) {
try {
const user = (req as AuthenticatedRequest).user!;
const roomId = parseInt(req.params.roomId, 10);
await messengerService.markAsRead(roomId, user.userId);
res.json({ success: true });
} catch (error) {
const err = error as Error;
console.error('markAsRead error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
}
async uploadFile(req: Request, res: Response) {
try {
const user = (req as AuthenticatedRequest).user!;
const files = req.files as Express.Multer.File[];
if (!files || files.length === 0) {
return res.status(400).json({ success: false, message: 'No files uploaded.' });
}
const roomId = parseInt(req.body.room_id, 10);
if (!roomId) {
return res.status(400).json({ success: false, message: 'room_id is required.' });
}
const io = getIo();
const savedFiles = [];
for (const file of files) {
// Use a readable placeholder as content to avoid filename encoding issues
const isImage = file.mimetype.startsWith('image/');
const content = isImage ? '[이미지]' : '[파일]';
// Create a file message
const message = await messengerService.sendMessage(
roomId,
user.userId,
user.companyCode!,
content,
'file'
);
const savedFile = await messengerService.saveFile(message.id, {
originalName: file.originalname,
storedName: file.filename,
filePath: file.path,
fileSize: file.size,
mimeType: file.mimetype,
});
message.files = [savedFile];
// Broadcast to room so recipients receive it in real-time
io.to(`${user.companyCode}:${roomId}`).emit('new_message', message);
savedFiles.push({ message, file: savedFile });
}
res.json({ success: true, data: savedFiles });
} catch (error) {
const err = error as Error;
console.error('uploadFile error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
}
async downloadFile(req: Request, res: Response) {
try {
const fileId = parseInt(req.params.fileId, 10);
const file = await messengerService.getFileById(fileId);
if (!file) {
return res.status(404).json({ success: false, message: 'File not found.' });
}
res.download(file.file_path, file.original_name);
} catch (error) {
const err = error as Error;
console.error('downloadFile error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
}
async getCompanyUsers(req: Request, res: Response) {
try {
const user = (req as AuthenticatedRequest).user!;
const users = await messengerService.getCompanyUsers(user.companyCode!, user.userId);
res.json({ success: true, data: users });
} catch (error) {
const err = error as Error;
console.error('getCompanyUsers error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
}
async updateRoom(req: Request, res: Response) {
try {
const user = (req as AuthenticatedRequest).user!;
const roomId = parseInt(req.params.roomId, 10);
const { room_name } = req.body;
if (!room_name) {
return res.status(400).json({ success: false, message: 'room_name is required.' });
}
const room = await messengerService.updateRoom(roomId, user.companyCode!, room_name);
if (!room) {
return res.status(404).json({ success: false, message: 'Room not found.' });
}
res.json({ success: true, data: room });
} catch (error) {
const err = error as Error;
console.error('updateRoom error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
}
async getUnreadCount(req: Request, res: Response) {
try {
const user = (req as AuthenticatedRequest).user!;
const count = await messengerService.getUnreadCount(user.userId, user.companyCode!);
res.json({ success: true, data: { unread_count: count } });
} catch (error) {
const err = error as Error;
console.error('getUnreadCount error:', err.message);
res.status(500).json({ success: false, message: err.message });
}
}
}
export const messengerController = new MessengerController();
@@ -0,0 +1,84 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as quoteService from "../services/quoteService";
import { logger } from "../utils/logger";
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { search, status, startDate, endDate } = req.query as Record<string, string>;
const data = await quoteService.getList(companyCode, { search, status, startDate, endDate });
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 getById(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const data = await quoteService.getById(companyCode, parseInt(id));
if (!data) {
return res.status(404).json({ success: false, message: "견적을 찾을 수 없습니다." });
}
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 generateNumber(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const quoteNo = await quoteService.generateNumber(companyCode);
return res.json({ success: true, data: { quoteNo } });
} 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) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const data = await quoteService.create(companyCode, userId, req.body);
return res.status(201).json({ success: true, data, message: "견적이 등록되었습니다." });
} catch (error: any) {
logger.error("견적 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function update(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
await quoteService.update(companyCode, userId, parseInt(id), req.body);
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 remove(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await quoteService.remove(companyCode, parseInt(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 });
}
}
@@ -0,0 +1,358 @@
import { Response } from 'express';
import { AuthenticatedRequest } from '../types/auth';
import { userMailAccountService } from '../services/userMailAccountService';
import { userMailImapService } from '../services/userMailImapService';
import { userMailSmtpService } from '../services/userMailSmtpService';
import { encryptionService } from '../services/encryptionService';
import { imapConnectionPool } from '../services/imapConnectionPool';
import { mailCache } from '../services/mailCache';
class UserMailController {
async listAccounts(req: AuthenticatedRequest, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
const protocol = req.query.protocol as string | undefined;
const accounts = await userMailAccountService.getAccountsByUserId(userId, protocol);
// 비밀번호 제거 후 반환
const safe = accounts.map(({ password, ...rest }) => rest);
res.json({ success: true, data: safe });
} catch (error) {
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
}
}
async createAccount(req: AuthenticatedRequest, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
// 저장 전 연결 테스트 (임시 account 객체 사용, 평문 비밀번호를 암호화해서 전달)
const tempAccount = { ...req.body, id: 0, userId, status: 'active', password: encryptionService.encrypt(req.body.password) };
const service = userMailImapService;
const testResult = await service.testConnection(tempAccount);
if (!testResult.success) {
return res.status(400).json({ success: false, message: `연결 테스트 실패: ${testResult.message}` });
}
const account = await userMailAccountService.createAccount(userId, req.body);
const { password, ...safe } = account;
res.status(201).json({ success: true, data: safe });
} catch (error) {
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
}
}
async updateAccount(req: AuthenticatedRequest, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
const accountId = parseInt(req.params.accountId);
// 비밀번호 변경이 포함된 경우 연결 테스트
if (req.body.password) {
const existing = await userMailAccountService.getAccountById(accountId, userId);
if (!existing) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
const tempAccount = { ...existing, ...req.body, password: encryptionService.encrypt(req.body.password) };
const service = userMailImapService;
const testResult = await service.testConnection(tempAccount);
if (!testResult.success) {
return res.status(400).json({ success: false, message: `연결 테스트 실패: ${testResult.message}` });
}
}
const account = await userMailAccountService.updateAccount(accountId, userId, req.body);
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
imapConnectionPool.destroyByAccount(accountId);
mailCache.invalidateByPrefix(`mailList:${accountId}:`);
mailCache.invalidateByPrefix(`mailDetail:${accountId}:`);
const { password, ...safe } = account;
res.json({ success: true, data: safe });
} catch (error) {
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
}
}
async deleteAccount(req: AuthenticatedRequest, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
const accountId = parseInt(req.params.accountId);
const deleted = await userMailAccountService.deleteAccount(accountId, userId);
if (!deleted) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
imapConnectionPool.destroyByAccount(accountId);
mailCache.invalidateByPrefix(`mailList:${accountId}:`);
mailCache.invalidateByPrefix(`mailDetail:${accountId}:`);
res.json({ success: true, message: '계정이 삭제되었습니다.' });
} catch (error) {
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
}
}
async testConnectionDirect(req: AuthenticatedRequest, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
const { protocol, host, port, useTls, username, password } = req.body;
if (!protocol || !host || !port || !username || !password) {
return res.status(400).json({ success: false, message: '필수 항목 누락' });
}
const tempAccount = {
id: 0, userId, displayName: '', email: '', protocol, host, port,
useTls: useTls ?? true, username, status: 'active',
password: encryptionService.encrypt(password),
createdAt: new Date(), updatedAt: new Date(),
};
const service = userMailImapService;
const result = await service.testConnection(tempAccount);
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
}
}
async testConnection(req: AuthenticatedRequest, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
const accountId = parseInt(req.params.accountId);
const account = await userMailAccountService.getAccountById(accountId, userId);
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
const service = userMailImapService;
const result = await service.testConnection(account);
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
}
}
async listMails(req: AuthenticatedRequest, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
const accountId = parseInt(req.params.accountId);
const account = await userMailAccountService.getAccountById(accountId, userId);
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
const limit = parseInt(req.query.limit as string) || 50;
const service = userMailImapService;
const mails = await service.fetchMailList(account, limit);
res.json({ success: true, data: mails });
} catch (error) {
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
}
}
async streamMails(req: AuthenticatedRequest, res: Response) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const userId = (req as any).user?.userId;
const accountId = parseInt(req.params.accountId);
const limit = parseInt(req.query.limit as string) || 20;
const before = req.query.before ? parseInt(req.query.before as string) : null;
const account = await userMailAccountService.getAccountById(accountId, userId);
if (!account) {
res.write(`event: error\ndata: ${JSON.stringify({ message: '계정을 찾을 수 없습니다.' })}\n\n`);
return res.end();
}
let ended = false;
req.on('close', () => { ended = true; });
await userMailImapService.fetchMailListStream(
account, limit, before,
(mail) => {
if (!ended) res.write(`data: ${JSON.stringify(mail)}\n\n`);
},
() => {
if (!ended) {
res.write(`event: done\ndata: {}\n\n`);
res.end();
}
},
(err) => {
if (!ended) {
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
res.end();
}
}
);
}
async getMailDetail(req: AuthenticatedRequest, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
const accountId = parseInt(req.params.accountId);
const account = await userMailAccountService.getAccountById(accountId, userId);
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
const seqno = parseInt(req.params.seqno);
const service = userMailImapService;
const detail = await service.getMailDetail(account, seqno);
if (!detail) return res.status(404).json({ success: false, message: '메일을 찾을 수 없습니다.' });
res.json({ success: true, data: detail });
} catch (error) {
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
}
}
async markAsRead(req: AuthenticatedRequest, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
const accountId = parseInt(req.params.accountId);
const account = await userMailAccountService.getAccountById(accountId, userId);
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
const seqno = parseInt(req.params.seqno);
const service = userMailImapService;
const result = await service.markAsRead(account, seqno);
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
}
}
async deleteMail(req: AuthenticatedRequest, res: Response) {
try {
const userId = (req as any).user?.userId;
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
const accountId = parseInt(req.params.accountId);
const account = await userMailAccountService.getAccountById(accountId, userId);
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
const seqno = parseInt(req.params.seqno);
const service = userMailImapService;
const result = await service.deleteMail(account, seqno);
res.json({ success: true, data: result });
} catch (error) {
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
}
}
async listFolders(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const accountId = parseInt(req.params.accountId);
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
const folders = await userMailImapService.listFolders(account);
res.json({ success: true, data: folders });
} catch (err) {
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
}
}
async streamFolderMails(req: AuthenticatedRequest, res: Response): Promise<void> {
const accountId = parseInt(req.params.accountId);
const folder = decodeURIComponent(req.params.folder);
const limit = parseInt(req.query.limit as string) || 20;
const before = req.query.before ? parseInt(req.query.before as string) : null;
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
await userMailImapService.streamMailsByFolder(
account, folder, limit, before,
(mail) => {
res.write(`event: message\ndata: ${JSON.stringify(mail)}\n\n`);
},
() => {
res.write(`event: done\ndata: {}\n\n`);
res.end();
},
(err) => {
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
res.end();
}
);
}
async moveMail(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const accountId = parseInt(req.params.accountId);
const seqno = parseInt(req.params.seqno);
const { targetFolder } = req.body;
if (!targetFolder) { res.status(400).json({ success: false, message: 'targetFolder 필요' }); return; }
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
const result = await userMailImapService.moveMail(account, seqno, targetFolder);
res.json(result);
} catch (err) {
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
}
}
async getAttachments(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const accountId = parseInt(req.params.accountId);
const seqno = parseInt(req.params.seqno);
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
const folder = (req.query.folder as string) || 'INBOX';
const attachments = await userMailImapService.getAttachmentList(account, seqno, folder);
res.json({ success: true, data: attachments });
} catch (err) {
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
}
}
async downloadAttachment(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const accountId = parseInt(req.params.accountId);
const seqno = parseInt(req.params.seqno);
const partId = decodeURIComponent(req.params.partId);
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
const folder = (req.query.folder as string) || 'INBOX';
const filenameHint = (req.params.filename as string | undefined) || (req.query.filename as string | undefined);
await userMailImapService.downloadAttachment(account, seqno, partId, res, folder, filenameHint);
} catch (err) {
if (!res.headersSent) {
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
}
}
}
async sendMail(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const accountId = parseInt(req.params.accountId);
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
const result = await userMailSmtpService.sendMail(account, req.body);
res.json(result);
} catch (err) {
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
}
}
}
export const userMailController = new UserMailController();
+58
View File
@@ -112,6 +112,64 @@ export async function runTableHistoryActionMigration() {
/** /**
* DTG Management 테이블 이력 시스템 마이그레이션 * DTG Management 테이블 이력 시스템 마이그레이션
*/ */
export async function runUserMailAccountsMigration() {
try {
console.log("🔄 사용자 메일 계정 테이블 마이그레이션 시작...");
await PostgreSQLService.query(`
CREATE TABLE IF NOT EXISTS user_mail_accounts (
id SERIAL PRIMARY KEY,
user_id VARCHAR(100) NOT NULL,
display_name VARCHAR(200) NOT NULL,
email VARCHAR(255) NOT NULL,
protocol VARCHAR(10) NOT NULL CHECK (protocol IN ('imap', 'pop3')),
host VARCHAR(255) NOT NULL,
port INTEGER NOT NULL,
use_tls BOOLEAN NOT NULL DEFAULT true,
username VARCHAR(255) NOT NULL,
password TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await PostgreSQLService.query(`
CREATE INDEX IF NOT EXISTS idx_user_mail_accounts_user_id ON user_mail_accounts(user_id)
`);
console.log("✅ 사용자 메일 계정 테이블 마이그레이션 완료!");
} catch (error) {
console.error("❌ 사용자 메일 계정 테이블 마이그레이션 실패:", error);
}
}
/**
* Messenger tables migration
*/
export async function runMessengerMigration() {
try {
console.log("🔄 메신저 테이블 마이그레이션 시작...");
const sqlFilePath = path.join(
__dirname,
"../../../db/migrations/messenger_tables.sql"
);
if (!fs.existsSync(sqlFilePath)) {
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
return;
}
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
await PostgreSQLService.query(sqlContent);
console.log("✅ 메신저 테이블 마이그레이션 완료!");
} catch (error) {
console.error("❌ 메신저 테이블 마이그레이션 실패:", error);
if (error instanceof Error && error.message.includes("already exists")) {
console.log("ℹ️ 테이블이 이미 존재합니다.");
}
}
}
export async function runDtgManagementLogMigration() { export async function runDtgManagementLogMigration() {
try { try {
console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작..."); console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작...");
@@ -29,9 +29,9 @@ export const authenticateToken = async (
next: NextFunction next: NextFunction
): Promise<void> => { ): Promise<void> => {
try { try {
// Authorization 헤더에서 토큰 추출 // Authorization 헤더 또는 query param에서 토큰 추출 (파일 다운로드용)
const authHeader = req.get("Authorization"); const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN const token = (authHeader && authHeader.split(" ")[1]) || (req.query.token as string) || null;
if (!token) { if (!token) {
res.status(401).json({ res.status(401).json({
@@ -15,6 +15,7 @@ export interface AuthenticatedRequest extends Request {
userId: string; userId: string;
userName: string; userName: string;
companyCode: string; companyCode: string;
userType?: string;
userLang?: string; userLang?: string;
}; };
} }
@@ -47,8 +48,9 @@ export const requireSuperAdmin = (
return; return;
} }
// 슈퍼관리자 권한 확인 (회사코드가 '*'인 사용자) // 슈퍼관리자 권한 확인 (회사코드가 '*'이거나 userType이 'SUPER_ADMIN'인 사용자)
if (req.user.companyCode !== "*") { // 회사전환 후에도 SUPER_ADMIN은 관리 기능에 접근 가능해야 함
if (req.user.companyCode !== "*" && req.user.userType !== "SUPER_ADMIN") {
logger.warn("DDL 실행 시도 - 권한 부족", { logger.warn("DDL 실행 시도 - 권한 부족", {
userId: req.user.userId, userId: req.user.userId,
companyCode: req.user.companyCode, companyCode: req.user.companyCode,
@@ -167,7 +169,7 @@ export const validateDDLPermission = (
* 사용자가 슈퍼관리자인지 확인하는 유틸리티 함수 * 사용자가 슈퍼관리자인지 확인하는 유틸리티 함수
*/ */
export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => { export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => {
return user?.companyCode === "*"; return user?.companyCode === "*" || user?.userType === "SUPER_ADMIN";
}; };
/** /**
+8 -7
View File
@@ -33,6 +33,7 @@ import {
getTableSchema, // 테이블 스키마 조회 getTableSchema, // 테이블 스키마 조회
} from "../controllers/adminController"; } from "../controllers/adminController";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
import { requireSuperAdmin } from "../middleware/permissionMiddleware";
const router = Router(); const router = Router();
@@ -68,13 +69,13 @@ router.delete("/users/:userId", deleteUser); // 사용자 삭제 (soft delete)
// 부서 관리 API // 부서 관리 API
router.get("/departments", getDepartmentList); // 부서 목록 조회 router.get("/departments", getDepartmentList); // 부서 목록 조회
// 회사 관리 API // 회사 관리 API (최고관리자 전용)
router.get("/companies", getCompanyList); router.get("/companies", requireSuperAdmin, getCompanyList);
router.get("/companies/db", getCompanyListFromDB); // 실제 DB에서 회사 목록 조회 router.get("/companies/db", requireSuperAdmin, getCompanyListFromDB);
router.get("/companies/:companyCode", getCompanyByCode); // 회사 단건 조회 router.get("/companies/:companyCode", requireSuperAdmin, getCompanyByCode);
router.post("/companies", createCompany); // 회사 등록 router.post("/companies", requireSuperAdmin, createCompany);
router.put("/companies/:companyCode", updateCompany); // 회사 수정 router.put("/companies/:companyCode", requireSuperAdmin, updateCompany);
router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제 router.delete("/companies/:companyCode", requireSuperAdmin, deleteCompany);
// 사용자 로케일 API // 사용자 로케일 API
router.get("/user-locale", getUserLocale); router.get("/user-locale", getUserLocale);
@@ -1,5 +1,6 @@
import express from "express"; import express from "express";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
import { requireSuperAdmin } from "../middleware/permissionMiddleware";
import { AuthenticatedRequest } from "../types/auth"; import { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { FileSystemManager } from "../utils/fileSystemManager"; import { FileSystemManager } from "../utils/fileSystemManager";
@@ -7,8 +8,9 @@ import { query, queryOne } from "../database/db";
const router = express.Router(); const router = express.Router();
// 모든 라우트에 인증 미들웨어 적용 // 모든 라우트에 인증 + 최고관리자 권한 필수
router.use(authenticateToken); router.use(authenticateToken);
router.use(requireSuperAdmin);
/** /**
* DELETE /api/company-management/:companyCode * DELETE /api/company-management/:companyCode
@@ -0,0 +1,45 @@
import { Router } from 'express';
import { messengerController } from '../controllers/messengerController';
import { authenticateToken } from '../middleware/authMiddleware';
import { uploadMessengerFile } from '../config/multerMessengerConfig';
const router = Router();
// All messenger routes require authentication
router.use(authenticateToken);
// GET /api/messenger/rooms - Get my rooms
router.get('/rooms', (req, res) => messengerController.getRooms(req, res));
// POST /api/messenger/rooms - Create a room
router.post('/rooms', (req, res) => messengerController.createRoom(req, res));
// GET /api/messenger/rooms/:roomId/messages - Get messages
router.get('/rooms/:roomId/messages', (req, res) => messengerController.getMessages(req, res));
// POST /api/messenger/rooms/:roomId/messages - Send message
router.post('/rooms/:roomId/messages', (req, res) => messengerController.sendMessage(req, res));
// POST /api/messenger/rooms/:roomId/read - Mark as read
router.post('/rooms/:roomId/read', (req, res) => messengerController.markAsRead(req, res));
// PUT /api/messenger/rooms/:roomId - Update room
router.put('/rooms/:roomId', (req, res) => messengerController.updateRoom(req, res));
// POST /api/messenger/files/upload - Upload files
router.post(
'/files/upload',
uploadMessengerFile.array('files', 10),
(req, res) => messengerController.uploadFile(req, res)
);
// GET /api/messenger/files/:fileId - Download file
router.get('/files/:fileId', (req, res) => messengerController.downloadFile(req, res));
// GET /api/messenger/users - Get company users
router.get('/users', (req, res) => messengerController.getCompanyUsers(req, res));
// GET /api/messenger/unread - Get unread count
router.get('/unread', (req, res) => messengerController.getUnreadCount(req, res));
export default router;
+15
View File
@@ -0,0 +1,15 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as quoteController from "../controllers/quoteController";
const router = Router();
router.use(authenticateToken);
router.get("/list", quoteController.getList);
router.get("/generate-number", quoteController.generateNumber);
router.get("/:id", quoteController.getById);
router.post("/", quoteController.create);
router.put("/:id", quoteController.update);
router.delete("/:id", quoteController.remove);
export default router;
+26
View File
@@ -0,0 +1,26 @@
import express from 'express';
import { authenticateToken } from '../middleware/authMiddleware';
import { userMailController } from '../controllers/userMailController';
const router = express.Router();
router.use(authenticateToken);
router.post('/test-connection', (req, res) => userMailController.testConnectionDirect(req as any, res));
router.get('/accounts', (req, res) => userMailController.listAccounts(req as any, res));
router.post('/accounts', (req, res) => userMailController.createAccount(req as any, res));
router.put('/accounts/:accountId', (req, res) => userMailController.updateAccount(req as any, res));
router.delete('/accounts/:accountId', (req, res) => userMailController.deleteAccount(req as any, res));
router.post('/accounts/:accountId/test', (req, res) => userMailController.testConnection(req as any, res));
router.get('/accounts/:accountId/mails/stream', (req, res) => userMailController.streamMails(req as any, res));
router.get('/accounts/:accountId/mails', (req, res) => userMailController.listMails(req as any, res));
router.get('/accounts/:accountId/mails/:seqno', (req, res) => userMailController.getMailDetail(req as any, res));
router.post('/accounts/:accountId/mails/:seqno/mark-read', (req, res) => userMailController.markAsRead(req as any, res));
router.delete('/accounts/:accountId/mails/:seqno', (req, res) => userMailController.deleteMail(req as any, res));
router.get('/accounts/:accountId/folders', (req, res) => userMailController.listFolders(req as any, res));
router.get('/accounts/:accountId/folders/:folder/mails/stream', (req, res) => userMailController.streamFolderMails(req as any, res));
router.post('/accounts/:accountId/mails/:seqno/move', (req, res) => userMailController.moveMail(req as any, res));
router.get('/accounts/:accountId/mails/:seqno/attachments', (req, res) => userMailController.getAttachments(req as any, res));
router.get('/accounts/:accountId/mails/:seqno/attachment/:partId', (req, res) => userMailController.downloadAttachment(req as any, res));
router.post('/accounts/:accountId/send', (req, res) => userMailController.sendMail(req as any, res));
export default router;
@@ -14,7 +14,7 @@ class EncryptionService {
encrypt(text: string): string { encrypt(text: string): string {
const iv = crypto.randomBytes(16); const iv = crypto.randomBytes(16);
const cipher = crypto.createCipher(this.algorithm, this.key); const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex'); let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex'); encrypted += cipher.final('hex');
@@ -34,7 +34,7 @@ class EncryptionService {
const iv = Buffer.from(ivHex, 'hex'); const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex'); const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipher(this.algorithm, this.key); const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
decipher.setAuthTag(authTag); decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8'); let decrypted = decipher.update(encrypted, 'hex', 'utf8');
@@ -0,0 +1,109 @@
import { ImapFlow } from 'imapflow';
import { encryptionService } from './encryptionService';
import { UserMailAccount } from './userMailAccountService';
interface PoolEntry {
client: ImapFlow;
accountId: number;
lastUsed: number;
busy: boolean;
queue: Array<{ fn: (client: ImapFlow) => Promise<any>; resolve: (v: any) => void; reject: (e: any) => void }>;
}
class ImapConnectionPool {
private pool = new Map<number, PoolEntry>();
private readonly maxIdleMs = 300_000;
constructor() {
setInterval(() => this.cleanupIdle(), 60_000);
process.on('SIGTERM', () => this.destroyAll());
process.on('SIGINT', () => this.destroyAll());
}
async execute<T>(account: UserMailAccount, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
const decryptedPassword = encryptionService.decrypt(account.password);
let entry = this.pool.get(account.id);
if (entry && !entry.client.usable) {
this.pool.delete(account.id);
entry = undefined;
}
if (!entry) {
const client = new ImapFlow({
host: account.host,
port: account.port,
secure: account.useTls,
auth: { user: account.username, pass: decryptedPassword },
logger: false as any,
tls: { rejectUnauthorized: false },
});
await client.connect();
entry = { client, accountId: account.id, lastUsed: Date.now(), busy: false, queue: [] };
this.pool.set(account.id, entry);
client.on('close', () => {
const e = this.pool.get(account.id);
if (e && e.client === client) {
this.pool.delete(account.id);
for (const pending of e.queue) pending.reject(new Error('IMAP 연결이 끊겼습니다'));
e.queue = [];
}
});
}
if (entry.busy) {
return new Promise<T>((resolve, reject) => {
entry!.queue.push({ fn: fn as any, resolve, reject });
});
}
return this.runWithEntry(entry, fn);
}
private async runWithEntry<T>(entry: PoolEntry, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
entry.busy = true;
entry.lastUsed = Date.now();
try {
return await fn(entry.client);
} catch (err) {
if (!entry.client.usable) {
this.pool.delete(entry.accountId);
}
throw err;
} finally {
entry.busy = false;
if (entry.queue.length > 0) {
const next = entry.queue.shift()!;
this.runWithEntry(entry, next.fn).then(next.resolve).catch(next.reject);
}
}
}
private cleanupIdle() {
const now = Date.now();
for (const [id, entry] of this.pool.entries()) {
if (!entry.busy && entry.queue.length === 0 && now - entry.lastUsed > this.maxIdleMs) {
try { entry.client.logout(); } catch {}
this.pool.delete(id);
}
}
}
destroyByAccount(accountId: number) {
const entry = this.pool.get(accountId);
if (entry) {
try { entry.client.logout(); } catch {}
this.pool.delete(accountId);
}
}
destroyAll() {
for (const entry of this.pool.values()) {
try { entry.client.logout(); } catch {}
}
this.pool.clear();
}
}
export const imapConnectionPool = new ImapConnectionPool();
+43
View File
@@ -0,0 +1,43 @@
interface CacheEntry<T> {
data: T;
expiresAt: number;
}
class MailCache {
private cache = new Map<string, CacheEntry<any>>();
private readonly maxEntries = 1000;
constructor() {
setInterval(() => this.sweep(), 60_000);
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
return null;
}
return entry.data as T;
}
set<T>(key: string, data: T, ttlMs: number) {
if (this.cache.size >= this.maxEntries) this.sweep();
this.cache.set(key, { data, expiresAt: Date.now() + ttlMs });
}
invalidateByPrefix(prefix: string) {
for (const key of this.cache.keys()) {
if (key.startsWith(prefix)) this.cache.delete(key);
}
}
private sweep() {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (now > entry.expiresAt) this.cache.delete(key);
}
}
}
export const mailCache = new MailCache();
@@ -0,0 +1,405 @@
import { PostgreSQLService } from '../database/PostgreSQLService';
import {
MessengerRoom,
MessengerMessage,
MessengerFile,
MessengerUser,
CreateRoomRequest,
MessengerParticipant,
} from '../types/messenger';
class MessengerService {
/**
* Get rooms for a user with last message and unread count
*/
async getRooms(userId: string, companyCode: string): Promise<MessengerRoom[]> {
const result = await PostgreSQLService.query(
`SELECT r.*,
m.content AS last_message,
m.created_at AS last_message_at,
m.sender_id AS last_sender_id,
COALESCE(unread.cnt, 0)::int AS unread_count
FROM messenger_rooms r
INNER JOIN messenger_participants p ON p.room_id = r.id AND p.user_id = $1
LEFT JOIN LATERAL (
SELECT content, created_at, sender_id
FROM messenger_messages
WHERE room_id = r.id
ORDER BY created_at DESC
LIMIT 1
) m ON true
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS cnt
FROM messenger_messages
WHERE room_id = r.id
AND created_at > p.last_read_at
AND sender_id != $1
) unread ON true
WHERE r.company_code = $2
ORDER BY COALESCE(m.created_at, r.created_at) DESC`,
[userId, companyCode]
);
// Attach participants to each room
const rooms: MessengerRoom[] = result.rows;
if (rooms.length > 0) {
const roomIds = rooms.map((r) => r.id);
const partResult = await PostgreSQLService.query(
`SELECT mp.*, ui.user_name, ui.dept_name,
CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS photo
FROM messenger_participants mp
LEFT JOIN user_info ui ON ui.user_id = mp.user_id AND ui.company_code = mp.company_code
WHERE mp.room_id = ANY($1)`,
[roomIds]
);
const partMap = new Map<number, MessengerParticipant[]>();
for (const p of partResult.rows) {
if (!partMap.has(p.room_id)) partMap.set(p.room_id, []);
partMap.get(p.room_id)!.push(p);
}
for (const room of rooms) {
room.participants = partMap.get(room.id) || [];
}
}
return rooms;
}
/**
* Create a room. For DM, return existing room if one already exists between the two users.
*/
async createRoom(
creatorId: string,
companyCode: string,
data: CreateRoomRequest
): Promise<MessengerRoom> {
// DM duplicate check
if (data.room_type === 'dm' && data.participant_ids.length === 1) {
const otherUserId = data.participant_ids[0];
const existing = await PostgreSQLService.query(
`SELECT r.* FROM messenger_rooms r
WHERE r.company_code = $1 AND r.room_type = 'dm'
AND EXISTS (SELECT 1 FROM messenger_participants WHERE room_id = r.id AND user_id = $2)
AND EXISTS (SELECT 1 FROM messenger_participants WHERE room_id = r.id AND user_id = $3)
AND (SELECT COUNT(*) FROM messenger_participants WHERE room_id = r.id) = 2
LIMIT 1`,
[companyCode, creatorId, otherUserId]
);
if (existing.rows.length > 0) {
return existing.rows[0];
}
}
// Create room
const roomResult = await PostgreSQLService.query(
`INSERT INTO messenger_rooms (company_code, room_type, room_name, created_by)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[companyCode, data.room_type, data.room_name || null, creatorId]
);
const room: MessengerRoom = roomResult.rows[0];
// Add participants (creator + others)
const allParticipants = [creatorId, ...data.participant_ids.filter((id) => id !== creatorId)];
for (const uid of allParticipants) {
await PostgreSQLService.query(
`INSERT INTO messenger_participants (room_id, user_id, company_code)
VALUES ($1, $2, $3)
ON CONFLICT (room_id, user_id) DO NOTHING`,
[room.id, uid, companyCode]
);
}
return room;
}
/**
* Get messages with cursor-based pagination
*/
async getMessages(
roomId: number,
userId: string,
companyCode: string,
limit: number = 50,
before?: number
): Promise<MessengerMessage[]> {
let query: string;
let params: any[];
if (before) {
query = `SELECT msg.*,
ui.user_name AS sender_name,
CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS sender_photo,
COALESCE(tc.thread_count, 0)::int AS thread_count
FROM messenger_messages msg
LEFT JOIN user_info ui ON ui.user_id = msg.sender_id AND ui.company_code = msg.company_code
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS thread_count
FROM messenger_messages
WHERE parent_message_id = msg.id
) tc ON true
WHERE msg.room_id = $1 AND msg.company_code = $2 AND msg.id < $3
ORDER BY msg.created_at DESC
LIMIT $4`;
params = [roomId, companyCode, before, limit];
} else {
query = `SELECT msg.*,
ui.user_name AS sender_name,
CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS sender_photo,
COALESCE(tc.thread_count, 0)::int AS thread_count
FROM messenger_messages msg
LEFT JOIN user_info ui ON ui.user_id = msg.sender_id AND ui.company_code = msg.company_code
LEFT JOIN LATERAL (
SELECT COUNT(*)::int AS thread_count
FROM messenger_messages
WHERE parent_message_id = msg.id
) tc ON true
WHERE msg.room_id = $1 AND msg.company_code = $2
ORDER BY msg.created_at DESC
LIMIT $3`;
params = [roomId, companyCode, limit];
}
const result = await PostgreSQLService.query(query, params);
// Reverse so messages are in chronological order (query uses DESC for cursor pagination)
const messages: MessengerMessage[] = result.rows.reverse();
// Attach reactions and files
if (messages.length > 0) {
const msgIds = messages.map((m) => m.id);
const [reactionsResult, filesResult] = await Promise.all([
PostgreSQLService.query(
`SELECT * FROM messenger_reactions WHERE message_id = ANY($1)`,
[msgIds]
),
PostgreSQLService.query(
`SELECT * FROM messenger_files WHERE message_id = ANY($1)`,
[msgIds]
),
]);
const reactionsMap = new Map<number, any[]>();
for (const r of reactionsResult.rows) {
if (!reactionsMap.has(r.message_id)) reactionsMap.set(r.message_id, []);
reactionsMap.get(r.message_id)!.push(r);
}
const filesMap = new Map<number, any[]>();
for (const f of filesResult.rows) {
if (!filesMap.has(f.message_id)) filesMap.set(f.message_id, []);
filesMap.get(f.message_id)!.push(f);
}
for (const msg of messages) {
msg.reactions = reactionsMap.get(msg.id) || [];
msg.files = filesMap.get(msg.id) || [];
}
}
return messages;
}
/**
* Send a message and return the saved message
*/
async sendMessage(
roomId: number,
senderId: string,
companyCode: string,
content: string,
messageType: string = 'text',
parentMessageId?: number
): Promise<MessengerMessage> {
const result = await PostgreSQLService.query(
`INSERT INTO messenger_messages (room_id, sender_id, company_code, content, message_type, parent_message_id)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[roomId, senderId, companyCode, content, messageType, parentMessageId || null]
);
// Update room's updated_at
await PostgreSQLService.query(
`UPDATE messenger_rooms SET updated_at = NOW() WHERE id = $1`,
[roomId]
);
// Get sender info
const userResult = await PostgreSQLService.query(
`SELECT user_name,
CASE WHEN photo IS NOT NULL THEN encode(photo, 'base64') ELSE NULL END AS photo
FROM user_info WHERE user_id = $1 AND company_code = $2`,
[senderId, companyCode]
);
const message = result.rows[0];
if (userResult.rows.length > 0) {
message.sender_name = userResult.rows[0].user_name;
message.sender_photo = userResult.rows[0].photo;
}
message.reactions = [];
message.files = [];
return message;
}
/**
* Mark messages as read
*/
async markAsRead(roomId: number, userId: string): Promise<void> {
await PostgreSQLService.query(
`UPDATE messenger_participants SET last_read_at = NOW()
WHERE room_id = $1 AND user_id = $2`,
[roomId, userId]
);
}
/**
* Get company users for user picker
*/
async getCompanyUsers(companyCode: string, excludeUserId?: string): Promise<MessengerUser[]> {
let query: string;
let params: any[];
if (excludeUserId) {
query = `SELECT user_id, user_name, dept_name, email,
CASE WHEN photo IS NOT NULL THEN encode(photo, 'base64') ELSE NULL END AS photo
FROM user_info
WHERE company_code = $1 AND user_id != $2
ORDER BY user_name`;
params = [companyCode, excludeUserId];
} else {
query = `SELECT user_id, user_name, dept_name, email,
CASE WHEN photo IS NOT NULL THEN encode(photo, 'base64') ELSE NULL END AS photo
FROM user_info
WHERE company_code = $1
ORDER BY user_name`;
params = [companyCode];
}
const result = await PostgreSQLService.query(query, params);
return result.rows;
}
/**
* Add a reaction to a message
*/
async addReaction(messageId: number, userId: string, emoji: string): Promise<void> {
await PostgreSQLService.query(
`INSERT INTO messenger_reactions (message_id, user_id, emoji)
VALUES ($1, $2, $3)
ON CONFLICT (message_id, user_id, emoji) DO NOTHING`,
[messageId, userId, emoji]
);
}
/**
* Remove a reaction from a message
*/
async removeReaction(messageId: number, userId: string, emoji: string): Promise<void> {
await PostgreSQLService.query(
`DELETE FROM messenger_reactions
WHERE message_id = $1 AND user_id = $2 AND emoji = $3`,
[messageId, userId, emoji]
);
}
/**
* Get total unread message count for badge
*/
async getUnreadCount(userId: string, companyCode: string): Promise<number> {
const result = await PostgreSQLService.query(
`SELECT COALESCE(SUM(cnt), 0)::int AS total_unread
FROM (
SELECT COUNT(*) AS cnt
FROM messenger_participants p
INNER JOIN messenger_messages m ON m.room_id = p.room_id
AND m.created_at > p.last_read_at
AND m.sender_id != $1
WHERE p.user_id = $1 AND p.company_code = $2
GROUP BY p.room_id
) sub`,
[userId, companyCode]
);
return result.rows[0]?.total_unread || 0;
}
/**
* Save file info for a message
*/
async saveFile(
messageId: number,
fileInfo: { originalName: string; storedName: string; filePath: string; fileSize: number; mimeType: string }
): Promise<MessengerFile> {
const result = await PostgreSQLService.query(
`INSERT INTO messenger_files (message_id, original_name, stored_name, file_path, file_size, mime_type)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[messageId, fileInfo.originalName, fileInfo.storedName, fileInfo.filePath, fileInfo.fileSize, fileInfo.mimeType]
);
return result.rows[0];
}
/**
* Get room by ID with participants
*/
async getRoomById(roomId: number, companyCode: string): Promise<MessengerRoom | null> {
const result = await PostgreSQLService.query(
`SELECT * FROM messenger_rooms WHERE id = $1 AND company_code = $2`,
[roomId, companyCode]
);
if (result.rows.length === 0) return null;
const room: MessengerRoom = result.rows[0];
const partResult = await PostgreSQLService.query(
`SELECT mp.*, ui.user_name, ui.dept_name,
CASE WHEN ui.photo IS NOT NULL THEN encode(ui.photo, 'base64') ELSE NULL END AS photo
FROM messenger_participants mp
LEFT JOIN user_info ui ON ui.user_id = mp.user_id AND ui.company_code = mp.company_code
WHERE mp.room_id = $1`,
[roomId]
);
room.participants = partResult.rows;
return room;
}
/**
* Update room name
*/
async updateRoom(roomId: number, companyCode: string, roomName: string): Promise<MessengerRoom | null> {
const result = await PostgreSQLService.query(
`UPDATE messenger_rooms SET room_name = $1, updated_at = NOW()
WHERE id = $2 AND company_code = $3
RETURNING *`,
[roomName, roomId, companyCode]
);
return result.rows.length > 0 ? result.rows[0] : null;
}
/**
* Get file by ID
*/
async getFileById(fileId: number): Promise<MessengerFile | null> {
const result = await PostgreSQLService.query(
`SELECT * FROM messenger_files WHERE id = $1`,
[fileId]
);
return result.rows.length > 0 ? result.rows[0] : null;
}
/**
* Get participant room IDs for socket join
*/
async getUserRoomIds(userId: string, companyCode: string): Promise<number[]> {
const result = await PostgreSQLService.query(
`SELECT room_id FROM messenger_participants
WHERE user_id = $1 AND company_code = $2`,
[userId, companyCode]
);
return result.rows.map((r: any) => r.room_id);
}
}
export const messengerService = new MessengerService();
+321
View File
@@ -0,0 +1,321 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
interface QuoteFilter {
search?: string;
status?: string;
startDate?: string;
endDate?: string;
}
interface QuoteBody {
quote_no?: string;
quote_date: string;
valid_until?: string;
customer_objid?: number;
customer_name?: string;
status?: string;
manager?: string;
domestic_type?: string;
payment_terms?: string;
delivery_method?: string;
notes?: string;
customer_ceo?: string;
customer_biz_no?: string;
customer_address?: string;
customer_contact?: string;
customer_phone?: string;
incoterms?: string;
currency?: string;
exchange_rate?: number;
port_of_loading?: string;
port_of_discharge?: string;
shipment_date?: string;
hs_code?: string;
country_of_origin?: string;
lc_number?: string;
trade_notes?: string;
items?: QuoteItem[];
}
interface QuoteItem {
item_no?: number;
item_code?: string;
item_name?: string;
spec?: string;
qty?: number;
unit?: string;
request_length?: number;
unit_price?: number;
supply_amount?: number;
vat_amount?: number;
total_amount?: number;
notes?: string;
}
export async function getList(companyCode: string, filter: QuoteFilter) {
const pool = getPool();
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`q.company_code = $${idx}`);
params.push(companyCode);
idx++;
}
conditions.push(`q.use_yn = 'Y'`);
if (filter.search) {
conditions.push(`(q.quote_no ILIKE $${idx} OR q.customer_name ILIKE $${idx})`);
params.push(`%${filter.search}%`);
idx++;
}
if (filter.status) {
conditions.push(`q.status = $${idx}`);
params.push(filter.status);
idx++;
}
if (filter.startDate) {
conditions.push(`q.quote_date >= $${idx}`);
params.push(filter.startDate);
idx++;
}
if (filter.endDate) {
conditions.push(`q.quote_date <= $${idx}`);
params.push(filter.endDate);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT q.objid, q.quote_no, q.quote_date, q.valid_until,
q.customer_objid, q.customer_name, q.status, q.manager,
q.domestic_type, q.payment_terms, q.delivery_method, q.notes,
q.total_supply, q.total_vat, q.total_amount,
q.customer_ceo, q.customer_biz_no, q.customer_address,
q.customer_contact, q.customer_phone,
q.incoterms, q.currency, q.exchange_rate,
q.port_of_loading, q.port_of_discharge, q.shipment_date,
q.hs_code, q.country_of_origin, q.lc_number, q.trade_notes,
q.revision_count, q.created_by, q.created_at, q.updated_at
FROM quote_mng q
${where}
ORDER BY q.created_at DESC
`;
const result = await pool.query(query, params);
logger.info("견적 목록 조회", { companyCode, count: result.rowCount });
return result.rows;
}
export async function getById(companyCode: string, objid: number) {
const pool = getPool();
// 마스터
const masterRes = await pool.query(
`SELECT * FROM quote_mng WHERE objid = $1 AND company_code = $2 AND use_yn = 'Y'`,
[objid, companyCode],
);
if (masterRes.rowCount === 0) return null;
// 품목
const detailRes = await pool.query(
`SELECT * FROM quote_detail WHERE quote_objid = $1 ORDER BY item_no`,
[objid],
);
return { ...masterRes.rows[0], items: detailRes.rows };
}
export async function generateNumber(companyCode: string): Promise<string> {
const pool = getPool();
const year = new Date().getFullYear();
const prefix = `QT-${year}-`;
const res = await pool.query(
`SELECT quote_no FROM quote_mng
WHERE company_code = $1 AND quote_no LIKE $2
ORDER BY quote_no DESC LIMIT 1`,
[companyCode, `${prefix}%`],
);
let seq = 1;
if (res.rowCount && res.rowCount > 0) {
const last = res.rows[0].quote_no as string;
const lastSeq = parseInt(last.replace(prefix, ""), 10);
if (!isNaN(lastSeq)) seq = lastSeq + 1;
}
return `${prefix}${String(seq).padStart(4, "0")}`;
}
export async function create(companyCode: string, userId: string, body: QuoteBody) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const quoteNo = body.quote_no || (await generateNumber(companyCode));
// 합계 계산
const items = body.items ?? [];
const totalSupply = items.reduce((s, i) => s + (i.supply_amount ?? 0), 0);
const totalVat = items.reduce((s, i) => s + (i.vat_amount ?? 0), 0);
const totalAmount = totalSupply + totalVat;
const masterRes = await client.query(
`INSERT INTO quote_mng (
quote_no, quote_date, valid_until, customer_objid, customer_name,
status, manager, domestic_type, payment_terms, delivery_method, notes,
total_supply, total_vat, total_amount,
customer_ceo, customer_biz_no, customer_address, customer_contact, customer_phone,
incoterms, currency, exchange_rate, port_of_loading, port_of_discharge,
shipment_date, hs_code, country_of_origin, lc_number, trade_notes,
company_code, created_by, updated_by
) 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,$29,$30,$31,$31
) RETURNING objid`,
[
quoteNo, body.quote_date, body.valid_until || null,
body.customer_objid || null, body.customer_name || null,
body.status || "draft", body.manager || null,
body.domestic_type || "국내", body.payment_terms || null,
body.delivery_method || null, body.notes || null,
totalSupply, totalVat, totalAmount,
body.customer_ceo || null, body.customer_biz_no || null,
body.customer_address || null, body.customer_contact || null,
body.customer_phone || null,
body.incoterms || null, body.currency || "KRW",
body.exchange_rate || null, body.port_of_loading || null,
body.port_of_discharge || null, body.shipment_date || null,
body.hs_code || null, body.country_of_origin || null,
body.lc_number || null, body.trade_notes || null,
companyCode, userId,
],
);
const quoteObjid = masterRes.rows[0].objid;
// 품목 INSERT
for (let i = 0; i < items.length; i++) {
const item = items[i];
await client.query(
`INSERT INTO quote_detail (
quote_objid, item_no, item_code, item_name, spec,
qty, unit, request_length, unit_price,
supply_amount, vat_amount, total_amount, notes, company_code
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
[
quoteObjid, i + 1, item.item_code || null, item.item_name || null,
item.spec || null, item.qty ?? 0, item.unit || "EA",
item.request_length || null, item.unit_price ?? 0,
item.supply_amount ?? 0, item.vat_amount ?? 0,
item.total_amount ?? 0, item.notes || null, companyCode,
],
);
}
await client.query("COMMIT");
logger.info("견적 등록 완료", { companyCode, quoteNo, quoteObjid });
return { objid: quoteObjid, quote_no: quoteNo };
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function update(companyCode: string, userId: string, objid: number, body: QuoteBody) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const items = body.items ?? [];
const totalSupply = items.reduce((s, i) => s + (i.supply_amount ?? 0), 0);
const totalVat = items.reduce((s, i) => s + (i.vat_amount ?? 0), 0);
const totalAmount = totalSupply + totalVat;
await client.query(
`UPDATE quote_mng SET
quote_date=$1, valid_until=$2, customer_objid=$3, customer_name=$4,
status=$5, manager=$6, domestic_type=$7, payment_terms=$8,
delivery_method=$9, notes=$10,
total_supply=$11, total_vat=$12, total_amount=$13,
customer_ceo=$14, customer_biz_no=$15, customer_address=$16,
customer_contact=$17, customer_phone=$18,
incoterms=$19, currency=$20, exchange_rate=$21,
port_of_loading=$22, port_of_discharge=$23, shipment_date=$24,
hs_code=$25, country_of_origin=$26, lc_number=$27, trade_notes=$28,
revision_count = revision_count + 1,
updated_by=$29, updated_at=CURRENT_TIMESTAMP
WHERE objid=$30 AND company_code=$31`,
[
body.quote_date, body.valid_until || null,
body.customer_objid || null, body.customer_name || null,
body.status || "draft", body.manager || null,
body.domestic_type || "국내", body.payment_terms || null,
body.delivery_method || null, body.notes || null,
totalSupply, totalVat, totalAmount,
body.customer_ceo || null, body.customer_biz_no || null,
body.customer_address || null, body.customer_contact || null,
body.customer_phone || null,
body.incoterms || null, body.currency || "KRW",
body.exchange_rate || null, body.port_of_loading || null,
body.port_of_discharge || null, body.shipment_date || null,
body.hs_code || null, body.country_of_origin || null,
body.lc_number || null, body.trade_notes || null,
userId, objid, companyCode,
],
);
// 기존 품목 삭제 후 재등록
await client.query(`DELETE FROM quote_detail WHERE quote_objid = $1`, [objid]);
for (let i = 0; i < items.length; i++) {
const item = items[i];
await client.query(
`INSERT INTO quote_detail (
quote_objid, item_no, item_code, item_name, spec,
qty, unit, request_length, unit_price,
supply_amount, vat_amount, total_amount, notes, company_code
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
[
objid, i + 1, item.item_code || null, item.item_name || null,
item.spec || null, item.qty ?? 0, item.unit || "EA",
item.request_length || null, item.unit_price ?? 0,
item.supply_amount ?? 0, item.vat_amount ?? 0,
item.total_amount ?? 0, item.notes || null, companyCode,
],
);
}
await client.query("COMMIT");
logger.info("견적 수정 완료", { companyCode, objid });
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function remove(companyCode: string, objid: number) {
const pool = getPool();
await pool.query(
`UPDATE quote_mng SET use_yn = 'N', updated_at = CURRENT_TIMESTAMP WHERE objid = $1 AND company_code = $2`,
[objid, companyCode],
);
logger.info("견적 삭제(소프트)", { companyCode, objid });
}
@@ -0,0 +1,121 @@
import { PostgreSQLService } from "../database/PostgreSQLService";
import { encryptionService } from "./encryptionService";
export interface UserMailAccount {
id: number;
userId: string;
displayName: string;
email: string;
protocol: 'imap';
host: string;
port: number;
useTls: boolean;
username: string;
password: string; // 암호화된 상태
status: string;
createdAt: string | Date;
updatedAt: string | Date;
}
export interface CreateUserMailAccountDto {
displayName: string;
email: string;
protocol: 'imap';
host: string;
port: number;
useTls: boolean;
username: string;
password: string; // 평문 (서비스에서 암호화)
}
function rowToAccount(row: any): UserMailAccount {
return {
id: row.id,
userId: row.user_id,
displayName: row.display_name,
email: row.email,
protocol: row.protocol,
host: row.host,
port: row.port,
useTls: row.use_tls,
username: row.username,
password: row.password,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
class UserMailAccountService {
async getAccountsByUserId(userId: string, protocol?: string): Promise<UserMailAccount[]> {
let query = 'SELECT * FROM user_mail_accounts WHERE user_id = $1';
const params: any[] = [userId];
if (protocol) {
query += ' AND protocol = $2';
params.push(protocol);
}
query += ' ORDER BY created_at DESC';
const result = await PostgreSQLService.query(query, params);
return result.rows.map(rowToAccount);
}
async getAccountById(id: number, userId: string): Promise<UserMailAccount | null> {
const result = await PostgreSQLService.query(
'SELECT * FROM user_mail_accounts WHERE id = $1 AND user_id = $2',
[id, userId]
);
return result.rows.length > 0 ? rowToAccount(result.rows[0]) : null;
}
async createAccount(userId: string, dto: CreateUserMailAccountDto): Promise<UserMailAccount> {
const encryptedPassword = encryptionService.encrypt(dto.password);
const result = await PostgreSQLService.query(
`INSERT INTO user_mail_accounts (user_id, display_name, email, protocol, host, port, use_tls, username, password)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING *`,
[userId, dto.displayName, dto.email, dto.protocol, dto.host, dto.port, dto.useTls, dto.username, encryptedPassword]
);
return rowToAccount(result.rows[0]);
}
async updateAccount(id: number, userId: string, dto: Partial<CreateUserMailAccountDto>): Promise<UserMailAccount | null> {
const existing = await this.getAccountById(id, userId);
if (!existing) return null;
const fields: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (dto.displayName !== undefined) { fields.push(`display_name = $${paramIndex++}`); values.push(dto.displayName); }
if (dto.email !== undefined) { fields.push(`email = $${paramIndex++}`); values.push(dto.email); }
if (dto.protocol !== undefined) { fields.push(`protocol = $${paramIndex++}`); values.push(dto.protocol); }
if (dto.host !== undefined) { fields.push(`host = $${paramIndex++}`); values.push(dto.host); }
if (dto.port !== undefined) { fields.push(`port = $${paramIndex++}`); values.push(dto.port); }
if (dto.useTls !== undefined) { fields.push(`use_tls = $${paramIndex++}`); values.push(dto.useTls); }
if (dto.username !== undefined) { fields.push(`username = $${paramIndex++}`); values.push(dto.username); }
if (dto.password !== undefined) { fields.push(`password = $${paramIndex++}`); values.push(encryptionService.encrypt(dto.password)); }
if (fields.length === 0) return existing;
fields.push(`updated_at = NOW()`);
values.push(id, userId);
const result = await PostgreSQLService.query(
`UPDATE user_mail_accounts SET ${fields.join(', ')} WHERE id = $${paramIndex++} AND user_id = $${paramIndex} RETURNING *`,
values
);
return result.rows.length > 0 ? rowToAccount(result.rows[0]) : null;
}
async deleteAccount(id: number, userId: string): Promise<boolean> {
const result = await PostgreSQLService.query(
'DELETE FROM user_mail_accounts WHERE id = $1 AND user_id = $2',
[id, userId]
);
return result.rowCount > 0;
}
}
export const userMailAccountService = new UserMailAccountService();
@@ -0,0 +1,419 @@
import { ImapFlow } from 'imapflow';
import { simpleParser } from 'mailparser';
import { encryptionService } from './encryptionService';
import { UserMailAccount } from './userMailAccountService';
import { imapConnectionPool } from './imapConnectionPool';
import { mailCache } from './mailCache';
export interface ReceivedMail {
id: string;
messageId: string;
from: string;
to: string;
subject: string;
date: Date;
preview: string;
isRead: boolean;
hasAttachments: boolean;
}
export interface MailDetail extends ReceivedMail {
htmlBody: string;
textBody: string;
cc?: string;
bcc?: string;
attachments: Array<{
filename: string;
contentType: string;
size: number;
}>;
}
class UserMailImapService {
async fetchMailList(account: UserMailAccount, limit: number = 50): Promise<ReceivedMail[]> {
const cacheKey = `mailList:${account.id}:INBOX:${limit}`;
const cached = mailCache.get<ReceivedMail[]>(cacheKey);
if (cached) return cached;
const mails = await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock('INBOX');
try {
const status = await client.status('INBOX', { messages: true });
const total = status.messages || 0;
if (total === 0) return [];
const start = Math.max(1, total - limit + 1);
const range = `${start}:${total}`;
const result: ReceivedMail[] = [];
for await (const msg of client.fetch(range, {
uid: true,
flags: true,
envelope: true,
bodyStructure: true,
})) {
const hasAttachments = msg.bodyStructure
? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"')
: false;
result.push({
id: `${account.id}-imap-${msg.seq}`,
messageId: msg.envelope?.messageId || `${msg.seq}`,
from: msg.envelope?.from?.[0]
? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim()
: 'Unknown',
to: msg.envelope?.to?.[0]?.address || '',
subject: msg.envelope?.subject || '(제목 없음)',
date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(),
preview: '',
isRead: msg.flags?.has('\\Seen') || false,
hasAttachments,
});
}
result.sort((a, b) => b.date.getTime() - a.date.getTime());
return result;
} finally {
mailbox.release();
}
});
mailCache.set(cacheKey, mails, 60_000);
return mails;
}
async fetchMailListStream(
account: UserMailAccount,
limit: number = 20,
beforeSeqno: number | null = null,
onMail: (mail: ReceivedMail) => void,
onDone: () => void,
onError: (err: Error) => void
): Promise<void> {
try {
await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock('INBOX');
try {
const status = await client.status('INBOX', { messages: true });
const total = status.messages || 0;
if (total === 0) { onDone(); return; }
let start: number, end: number;
if (beforeSeqno !== null) {
end = beforeSeqno - 1;
start = Math.max(1, beforeSeqno - limit);
} else {
start = Math.max(1, total - limit + 1);
end = total;
}
if (end < 1 || start > end) { onDone(); return; }
for await (const msg of client.fetch(`${start}:${end}`, {
uid: true,
flags: true,
envelope: true,
bodyStructure: true,
})) {
const hasAttachments = msg.bodyStructure
? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"')
: false;
onMail({
id: `${account.id}-imap-${msg.seq}`,
messageId: msg.envelope?.messageId || `${msg.seq}`,
from: msg.envelope?.from?.[0]
? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim()
: 'Unknown',
to: msg.envelope?.to?.[0]?.address || '',
subject: msg.envelope?.subject || '(제목 없음)',
date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(),
preview: '',
isRead: msg.flags?.has('\\Seen') || false,
hasAttachments,
});
}
onDone();
} finally {
mailbox.release();
}
});
} catch (err) {
onError(err instanceof Error ? err : new Error(String(err)));
}
}
async getMailDetail(account: UserMailAccount, seqno: number): Promise<MailDetail | null> {
const cacheKey = `mailDetail:${account.id}:${seqno}`;
const cached = mailCache.get<MailDetail>(cacheKey);
if (cached) return cached;
const detail = await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock('INBOX');
try {
const msg = await client.fetchOne(`${seqno}`, {
uid: true,
flags: true,
envelope: true,
bodyStructure: true,
source: true,
});
if (!msg) return null;
const parsed = await simpleParser(msg.source as Buffer);
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
const ccAddress = Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc;
return {
id: `${account.id}-imap-${seqno}`,
messageId: parsed.messageId || `${seqno}`,
from: fromAddress?.text || 'Unknown',
to: toAddress?.text || '',
cc: ccAddress?.text,
subject: parsed.subject || '(제목 없음)',
date: parsed.date || new Date(),
htmlBody: parsed.html || '',
textBody: parsed.text || '',
preview: '',
isRead: msg.flags?.has('\\Seen') || false,
hasAttachments: (parsed.attachments?.length || 0) > 0,
attachments: (parsed.attachments || []).map((att: any) => ({
filename: att.filename || 'unnamed',
contentType: att.contentType || 'application/octet-stream',
size: att.size || 0,
})),
} as MailDetail;
} finally {
mailbox.release();
}
});
if (detail) mailCache.set(cacheKey, detail, 300_000);
return detail;
}
async markAsRead(account: UserMailAccount, seqno: number): Promise<{ success: boolean; message: string }> {
try {
await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock('INBOX');
try {
await client.messageFlagsAdd(`${seqno}`, ['\\Seen']);
} finally {
mailbox.release();
}
});
mailCache.invalidateByPrefix(`mailList:${account.id}:`);
return { success: true, message: '읽음 처리 완료' };
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : '오류' };
}
}
async deleteMail(account: UserMailAccount, seqno: number): Promise<{ success: boolean; message: string }> {
try {
await imapConnectionPool.execute(account, async (client) => {
// \Trash 특수 폴더 탐색 (Gmail: [Gmail]/휴지통 등)
const folders = await client.list();
const trashFolder = folders.find(f => f.specialUse === '\\Trash');
const mailbox = await client.getMailboxLock('INBOX');
try {
if (trashFolder) {
await client.messageMove(`${seqno}`, trashFolder.path);
} else {
await client.messageDelete(`${seqno}`);
}
} finally {
mailbox.release();
}
});
mailCache.invalidateByPrefix(`mailList:${account.id}:`);
mailCache.invalidateByPrefix(`mailDetail:${account.id}:${seqno}`);
return { success: true, message: '휴지통으로 이동 완료' };
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : '오류' };
}
}
async listFolders(account: UserMailAccount): Promise<Array<{ path: string; name: string; unseen: number; }>> {
return imapConnectionPool.execute(account, async (client) => {
const folders = await client.list({ statusQuery: { unseen: true } });
return folders
.filter(f => f.listed)
.map(f => ({
path: f.path,
name: f.name,
unseen: (f as any).status?.unseen ?? 0,
}));
});
}
async streamMailsByFolder(
account: UserMailAccount,
folder: string,
limit: number = 20,
beforeSeqno: number | null = null,
onMail: (mail: ReceivedMail) => void,
onDone: () => void,
onError: (err: Error) => void
): Promise<void> {
try {
await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock(folder);
try {
const status = await client.status(folder, { messages: true });
const total = status.messages || 0;
if (total === 0) { onDone(); return; }
let start: number, end: number;
if (beforeSeqno !== null) {
end = beforeSeqno - 1;
start = Math.max(1, beforeSeqno - limit);
} else {
start = Math.max(1, total - limit + 1);
end = total;
}
if (end < 1 || start > end) { onDone(); return; }
for await (const msg of client.fetch(`${start}:${end}`, {
uid: true, flags: true, envelope: true, bodyStructure: true,
})) {
const hasAttachments = msg.bodyStructure
? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"')
: false;
onMail({
id: `${account.id}-imap-${msg.seq}`,
messageId: msg.envelope?.messageId || `${msg.seq}`,
from: msg.envelope?.from?.[0]
? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim()
: 'Unknown',
to: msg.envelope?.to?.[0]?.address || '',
subject: msg.envelope?.subject || '(제목 없음)',
date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(),
preview: '',
isRead: msg.flags?.has('\\Seen') || false,
hasAttachments,
});
}
onDone();
} finally {
mailbox.release();
}
});
} catch (err) {
onError(err instanceof Error ? err : new Error(String(err)));
}
}
async moveMail(account: UserMailAccount, seqno: number, targetFolder: string): Promise<{ success: boolean; message: string }> {
try {
await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock('INBOX');
try {
await client.messageMove(`${seqno}`, targetFolder);
} finally {
mailbox.release();
}
});
mailCache.invalidateByPrefix(`mailList:${account.id}:`);
return { success: true, message: '이동 완료' };
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : '오류' };
}
}
async downloadAttachment(
account: UserMailAccount,
seqno: number,
partId: string,
res: import('express').Response,
folder: string = 'INBOX',
filenameHint?: string
): Promise<void> {
await imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock(folder);
try {
const { meta, content } = await client.download(`${seqno}`, partId);
const rawFilename = filenameHint || (meta as any).filename || 'attachment';
const encodedFilename = encodeURIComponent(rawFilename);
res.setHeader('Content-Disposition', `attachment; filename="${rawFilename}"; filename*=UTF-8''${encodedFilename}`);
res.setHeader('Content-Type', (meta as any).contentType || 'application/octet-stream');
if ((meta as any).size) res.setHeader('Content-Length', String((meta as any).size));
await require('stream/promises').pipeline(content, res);
} finally {
mailbox.release();
}
});
}
async getAttachmentList(account: UserMailAccount, seqno: number, folder: string = 'INBOX'): Promise<Array<{ partId: string; filename: string; contentType: string; size: number }>> {
return imapConnectionPool.execute(account, async (client) => {
const mailbox = await client.getMailboxLock(folder);
try {
const msg = await client.fetchOne(`${seqno}`, { bodyStructure: true });
if (!msg || !msg.bodyStructure) return [];
const result: Array<{ partId: string; filename: string; contentType: string; size: number }> = [];
function walk(node: any, part: string) {
const filename = node.parameters?.name || node.dispositionParameters?.filename;
if (filename && node.type !== 'text' && node.type !== 'multipart') {
result.push({
partId: node.part || part,
filename,
contentType: `${node.type}/${node.subtype}`,
size: node.size || 0,
});
}
if (node.childNodes) node.childNodes.forEach((c: any, i: number) => walk(c, `${part}.${i + 1}`));
}
walk(msg.bodyStructure, '1');
return result;
} finally {
mailbox.release();
}
});
}
async testConnection(account: UserMailAccount): Promise<{ success: boolean; message: string }> {
const decryptedPassword = encryptionService.decrypt(account.password);
const client = new ImapFlow({
host: account.host,
port: account.port,
secure: account.useTls,
auth: { user: account.username, pass: decryptedPassword },
logger: false as any,
tls: { rejectUnauthorized: false },
});
try {
await client.connect();
await client.logout();
return { success: true, message: 'IMAP 연결 성공' };
} catch (err) {
let message = '연결 실패';
if (err instanceof Error) {
const imapErr = err as any;
const raw = imapErr.response || imapErr.responseCode || imapErr.cause?.message || err.message;
const r = String(raw).toLowerCase();
if (r.includes('authentication') || r.includes('invalid credentials') || r.includes('authenticationfailed') || r.includes('login failed')) {
message = '인증 실패: 이메일 주소 또는 비밀번호가 올바르지 않습니다.';
} else if (r.includes('econnrefused') || r.includes('connection refused')) {
message = '연결 거부: 호스트 또는 포트를 확인하세요.';
} else if (r.includes('enotfound') || r.includes('getaddrinfo')) {
message = '호스트를 찾을 수 없습니다. IMAP 주소를 확인하세요.';
} else if (r.includes('timeout') || r.includes('etimedout')) {
message = '연결 시간 초과: 서버가 응답하지 않습니다.';
} else if (r.includes('self signed') || r.includes('certificate')) {
message = 'SSL 인증서 오류가 발생했습니다.';
} else if (r.includes('econnreset')) {
message = '연결이 강제로 끊겼습니다. TLS/SSL 설정을 확인하세요.';
} else {
message = raw;
}
}
return { success: false, message };
}
}
}
export const userMailImapService = new UserMailImapService();
@@ -0,0 +1,63 @@
import nodemailer from 'nodemailer';
import { encryptionService } from './encryptionService';
import { UserMailAccount } from './userMailAccountService';
export interface SendMailDto {
to: string;
cc?: string;
subject: string;
html: string;
text?: string;
inReplyTo?: string;
references?: string;
}
class UserMailSmtpService {
private getSmtpConfig(account: UserMailAccount) {
const decryptedPassword = encryptionService.decrypt(account.password);
// IMAP host에서 SMTP host 추론
const smtpHost = account.host
.replace(/^imap\./, 'smtp.')
.replace(/^mail\./, 'smtp.');
// 포트 추론: TLS → 465, plain → 587
const port = account.useTls ? 465 : 587;
return {
host: smtpHost,
port,
secure: account.useTls, // 465: true, 587: false (STARTTLS)
auth: { user: account.username, pass: decryptedPassword },
tls: { rejectUnauthorized: false },
};
}
async sendMail(account: UserMailAccount, dto: SendMailDto): Promise<{ success: boolean; message: string }> {
try {
const transporter = nodemailer.createTransport(this.getSmtpConfig(account));
await transporter.sendMail({
from: `${account.displayName} <${account.email}>`,
to: dto.to,
cc: dto.cc,
subject: dto.subject,
html: dto.html,
text: dto.text,
inReplyTo: dto.inReplyTo,
references: dto.references,
});
return { success: true, message: '발송 완료' };
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : '발송 실패' };
}
}
async testSmtpConnection(account: UserMailAccount): Promise<{ success: boolean; message: string }> {
try {
const transporter = nodemailer.createTransport(this.getSmtpConfig(account));
await transporter.verify();
return { success: true, message: 'SMTP 연결 성공' };
} catch (err) {
return { success: false, message: err instanceof Error ? err.message : 'SMTP 연결 실패' };
}
}
}
export const userMailSmtpService = new UserMailSmtpService();
+171
View File
@@ -0,0 +1,171 @@
import { Server, Socket } from 'socket.io';
import jwt from 'jsonwebtoken';
import config from '../config/environment';
import { messengerService } from '../services/messengerService';
import { JwtPayload } from '../types/auth';
interface AuthenticatedSocket extends Socket {
data: {
userId: string;
userName: string;
companyCode: string;
};
}
// In-memory presence store: userId → { companyCode, status }
const presenceStore = new Map<string, { companyCode: string; status: 'online' | 'away' }>();
export function initMessengerSocket(io: Server) {
// JWT authentication middleware
io.use((socket, next) => {
const token = socket.handshake.auth?.token || socket.handshake.query?.token;
if (!token) {
return next(new Error('Authentication required'));
}
try {
const decoded = jwt.verify(token as string, config.jwt.secret) as JwtPayload;
socket.data.userId = decoded.userId;
socket.data.userName = decoded.userName;
socket.data.companyCode = decoded.companyCode || '';
next();
} catch (error) {
next(new Error('Invalid token'));
}
});
io.on('connection', async (socket: AuthenticatedSocket) => {
const { userId, companyCode } = socket.data;
console.log(`[Messenger] User connected: ${userId}`);
// Join company presence room and broadcast online status
const presenceRoom = `${companyCode}:presence`;
socket.join(presenceRoom);
presenceStore.set(userId, { companyCode, status: 'online' });
socket.to(presenceRoom).emit('user_status', { userId, status: 'online' });
// Send current online users list to newly connected socket
const currentPresence: Record<string, string> = {};
for (const [uid, info] of presenceStore.entries()) {
if (info.companyCode === companyCode) {
currentPresence[uid] = info.status;
}
}
socket.emit('presence_list', currentPresence);
// set_status: client emits when tab focus changes
socket.on('set_status', (data: { status: 'online' | 'away' }) => {
const entry = presenceStore.get(userId);
if (entry) {
entry.status = data.status;
io.to(presenceRoom).emit('user_status', { userId, status: data.status });
}
});
// join_rooms: subscribe to all user's rooms
socket.on('join_rooms', async () => {
try {
const roomIds = await messengerService.getUserRoomIds(userId, companyCode);
for (const roomId of roomIds) {
socket.join(`${companyCode}:${roomId}`);
}
socket.emit('rooms_joined', { roomIds });
} catch (error) {
console.error('[Messenger] join_rooms error:', error);
socket.emit('error', { message: 'Failed to join rooms' });
}
});
// send_message: save and broadcast
socket.on('send_message', async (data: {
room_id: number;
content: string;
message_type?: string;
parent_message_id?: number;
}) => {
try {
const message = await messengerService.sendMessage(
data.room_id,
userId,
companyCode,
data.content,
data.message_type || 'text',
data.parent_message_id
);
io.to(`${companyCode}:${data.room_id}`).emit('new_message', message);
} catch (error) {
console.error('[Messenger] send_message error:', error);
socket.emit('error', { message: 'Failed to send message' });
}
});
// message_read: update last_read_at
socket.on('message_read', async (data: { room_id: number }) => {
try {
await messengerService.markAsRead(data.room_id, userId);
io.to(`${companyCode}:${data.room_id}`).emit('user_read', {
room_id: data.room_id,
user_id: userId,
read_at: new Date().toISOString(),
});
} catch (error) {
console.error('[Messenger] message_read error:', error);
}
});
// typing indicators
socket.on('typing_start', (data: { room_id: number }) => {
socket.to(`${companyCode}:${data.room_id}`).emit('user_typing', {
room_id: data.room_id,
user_id: userId,
user_name: socket.data.userName,
});
});
socket.on('typing_stop', (data: { room_id: number }) => {
socket.to(`${companyCode}:${data.room_id}`).emit('user_stop_typing', {
room_id: data.room_id,
user_id: userId,
user_name: socket.data.userName,
});
});
// reactions
socket.on('add_reaction', async (data: { message_id: number; emoji: string; room_id: number }) => {
try {
await messengerService.addReaction(data.message_id, userId, data.emoji);
io.to(`${companyCode}:${data.room_id}`).emit('reaction_added', {
message_id: data.message_id,
user_id: userId,
emoji: data.emoji,
});
} catch (error) {
console.error('[Messenger] add_reaction error:', error);
}
});
socket.on('remove_reaction', async (data: { message_id: number; emoji: string; room_id: number }) => {
try {
await messengerService.removeReaction(data.message_id, userId, data.emoji);
io.to(`${companyCode}:${data.room_id}`).emit('reaction_removed', {
message_id: data.message_id,
user_id: userId,
emoji: data.emoji,
});
} catch (error) {
console.error('[Messenger] remove_reaction error:', error);
}
});
// join a specific room (e.g., after creating a new room)
socket.on('join_room', (data: { room_id: number }) => {
socket.join(`${companyCode}:${data.room_id}`);
});
socket.on('disconnect', () => {
console.log(`[Messenger] User disconnected: ${userId}`);
presenceStore.delete(userId);
io.to(presenceRoom).emit('user_status', { userId, status: 'offline' });
});
});
}
+11
View File
@@ -0,0 +1,11 @@
import { Server } from 'socket.io';
let _io: Server | null = null;
export function setIo(io: Server) {
_io = io;
}
export function getIo(): Server | null {
return _io;
}
+97
View File
@@ -0,0 +1,97 @@
// Messenger type definitions
export interface MessengerRoom {
id: number;
company_code: string;
room_type: 'dm' | 'group' | 'channel';
room_name: string | null;
created_by: string;
created_at: string;
updated_at: string;
// joined fields
last_message?: string;
last_message_at?: string;
last_sender_id?: string;
unread_count?: number;
participants?: MessengerParticipant[];
}
export interface MessengerParticipant {
id: number;
room_id: number;
user_id: string;
company_code: string;
last_read_at: string;
joined_at: string;
// joined fields
user_name?: string;
dept_name?: string;
photo?: string | null;
}
export interface MessengerMessage {
id: number;
room_id: number;
sender_id: string;
company_code: string;
content: string | null;
message_type: 'text' | 'file' | 'system';
parent_message_id: number | null;
created_at: string;
updated_at: string;
// joined fields
sender_name?: string;
sender_photo?: string | null;
reactions?: MessengerReaction[];
files?: MessengerFile[];
thread_count?: number;
}
export interface MessengerReaction {
id: number;
message_id: number;
user_id: string;
emoji: string;
created_at: string;
}
export interface MessengerFile {
id: number;
message_id: number;
original_name: string;
stored_name: string;
file_path: string;
file_size: number;
mime_type: string | null;
created_at: string;
}
// Request types
export interface CreateRoomRequest {
room_type: 'dm' | 'group' | 'channel';
room_name?: string;
participant_ids: string[];
}
export interface SendMessageRequest {
content: string;
message_type?: 'text' | 'file' | 'system';
parent_message_id?: number;
}
export interface AddReactionRequest {
message_id: number;
emoji: string;
}
export interface UpdateRoomRequest {
room_name: string;
}
export interface MessengerUser {
user_id: string;
user_name: string;
dept_name: string;
email?: string;
photo?: string | null;
}
+2 -1
View File
@@ -21,7 +21,8 @@ export enum PermissionLevel {
*/ */
export function isSuperAdmin(user?: PersonBean | null): boolean { export function isSuperAdmin(user?: PersonBean | null): boolean {
if (!user) return false; if (!user) return false;
return user.companyCode === "*" && user.userType === "SUPER_ADMIN"; // 회사전환 후에도 userType이 SUPER_ADMIN이면 최고관리자로 인정
return user.userType === "SUPER_ADMIN";
} }
/** /**
@@ -0,0 +1,122 @@
---
name: IMX[계획] IMAP 메일 기능 확장
description: 메일 삭제, SMTP 발송, 폴더 전환, 첨부파일 다운로드, 이동, 답장/전달 구현
type: plan
---
# IMX 계획 — IMAP 메일 기능 확장
## 개요
기존 메일 조회/읽음처리만 되던 IMAP 페이지에 전체 메일 클라이언트 기능 추가.
nodemailer(이미 설치), imapflow(이미 설치), TipTap v2(신규 설치) 기반.
## 현재 동작
- 계정 목록 조회
- 메일 스트리밍 목록
- 메일 상세 보기
- 읽음 처리
- 메일 삭제 (백엔드만, UI 버튼 있으나 실제 연동 확인 필요)
## 변경 후 동작
- 좌측 패널: 폴더 목록 (INBOX, Sent, Trash, Spam 등) + 미읽음 수
- 메일 상세 우측 버튼: 답장 / 전달 / 이동 / 삭제(→Trash)
- 하단 첨부파일 목록 + 다운로드 버튼
- 우상단 `작성` 버튼 → Dialog (TipTap 에디터, to/cc/subject)
- 답장/전달 시 원문 인용 자동 삽입
## 시각적 예시
```
┌──────────────────────────────────────────────────────────────────┐
│ 메일 관리 (IMAP) [작성] [계정추가] │
├──────────────┬───────────────────┬───────────────────────────────┤
│ [계정목록] │ [검색창] │ 제목: 보안 알림 │
│ │ │ From: Google │
│ Gmail │ ● Google 오후1:51 │ To: yechul@gmail.com │
│ Wace │ 보안 알림 │ Date: 2026-03-27 │
│ │ ● GitHub 오전9:48 │ [답장][전달][이동▼][삭제] │
│ ───────── │ Sudo code │ ───────────────────────────── │
│ [폴더목록] │ │ <HTML 본문> │
│ INBOX (3) │ │ │
│ Sent │ │ 📎 첨부파일 │
│ Trash │ │ file.pdf (120KB) [다운로드] │
│ Spam │ │ │
└──────────────┴───────────────────┴───────────────────────────────┘
```
## 아키텍처
```mermaid
graph TD
FE[page.tsx] -->|GET /folders| BE_CTRL[userMailController]
FE -->|POST /send| BE_CTRL
FE -->|POST /move| BE_CTRL
FE -->|GET /attachment| BE_CTRL
BE_CTRL --> IMAP[userMailImapService]
BE_CTRL --> SMTP[userMailSmtpService - 신규]
IMAP --> Pool[imapConnectionPool]
SMTP --> Nodemailer[nodemailer]
```
## 변경 파일
### 신규
- `backend-node/src/services/userMailSmtpService.ts` — SMTP 발송 전용
### 수정
- `backend-node/src/services/userMailImapService.ts` — 폴더목록, 이동, 첨부파일 추가
- `backend-node/src/controllers/userMailController.ts` — 신규 엔드포인트 핸들러
- `backend-node/src/routes/userMailRoutes.ts` — 신규 라우트 등록
- `frontend/lib/api/userMail.ts` — 신규 API 함수
- `frontend/app/(main)/mail/imap/page.tsx` — UI 전면 확장
## 신규 API 엔드포인트
| Method | Path | 설명 |
|--------|------|------|
| GET | `/user-mail/accounts/:id/folders` | 폴더 목록 + 미읽음 수 |
| GET | `/user-mail/accounts/:id/folders/:folder/mails/stream` | 폴더별 메일 스트리밍 |
| POST | `/user-mail/accounts/:id/mails/:seqno/move` | 메일 이동 `{ targetFolder }` |
| GET | `/user-mail/accounts/:id/mails/:seqno/attachment/:partId` | 첨부파일 다운로드 (스트리밍) |
| POST | `/user-mail/accounts/:id/send` | 메일 발송 `{ to, cc, subject, html, text, inReplyTo?, references? }` |
## 코드 설계
### userMailSmtpService.ts
```typescript
// SMTP 포트 추론: useTls true → 465, false → 587
// Gmail: smtp.gmail.com, wace.me: mail.wace.me 또는 host 에서 도메인 추출
// nodemailer createTransport + sendMail
// 답장: inReplyTo, references 헤더 설정
```
### userMailImapService.ts 추가 메서드
```typescript
listFolders(account): Promise<{ path, name, unseen }[]> // client.list({ statusQuery })
moveMail(account, seqno, targetFolder): Promise<Result> // messageMove
downloadAttachment(account, seqno, partId, res): Promise<void> // download() + pipeline(res)
```
### page.tsx 추가 UI
- `folders` state: 폴더 목록
- `currentFolder` state: 현재 폴더 (기본 INBOX)
- `ComposeDialog`: TipTap 에디터 + to/cc/subject 필드
- `composeMode`: 'new' | 'reply' | 'forward'
- 메일 상세 버튼: 답장, 전달, 이동(DropdownMenu), 삭제
## 예상 문제
1. **Gmail SMTP 포트**: Gmail은 587(STARTTLS) 또는 465(SSL). host에서 자동 추론.
2. **폴더명 인코딩**: 한글 폴더 등 UTF-7/UTF-8 혼용 → imapflow가 자동 처리
3. **첨부파일 partId**: bodyStructure 파싱이 복잡 → `client.download(seqno, partId)` 직접 사용
4. **TipTap SSR**: Next.js에서 dynamic import 필요 (`ssr: false`)
## 설계 원칙
- SMTP 서비스는 IMAP 서비스와 완전 분리 (파일 분리)
- 첨부파일은 서버에 저장하지 않고 스트리밍으로 직접 응답
- 답장/전달 인용: `<blockquote>` + RFC 2822 헤더 표준 준수
- TipTap은 dynamic import로 SSR 방지
@@ -0,0 +1,58 @@
---
name: IMX[맥락] IMAP 메일 기능 확장
description: 왜 이 기능들을 추가하는가, 핵심 결정 근거
type: context
---
# IMX 맥락 — IMAP 메일 기능 확장
## 왜 하는가
ERP 시스템에서 메일 확인만 되면 의미가 없음. 거래처 메일 수신 후 바로 답장, 견적서 첨부파일 저장, 담당자 전달까지 워크플로우가 연결되어야 실용적.
## 핵심 결정 + 근거
### SMTP 서비스 분리 (`userMailSmtpService.ts`)
- IMAP(수신)과 SMTP(송신)은 프로토콜 자체가 다름
- 파일 분리로 각각 독립적으로 교체/테스트 가능
- nodemailer는 이미 설치됨 (추가 의존성 없음)
### TipTap v2 선택 (메일 에디터)
- ProseMirror 기반 → 안정적, 확장 용이
- `@tiptap/react` 공식 패키지 → Next.js 15 호환
- Quill보다 번들 크기 작음 (~100KB vs ~200KB)
- dynamic import (`ssr: false`)로 SSR 문제 회피
### 첨부파일 스트리밍
- 서버에 임시 저장하지 않음 → 디스크 절약, 보안
- `imapflow client.download()``stream/promises pipeline()` → HTTP 응답
- 대용량 파일도 메모리 부담 없음
### 메일 삭제 = Trash 이동
- 즉시 삭제(`messageDelete`) 대신 Trash 폴더 이동
- 실수로 삭제 시 복구 가능
- Gmail/wace.me 모두 Trash 폴더 표준 지원
### 폴더 구조 표시
- `client.list({ statusQuery: { unseen: true } })` 로 미읽음 수 포함
- 폴더 클릭 시 기존 스트리밍 로직 재활용 (folder 파라미터 추가)
### 답장/전달 RFC 준수
- `inReplyTo`, `references` 헤더 → 메일 클라이언트에서 스레드로 묶임
- `<blockquote>` 인용 → Gmail/Outlook 모두 올바르게 렌더링
## 관련 파일
- `backend-node/src/services/userMailImapService.ts` — IMAP 수신 로직
- `backend-node/src/services/imapConnectionPool.ts` — 커넥션 풀 (건드리지 않음)
- `backend-node/src/services/mailCache.ts` — TTL 캐시 (건드리지 않음)
- `frontend/app/(main)/mail/imap/page.tsx` — 메인 UI
## 기술 참고
- imapflow `client.list()` → statusQuery 옵션으로 unseen 포함
- imapflow `client.messageMove(seqno, folder)` → UID 기반 이동
- imapflow `client.download(seqno, partId)` → ReadableStream 반환
- nodemailer `createTransport({ host, port, secure, auth })``sendMail()`
- RFC 2822 §3.6.4: `In-Reply-To`, `References` 헤더
- W3C HTML Threading: `<blockquote cite="mid:...">` 권장
@@ -0,0 +1,58 @@
---
name: IMX[체크] IMAP 메일 기능 확장
description: 구현 및 검증 체크리스트
type: checklist
---
# IMX 체크리스트 — IMAP 메일 기능 확장
## 공정 상태: 0%
## 구현 체크리스트
### Unit A — 백엔드 서비스
- [ ] `userMailImapService.ts`: `listFolders()` 추가
- [ ] `userMailImapService.ts`: `streamMailsByFolder()` 추가
- [ ] `userMailImapService.ts`: `moveMail()` 추가
- [ ] `userMailImapService.ts`: `downloadAttachment()` 추가
- [ ] `userMailSmtpService.ts` 신규 생성 (nodemailer 기반)
- [ ] TypeScript 에러 없음
### Unit B — 백엔드 컨트롤러/라우트
- [ ] `userMailController.ts`: `listFolders`, `streamFolderMails`, `moveMail`, `downloadAttachment`, `sendMail` 핸들러
- [ ] `userMailRoutes.ts`: 5개 신규 라우트 등록
- [ ] TypeScript 에러 없음
### Unit C — 프론트엔드 API
- [ ] `userMail.ts`: `getUserMailFolders()` 추가
- [ ] `userMail.ts`: `streamFolderMails()` 추가
- [ ] `userMail.ts`: `moveUserMail()` 추가
- [ ] `userMail.ts`: `sendUserMail()` 추가
- [ ] `userMail.ts`: 첨부파일 다운로드 URL 헬퍼 추가
### Unit D — 프론트엔드 UI (TipTap 설치 포함)
- [ ] TipTap 패키지 설치 (`@tiptap/react`, `@tiptap/starter-kit`, `@tiptap/extension-link`)
- [ ] 좌측 패널: 폴더 목록 + 미읽음 수
- [ ] 폴더 클릭 → 해당 폴더 메일 스트리밍
- [ ] 메일 상세: 답장/전달/이동/삭제 버튼
- [ ] ComposeDialog: TipTap 에디터 + to/cc/subject
- [ ] 답장/전달 시 원문 인용 자동 삽입
- [ ] 첨부파일 목록 + 다운로드 링크
- [ ] TypeScript 에러 없음
## 검증 체크리스트
- [ ] 폴더 목록이 좌측 패널에 표시됨 (INBOX, Sent, Trash 등)
- [ ] 폴더 클릭 시 해당 폴더 메일이 로드됨
- [ ] 메일 삭제 버튼 클릭 → Trash로 이동됨
- [ ] 메일 이동 드롭다운 → 다른 폴더로 이동됨
- [ ] 첨부파일 있는 메일에서 다운로드 버튼 동작
- [ ] 새 메일 작성 → 발송 성공
- [ ] 답장 → To 자동 입력, 원문 인용 포함
- [ ] 전달 → 원문 전체 포함
## 변경 이력
| 일자 | 내용 |
|------|------|
| 2026-03-27 | PCC 작성, 구현 시작 |
+212
View File
@@ -0,0 +1,212 @@
# MSN[계획] 메신저 기능 개발
## 개요
벡스플로어 ERP에 내장 메신저 기능을 추가한다. Gmail 편지쓰기 스타일의 우측 하단 플로팅 모달로 동작하며, Socket.IO 기반 실시간 통신을 제공한다. 모든 화면에서 접근 가능하고, 동일 company_code 내 사용자끼리 1:1 DM / 그룹 채팅 / 채널 대화를 지원한다.
---
## 현재 동작
- 메신저 기능 없음
- 사내 커뮤니케이션 수단 부재
## 변경 후 동작
- 모든 화면 우측 하단에 메신저 FAB 버튼 고정 (z-index: 9999)
- FAB 클릭 시 Gmail 편지쓰기 스타일 모달 팝업 (우측 하단)
- 모달 좌측: 채팅방 목록 (DM / 그룹 / 채널 탭)
- 모달 우측: 채팅 영역 (메시지 입력, 파일 첨부, 이모지, 멘션, 스레드)
- Socket.IO로 실시간 메시지 수신
- 읽지 않은 메시지 수 FAB 배지 표시
- 토스트 알림 on/off 토글 (메신저 설정 내)
---
## 시각적 예시
```
┌─────────────────────────────────────────────────────┐
│ 벡스플로어 화면 │
│ │
│ │
│ ┌────────────────────┐ │
│ │ 채팅방 목록 │ 채팅창 │ │
│ │─────────────────── │ │
│ │ DM 그룹 채널 │ │
│ │ ───────────────── │ │
│ │ 👤 김민호 ● │ │
│ │ 👥 개발팀 │ │
│ │ # 공지사항 │ │
│ └────────────────────┘ │
│ [💬 3] │
└─────────────────────────────────────────────────────┘
```
---
## 아키텍처
```mermaid
graph TB
subgraph Frontend
FAB[MessengerFAB] --> Modal[MessengerModal]
Modal --> RoomList[RoomList 좌측 240px]
Modal --> ChatPanel[ChatPanel 우측 480px]
ChatPanel --> MessageList[MessageList]
ChatPanel --> MessageInput[MessageInput]
MessageInput --> FileUpload[파일 첨부]
MessageInput --> EmojiPicker[이모지]
MessageInput --> MentionDropdown[멘션 @]
end
subgraph SocketIO
SocketClient[socket.io-client] <--> SocketServer[messengerSocket.ts]
end
subgraph Backend
SocketServer --> MessengerService[messengerService.ts]
Route[messengerRoutes.ts] --> Controller[messengerController.ts]
Controller --> MessengerService
MessengerService --> DB[(PostgreSQL)]
FileRoute[파일 업로드] --> Multer[multerMessengerConfig.ts]
end
Frontend <--> SocketIO
Frontend <--> Route
```
---
## 변경 파일
### 신규 생성
| 파일 | 역할 |
|------|------|
| `backend-node/src/types/messenger.ts` | TypeScript 인터페이스 |
| `backend-node/src/services/messengerService.ts` | 비즈니스 로직 |
| `backend-node/src/controllers/messengerController.ts` | HTTP 핸들러 |
| `backend-node/src/routes/messengerRoutes.ts` | REST API 라우트 |
| `backend-node/src/socket/messengerSocket.ts` | Socket.IO 이벤트 핸들러 |
| `backend-node/src/config/multerMessengerConfig.ts` | 파일 업로드 설정 |
| `db/migrations/messenger_tables.sql` | DB 테이블 생성 |
| `frontend/components/messenger/MessengerFAB.tsx` | 플로팅 버튼 |
| `frontend/components/messenger/MessengerModal.tsx` | 메인 모달 컨테이너 |
| `frontend/components/messenger/RoomList.tsx` | 채팅방 목록 |
| `frontend/components/messenger/ChatPanel.tsx` | 채팅 영역 |
| `frontend/components/messenger/MessageItem.tsx` | 메시지 단일 아이템 |
| `frontend/components/messenger/MessageInput.tsx` | 입력창 |
| `frontend/components/messenger/NewRoomModal.tsx` | 방 생성 모달 |
| `frontend/components/messenger/UserAvatar.tsx` | 프로필 아바타 |
| `frontend/hooks/useMessenger.ts` | 메신저 상태 훅 |
| `frontend/hooks/useMessengerSocket.ts` | Socket.IO 훅 |
| `frontend/contexts/MessengerContext.tsx` | 전역 메신저 상태 |
### 수정
| 파일 | 변경 내용 |
|------|-----------|
| `backend-node/src/app.ts` | Socket.IO 서버 초기화, messengerRoutes 등록 |
| `frontend/app/(main)/layout.tsx` | `<MessengerProvider>`, `<MessengerFAB />` 추가 |
---
## 코드 설계
### DB 스키마
```sql
-- 채팅방
messenger_rooms (
room_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_code VARCHAR(50) NOT NULL,
room_type VARCHAR(10) NOT NULL CHECK (room_type IN ('dm', 'group', 'channel')),
room_name VARCHAR(100),
created_by VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
-- 참여자
messenger_participants (
room_id UUID REFERENCES messenger_rooms(room_id),
user_id VARCHAR(100) NOT NULL,
company_code VARCHAR(50) NOT NULL,
joined_at TIMESTAMP DEFAULT NOW(),
last_read_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (room_id, user_id)
)
-- 메시지
messenger_messages (
message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
room_id UUID REFERENCES messenger_rooms(room_id),
company_code VARCHAR(50) NOT NULL,
sender_id VARCHAR(100) NOT NULL,
content TEXT,
message_type VARCHAR(10) DEFAULT 'text' CHECK (message_type IN ('text', 'file', 'system')),
parent_message_id UUID REFERENCES messenger_messages(message_id),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
is_deleted BOOLEAN DEFAULT FALSE
)
-- 이모지 리액션
messenger_reactions (
message_id UUID REFERENCES messenger_messages(message_id),
user_id VARCHAR(100) NOT NULL,
emoji VARCHAR(10) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (message_id, user_id, emoji)
)
-- 파일 첨부
messenger_files (
file_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message_id UUID REFERENCES messenger_messages(message_id),
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW()
)
```
### Socket.IO 이벤트
| 이벤트 | 방향 | 설명 |
|--------|------|------|
| `join_rooms` | Client→Server | 참여 중인 방 전체 구독 |
| `send_message` | Client→Server | 메시지 전송 |
| `new_message` | Server→Client | 새 메시지 수신 |
| `message_read` | Client→Server | 읽음 처리 |
| `user_online` | Server→Client | 온라인 상태 변경 |
| `typing_start/stop` | Client↔Server | 타이핑 표시 |
| `add_reaction` | Client→Server | 이모지 리액션 추가 |
| `reaction_updated` | Server→Client | 리액션 업데이트 |
### REST API
| Method | URL | 설명 |
|--------|-----|------|
| GET | `/api/messenger/rooms` | 내 채팅방 목록 |
| POST | `/api/messenger/rooms` | 채팅방 생성 |
| GET | `/api/messenger/rooms/:roomId/messages` | 메시지 히스토리 |
| POST | `/api/messenger/rooms/:roomId/read` | 읽음 처리 |
| POST | `/api/messenger/files/upload` | 파일 업로드 |
| GET | `/api/messenger/files/:fileId` | 파일 다운로드 |
| GET | `/api/messenger/users` | 회사 내 사용자 목록 |
| PUT | `/api/messenger/rooms/:roomId` | 방 이름/설정 수정 |
---
## 예상 문제
1. **Socket.IO + Next.js**: Next.js는 기본적으로 HTTP 서버를 추상화하므로, Express HTTP 서버에 Socket.IO를 붙이고 프론트엔드는 백엔드 포트로 직접 연결
2. **JWT 인증 with Socket.IO**: handshake 시 Authorization 헤더 또는 auth 옵션으로 토큰 전달, 서버에서 미들웨어로 검증
3. **채널 분리 가능성**: `room_type = 'channel'` 쿼리 조건으로만 필터링하므로, 채널 UI/라우트만 제거하면 기능 완전 제거 가능
---
## 설계 원칙
- 기존 MVC + Service 패턴 준수
- 모든 쿼리에 `company_code` 필터 적용 (멀티테넌시)
- 채널은 `room_type` 값으로만 분리 — 코드 결합도 최소화
- FAB/모달은 기존 레이아웃 DOM에 독립적으로 렌더링 (Portal 또는 최상단 마운트)
+61
View File
@@ -0,0 +1,61 @@
# MSN[맥락] 메신저 기능 개발
## 왜 하는가
벡스플로어 ERP 내에서 사용자 간 실시간 커뮤니케이션 수단이 없다. 업무 맥락을 ERP 밖(카카오톡, 슬랙 등)으로 내보내지 않고 시스템 안에서 처리할 수 있도록 내장 메신저를 도입한다.
---
## 핵심 결정 및 근거
| 결정 | 근거 |
|------|------|
| Socket.IO 신규 도입 | HTTP 폴링은 메신저에 부적합. 실시간 타이핑 표시, 온라인 상태, 즉각적인 메시지 수신이 필요 |
| Gmail 스타일 우측 하단 모달 | 업무 화면을 방해하지 않고 언제든 접근 가능. 별도 페이지 이동 없이 사용 |
| DM + 그룹 + 채널 1차 구현 | 채널은 `room_type` 값으로만 분리하여 나중에 제거 용이하게 설계 |
| company_code 격리 | 기존 멀티테넌시 패턴과 동일하게 적용. 회사 간 데이터 유출 방지 |
| 파일 업로드: multer 로컬 | 기존 메일 첨부파일과 동일한 방식. 인프라 추가 없이 일관성 유지 |
| 프로필: photo BLOB 활용 | `user_info.photo` 컬럼에 이미 이미지 저장됨. 없으면 이름 첫 글자 원형 아바타 |
| 토스트 알림 on/off | 집중 업무 시 알림 방해 방지. localStorage에 설정 저장 |
---
## 관련 파일
### 참고 패턴 (기존 코드)
| 파일 | 참고 목적 |
|------|-----------|
| `backend-node/src/config/multerConfig.ts` | 파일 업로드 설정 패턴 |
| `backend-node/src/services/authService.ts` | user_info.photo BLOB → base64 변환 패턴 |
| `backend-node/src/middleware/authMiddleware.ts` | JWT 인증 미들웨어 (Socket.IO handshake에도 동일 로직 적용) |
| `frontend/components/mail/` | UI 컴포넌트 구조 참고 |
| `frontend/hooks/use-toast.ts` | 기존 토스트 훅 사용 |
| `backend-node/src/app.ts` | 라우트 등록 및 미들웨어 설정 위치 |
---
## 기술 참고
### Socket.IO JWT 인증
```typescript
// 서버: handshake 시 토큰 검증
io.use((socket, next) => {
const token = socket.handshake.auth.token;
const user = verifyJWT(token);
socket.data.user = user;
next();
});
// 클라이언트: 연결 시 토큰 전달
const socket = io(BACKEND_URL, {
auth: { token: localStorage.getItem('token') }
});
```
### 채널 분리 설계
채널 기능은 `room_type = 'channel'` 조건으로만 분리되어 있음.
제거 시: RoomList의 채널 탭 UI 제거 + `/api/messenger/rooms?type=channel` 호출 제거만으로 완전 비활성화 가능. DB 스키마 변경 불필요.
### 멀티테넌시 적용
모든 쿼리에 `WHERE company_code = $n` 조건 필수 적용.
Socket.IO 룸 네이밍: `{company_code}:{room_id}` 형식으로 회사별 격리.
+97
View File
@@ -0,0 +1,97 @@
# MSN[체크] 메신저 기능 개발
## 공정 상태: 90% (1차 구현 완료, 2차 테스트 대기)
---
## 구현 체크리스트
### Phase 1: DB & 백엔드 기반
- [x] `db/migrations/messenger_tables.sql` 작성 및 실행
- [x] `backend-node/src/types/messenger.ts` 타입 정의
- [x] `backend-node/src/services/messengerService.ts` 구현
- [x] getRooms (내 채팅방 목록)
- [x] createRoom (DM / 그룹 / 채널)
- [x] getMessages (메시지 히스토리, 페이지네이션)
- [x] sendMessage
- [x] markAsRead
- [x] getCompanyUsers (사용자 목록)
- [x] addReaction / removeReaction
- [x] `backend-node/src/controllers/messengerController.ts` 구현
- [x] `backend-node/src/routes/messengerRoutes.ts` 구현
- [x] `backend-node/src/config/multerMessengerConfig.ts` 구현 (파일 업로드)
- [x] `backend-node/src/socket/messengerSocket.ts` 구현
- [x] JWT 인증 미들웨어
- [x] join_rooms 이벤트
- [x] send_message 이벤트
- [x] message_read 이벤트
- [x] typing_start / typing_stop 이벤트
- [x] add_reaction 이벤트
- [x] 온라인 상태 관리 (connect / disconnect)
- [x] `backend-node/src/app.ts` Socket.IO 초기화 및 라우트 등록
### Phase 2: 프론트엔드 컴포넌트
- [x] `frontend/contexts/MessengerContext.tsx` (전역 상태)
- [x] `frontend/hooks/useMessengerSocket.ts` (Socket.IO 연결 관리)
- [x] `frontend/hooks/useMessenger.ts` (채팅방/메시지 React Query)
- [x] `frontend/components/messenger/UserAvatar.tsx` (프로필 이미지 / 이름 첫 글자)
- [x] `frontend/components/messenger/MessengerFAB.tsx` (플로팅 버튼 + 배지)
- [x] `frontend/components/messenger/MessengerModal.tsx` (메인 모달 컨테이너)
- [x] `frontend/components/messenger/RoomList.tsx` (채팅방 목록 + DM/그룹/채널 탭)
- [x] `frontend/components/messenger/ChatPanel.tsx` (채팅 영역)
- [x] `frontend/components/messenger/MessageItem.tsx` (메시지 + 리액션 + 스레드 버튼)
- [x] `frontend/components/messenger/MessageInput.tsx` (입력창 + 파일 + 이모지 + 멘션)
- [x] `frontend/components/messenger/NewRoomModal.tsx` (방 생성 모달)
- [x] `frontend/components/messenger/MessengerSettings.tsx` (토스트 알림 on/off 등)
- [x] `frontend/app/(main)/layout.tsx` MessengerProvider + MessengerFAB 추가
---
## 검증 체크리스트
### 기본 동작
- [ ] FAB 버튼이 모든 페이지에서 우측 하단 고정 (z-index 최상위)
- [ ] FAB 클릭 시 모달 열기/닫기 동작
- [ ] 모달 크기: 좌측 240px + 우측 480px
### 채팅
- [ ] DM 방 생성 (사용자 선택 → 방 생성)
- [ ] 그룹 방 생성 (여러 사용자 선택 → 방 이름 입력 → 생성)
- [ ] 채널 방 생성
- [ ] 메시지 전송 및 실시간 수신 (Socket.IO)
- [ ] 메시지 히스토리 로드 (스크롤 시 이전 메시지)
### 부가 기능
- [ ] 파일 첨부 업로드/다운로드
- [ ] 이모지 리액션 추가/제거
- [ ] 멘션(@) 자동완성 드롭다운
- [ ] 스레드 답글
- [ ] 타이핑 표시 ("김민호님이 입력 중...")
### 알림
- [ ] 읽지 않은 메시지 수 FAB 배지 표시
- [ ] 다른 방 메시지 수신 시 토스트 알림
- [ ] 토스트 알림 on/off 토글 동작
- [ ] 토스트 설정값 localStorage 저장/복원
### 프로필
- [ ] photo 있는 사용자: 원형 이미지 표시
- [ ] photo 없는 사용자: 이름 첫 글자 원형 아바타 표시
### 멀티테넌시
- [ ] 다른 company_code 사용자 목록에 미노출
- [ ] 다른 회사 채팅방 접근 불가
---
## 정리
- [ ] DB 체크포인트 파일 삭제 (2차 테스트 완료 후)
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-30 | 최초 설계 확정 |
+169
View File
@@ -0,0 +1,169 @@
# UML[계획] - 사용자 메일 관리 시스템
## 개요
벡스플로우(Vexflow) 사용자 메일 관리 페이지 구현 프로젝트입니다. 외부 메일 서버(POP3/IMAP)와 연동하여 사용자가 본인의 메일 계정을 등록하고 벡스플로우 내에서 메일을 조회할 수 있는 기능을 제공합니다.
### 현재 동작
- Admin 메일 시스템만 존재
- JSON 파일 기반 저장소
- 사용자 구분 없음
### 변경 후 동작
- 사용자가 외부 메일 계정(IMAP 또는 POP3) 등록
- 벡스플로우에서 해당 계정의 메일 조회
- PostgreSQL 기반 계정 저장 및 관리
- 사용자별 격리(user_id 기반)
---
## 아키텍처
```
┌─────────────┐
│ 사용자 │
│ (Frontend) │
└──────┬──────┘
├─→ /mail/imap 페이지
└─→ /mail/pop3 페이지
┌──────────────────┐
│ userMail.ts │ (API 클라이언트)
│ (lib/api/) │
└────────┬─────────┘
┌────────────────────────────┐
│ /api/user-mail/* 라우트 │
│ (userMailController) │
└────────┬───────────────────┘
┌───────┴────────┐
│ │
↓ ↓
┌────────────────┐ ┌──────────────┐
│ userMailAccount│ │ userMailImap │
│ Service │ │ Service │
│ (PostgreSQL) │ │ (IMAP) │
└────────────────┘ │ │
└──────────────┘
┌──────────────────┐
│ 외부 IMAP 서버 │
└──────────────────┘
또는
┌──────────────────┐
│ userMailPop3 │
│ Service │
│ (POP3) │
└──────────────────┘
┌──────────────────┐
│ 외부 POP3 서버 │
└──────────────────┘
```
---
## 신규 파일 목록
### 백엔드 (Node.js/Express)
| 파일 경로 | 역할 |
|----------|------|
| `src/services/userMailAccountService.ts` | DB 계정 관리 (생성, 조회, 삭제, 수정) |
| `src/services/userMailImapService.ts` | IMAP 프로토콜 연결 및 메일 조회 |
| `src/services/userMailPop3Service.ts` | POP3 프로토콜 연결 및 메일 조회 |
| `src/controllers/userMailController.ts` | API 엔드포인트 처리 |
| `src/routes/userMailRoutes.ts` | 라우트 정의 |
### 프론트엔드 (React/TypeScript)
| 파일 경로 | 역할 |
|----------|------|
| `frontend/lib/api/userMail.ts` | API 클라이언트 |
| `frontend/app/(main)/mail/imap/page.tsx` | IMAP 메일 관리 페이지 |
| `frontend/app/(main)/mail/pop3/page.tsx` | POP3 메일 관리 페이지 |
---
## 수정 파일 목록
| 파일 경로 | 변경 사항 |
|----------|---------|
| `src/runMigration.ts` | 마이그레이션 스크립트에 user_mail_accounts 테이블 추가 |
| `src/app.ts` | userMailRoutes 등록 |
| `src/components/AdminPageRenderer.tsx` | /mail/imap, /mail/pop3 페이지 하드코딩 등록 (2줄) |
---
## 데이터베이스 스키마
### user_mail_accounts 테이블
```sql
CREATE TABLE user_mail_accounts (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
protocol VARCHAR(10) NOT NULL CHECK (protocol IN ('imap', 'pop3')),
host VARCHAR(255) NOT NULL,
port INT NOT NULL DEFAULT 993,
use_tls BOOLEAN DEFAULT TRUE,
username VARCHAR(255) NOT NULL,
password TEXT NOT NULL, -- 암호화됨 (encryptionService 사용)
status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, protocol, host, username)
);
```
---
## 설계 원칙
### 1. 사용자 격리
- 모든 API 요청에서 현재 사용자의 user_id 검증
- 다른 사용자의 계정/메일 접근 불가
### 2. 프로토콜별 서비스 분리
- userMailImapService.ts: IMAP 전용
- userMailPop3Service.ts: POP3 전용
- 각 서비스는 독립적으로 동작
### 3. 기존 기능 재활용
- `encryptionService`: 비밀번호 암호화/복호화
- `mailparser`: 메일 본문 파싱
- `imap` 패키지: IMAP 연결(기존 mailReceiveBasicService 참조)
### 4. 기존 Admin 메일 시스템과 분리
- 새로운 테이블, 서비스, 라우트로 완전 독립
- JSON 파일 기반 방식 미사용
---
## 주요 API 엔드포인트
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | `/api/user-mail/accounts` | 새 계정 등록 |
| GET | `/api/user-mail/accounts` | 사용자 계정 목록 |
| GET | `/api/user-mail/accounts/:id` | 계정 상세 조회 |
| PUT | `/api/user-mail/accounts/:id` | 계정 수정 |
| DELETE | `/api/user-mail/accounts/:id` | 계정 삭제 |
| POST | `/api/user-mail/accounts/:id/test` | 연결 테스트 |
| GET | `/api/user-mail/accounts/:id/mails` | 메일 목록 조회 |
---
## 변경 이력
| 날짜 | 버전 | 내용 |
|------|------|------|
| 2026-03-27 | v1.0 | 초안 작성 |
+147
View File
@@ -0,0 +1,147 @@
# UML[맥락] - 사용자 메일 관리 시스템
## 프로젝트 배경
### 추진 이유
- 팀장 지시로 POP3 구현 필요
- IMAP 허용 여부 확인 대기 중
- 두 프로토콜 모두 구현 후 비교하여 최적 솔루션 채택
---
## 핵심 기술 결정 사항
### 1. 페이지 등록 방식: 하드코딩
**선택**: 하드코딩 (AdminPageRenderer.tsx에 직접 등록)
**사유**:
- 컴포넌트 레지스트리에 추가할 권한 없음
- 간단한 추가 작업으로 빠른 구현 가능
**구현**:
```typescript
// AdminPageRenderer.tsx에 2줄 추가
{path: '/mail/imap', label: '메일(IMAP)', component: () => <IMapPage /> },
{path: '/mail/pop3', label: '메일(POP3)', component: () => <Pop3Page /> },
```
---
### 2. 저장소: PostgreSQL (Admin 메일과 완전 분리)
**선택**: PostgreSQL `user_mail_accounts` 테이블
**사유**:
- Admin 메일 시스템(JSON 파일 기반)과 완전 독립
- 사용자별 격리 용이 (user_id 기반)
- 확장성 및 성능 이점
**결과**:
- 기존 Admin 메일: JSON 파일 유지
- 신규 사용자 메일: PostgreSQL 관리
---
### 3. POP3 메일 삭제 정책: 서버 유지
**선택**: DELE 명령 미호출 (서버 메일 유지)
**사유**:
- 데이터 손실 방지
- 사용자 실수로 인한 피해 최소화
- 벡스플로우는 조회만 수행
**구현**:
- `userMailPop3Service.ts`에서 RETR 후 DELE 호출 안 함
- 서버의 자동 정리 정책에 의존
---
### 4. 페이지별 프로토콜 고정
**선택**: 페이지당 프로토콜 1개로 제한
**구현**:
- `/mail/imap` → IMAP 계정만 표시/관리
- `/mail/pop3` → POP3 계정만 표시/관리
**사유**:
- UI 단순화
- 프로토콜별 메일 구조 차이 처리 용이
- 사용자 혼동 최소화
---
## 관련 기존 코드 참조
### mailReceiveBasicService.ts
- IMAP 연결 및 메일 조회 로직
- 메일 파싱 및 저장 방식
- Error handling 패턴
**참조 사항**:
```typescript
// IMAP 연결 구조, 메일 검색 쿼리, 메일 수신 처리 방식
```
### encryptionService.ts
- 비밀번호 암호화/복호화
- DB 저장 시 암호화, 조회 시 복호화
**사용 방식**:
```typescript
// 저장: encryptionService.encrypt(password)
// 조회: encryptionService.decrypt(encrypted_password)
```
### AdminPageRenderer.tsx
- 기존 페이지 하드코딩 구조
- 페이지 등록 형식 및 라벨 지정 방식
**추가 위치**:
```typescript
// 기존 페이지 목록에 /mail/imap, /mail/pop3 추가
```
---
## 기술 스택 및 패키지
### 기존 패키지 (재활용)
| 패키지 | 버전 | 용도 |
|--------|------|------|
| `imap` | - | IMAP 연결 |
| `mailparser` | - | 메일 파싱 |
| `pg` | - | PostgreSQL 클라이언트 |
### 신규 패키지
| 패키지 | 버전 | 용도 |
|--------|------|------|
| `node-pop3` | latest | POP3 연결 |
---
## 핵심 고려 사항
### 보안
1. 메일 계정 비밀번호는 항상 암호화 상태로 저장
2. 사용자 격리: user_id 기반 접근 제어
3. 외부 서버 연결 정보는 민감: 환경변수 활용
### 성능
1. 메일 조회는 페이지네이션 처리
2. 연결 테스트는 별도 API (현재 메일 검색과 분리)
3. 대량 메일 처리 시 비동기 처리
### 에러 처리
1. 네트워크 오류: 재시도 로직
2. 인증 실패: 명확한 에러 메시지 제공
3. DB 오류: 트랜잭션 롤백
---
## 변경 이력
| 날짜 | 내용 |
|------|------|
| 2026-03-27 | 초안 작성 |
+161
View File
@@ -0,0 +1,161 @@
# UML[체크] - 사용자 메일 관리 시스템
## 공정 상태
**진행률: 90%** (IMAP 완성, POP3 미구현)
---
## 구현 체크리스트
### 데이터베이스
- [x] DB 마이그레이션 작성 (user_mail_accounts 테이블 생성)
### 패키지 설치
- [ ] npm install node-pop3 (설치됨, 서비스 미구현)
### 백엔드 서비스 계층
- [x] userMailAccountService.ts (DB CRUD)
- [x] userMailImapService.ts (IMAP 프로토콜)
- [x] userMailSmtpService.ts (SMTP 발송)
- [x] imapConnectionPool.ts (IMAP 연결 풀)
- [x] mailCache.ts (메일 캐시)
- [ ] userMailPop3Service.ts (POP3 프로토콜 - 미구현)
### 백엔드 API 계층
- [x] userMailController.ts (요청 처리)
- [x] userMailRoutes.ts (라우트 정의)
- [x] app.ts에 userMailRoutes 등록 (`/api/user-mail`)
### 프론트엔드 API 클라이언트
- [x] frontend/lib/api/userMail.ts
### 프론트엔드 페이지
- [x] frontend/app/(main)/mail/imap/page.tsx
- [x] frontend/app/(main)/mail/imap/ComposeDialog.tsx (메일 작성)
- [ ] frontend/app/(main)/mail/pop3/page.tsx (미구현)
### 페이지 등록
- [x] AdminPageRenderer.tsx에 /mail/imap 등록
- [ ] AdminPageRenderer.tsx에 /mail/pop3 등록 (미구현)
---
## 구현된 IMAP 기능
### 계정 관리
- [x] 계정 추가 (연결 테스트 후 저장)
- [x] 계정 수정
- [x] 계정 삭제
- [x] 연결 테스트 (저장 전 자동 + 수동)
### 메일 조회
- [x] SSE 스트리밍으로 메일 목록 로드 (20개씩)
- [x] 이전 메일 더 보기 (무한 스크롤 방식)
- [x] 메일 상세 조회 (HTML/텍스트 본문)
- [x] 폴더별 메일 조회 (INBOX, 휴지통, 스팸 등)
- [x] 새로고침 버튼
### 메일 관리
- [x] 읽음 처리 (클릭 시 자동, IMAP \Seen 플래그)
- [x] 메일 삭제 (\Trash 특수 폴더로 이동 - Gmail 호환)
- [x] 메일 이동 (폴더 간 이동)
### 첨부파일
- [x] 첨부파일 목록 표시 (pill 형태)
- [x] 첨부파일 다운로드 (ReadableStream 진행바 표시)
- [x] Content-Length 헤더 지원 (정확한 진행률)
### 발신
- [x] 메일 작성 / 발송 (SMTP)
- [x] 답장 (Re: 제목, inReplyTo 헤더)
- [x] 전달 (Fwd: 제목, 원본 본문 인용)
### UI
- [x] 3단 패널 레이아웃 (계정 / 메일 목록 / 상세)
- [x] 폴더 목록 (unseen 카운트 표시)
- [x] 읽음/삭제 후 unseen 카운트 자동 갱신
- [x] 검색 (제목/발신자 클라이언트 필터)
---
## 검증 체크리스트
### 데이터베이스
- [x] `user_mail_accounts` 테이블 존재 확인
- [x] 테이블 스키마 정확성 확인
### 계정 관리 API
- [x] POST `/api/user-mail/accounts` - 계정 생성
- [x] GET `/api/user-mail/accounts` - 사용자 계정 목록
- [x] PUT `/api/user-mail/accounts/:id` - 계정 수정
- [x] DELETE `/api/user-mail/accounts/:id` - 계정 삭제
- [x] POST `/api/user-mail/accounts/:id/test` - 연결 테스트
- [x] POST `/api/user-mail/test-connection` - 직접 연결 테스트
### 메일 API
- [x] GET `/api/user-mail/accounts/:id/mails/stream` - 스트리밍 목록
- [x] GET `/api/user-mail/accounts/:id/mails/:seqno` - 상세 조회
- [x] POST `/api/user-mail/accounts/:id/mails/:seqno/mark-read` - 읽음 처리
- [x] DELETE `/api/user-mail/accounts/:id/mails/:seqno` - 삭제 (휴지통 이동)
- [x] POST `/api/user-mail/accounts/:id/mails/:seqno/move` - 이동
- [x] GET `/api/user-mail/accounts/:id/folders` - 폴더 목록
- [x] GET `/api/user-mail/accounts/:id/folders/:folder/mails/stream` - 폴더별 스트리밍
- [x] GET `/api/user-mail/accounts/:id/mails/:seqno/attachments` - 첨부파일 목록
- [x] GET `/api/user-mail/accounts/:id/mails/:seqno/attachment/:partId` - 첨부파일 다운로드
- [x] POST `/api/user-mail/accounts/:id/send` - 메일 발송
### 사용자 격리 검증
- [x] 모든 쿼리에 WHERE user_id = $n 포함 (DB 레벨 강제)
- [x] 다른 user_id로 계정 접근 시 404 반환
### 프론트엔드 페이지
- [x] `/mail/imap` 페이지 접속 및 동작
- [x] Gmail IMAP 연동 확인
- [x] 메일 목록 → 상세 → 읽음 처리
- [x] 첨부파일 다운로드 진행바
- [x] 메일 삭제 → Gmail 휴지통 이동 확인
- [x] 답장/전달 발송 확인
---
## 알려진 이슈 및 주의사항
### 1. 메일 삭제 방식
- `\Trash` 특수 폴더로 이동 (EXPUNGE 아님)
- Gmail 호환: `[Gmail]/휴지통`으로 자동 라우팅
- 폴더 없으면 `messageDelete` fallback (영구 삭제 주의)
### 2. 첨부파일 진행바
- Content-Length 헤더 기반 진행률 계산
- imapflow `meta.size`로 헤더 설정
- totalSize fallback: `getUserMailAttachments`의 size 필드 사용
### 3. IMAP 연결 풀
- 계정당 1개 연결 유지 (maxIdleMs: 5분)
- busy 상태 시 큐잉 처리
- 연결 끊김 시 자동 재연결
### 4. 캐시
- 메일 목록: 60초 TTL
- 메일 상세: 5분 TTL
- 읽음/삭제/이동 시 해당 캐시 무효화
### 5. POP3 미구현
- `node-pop3` 패키지 설치됨
- 서비스 파일 미작성
- 팀장 지시 후 구현 예정
---
## 변경 이력
| 날짜 | 버전 | 내용 |
|------|------|------|
| 2026-03-27 | v1.0 | 초안 작성 |
| 2026-03-30 | v2.0 | IMAP 전 기능 구현 완료 (메일 조회/삭제/이동/첨부/발송/답장/전달/폴더/진행바) |
---
## 관련 문서
- [UML[계획]-user-mail.md](./UML[계획]-user-mail.md): 아키텍처 및 설계
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
.omc/
@@ -1,14 +0,0 @@
{"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}
@@ -1,10 +0,0 @@
{"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}
@@ -1,3 +0,0 @@
{
"lastSentAt": "2026-03-25T05:06:13.529Z"
}
-7
View File
@@ -1,7 +0,0 @@
{
"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
}
-281
View File
@@ -1,281 +0,0 @@
{
"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"
}
]
}
]
}
-116
View File
@@ -1,116 +0,0 @@
{
"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"
}
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,782 @@
"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 [inspectionContinuous, setInspectionContinuous] = useState(false);
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
const [consumableContinuous, setConsumableContinuous] = useState(false);
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("추가되었습니다.");
if (inspectionContinuous) {
setInspectionForm({});
} else {
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("추가되었습니다.");
if (consumableContinuous) {
setConsumableForm({});
} else {
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 className="flex items-center justify-between sm:justify-between">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={inspectionContinuous} onCheckedChange={(c) => setInspectionContinuous(!!c)} />
</label>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setInspectionModalOpen(false)}></Button>
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button>
</div>
</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 className="flex items-center justify-between sm:justify-between">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={consumableContinuous} onCheckedChange={(c) => setConsumableContinuous(!!c)} />
</label>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setConsumableModalOpen(false)}></Button>
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button>
</div>
</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<string[]>([]);
const [selectedWoId, setSelectedWoId] = useState<string | 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: string, checked: boolean) => {
setCheckedWoIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id)
);
}, []);
const handleSelectWo = useCallback((id: string) => {
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,542 @@
"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 * as departmentAPI from "@/lib/api/department";
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
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 [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
// 사원 모달
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) {
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 = async () => {
setDeptForm({});
setDeptEditMode(false);
setPreviewCode(null);
setNumberingRuleId(null);
setDeptModalOpen(true);
// 채번 규칙 조회 (dept_info.dept_code) — path params로 직접 호출
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/dept_info/dept_code`);
const ruleData = ruleRes.data;
if (ruleData?.success && ruleData?.data?.ruleId) {
const ruleId = ruleData.data.ruleId;
setNumberingRuleId(ruleId);
const previewRes = await previewNumberingCode(ruleId);
if (previewRes.success && previewRes.data?.generatedCode) {
setPreviewCode(previewRes.data.generatedCode);
}
}
} catch {
// 채번 규칙 없으면 무시
}
};
const openDeptEdit = () => {
if (!selectedDept) return;
setDeptForm({ ...selectedDept });
setDeptEditMode(true);
setDeptModalOpen(true);
};
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
const parentCode = (deptForm.parent_dept_code && deptForm.parent_dept_code !== "none") ? deptForm.parent_dept_code : null;
setSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
const response = await departmentAPI.updateDepartment(deptForm.dept_code, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
});
if (!response.success) { toast.error((response as any).error || "수정에 실패했습니다."); return; }
toast.success("수정되었습니다.");
} else {
const companyCode = user?.companyCode || "";
// 채번 규칙이 있으면 allocate로 실제 코드 할당
let allocatedCode: string | undefined;
if (numberingRuleId) {
const allocRes = await allocateNumberingCode(numberingRuleId);
if (allocRes.success && allocRes.data?.generatedCode) {
allocatedCode = allocRes.data.generatedCode;
} else {
toast.error("채번 코드 할당에 실패했습니다.");
return;
}
}
const response = await departmentAPI.createDepartment(companyCode, {
dept_name: deptForm.dept_name,
parent_dept_code: parentCode,
dept_code: allocatedCode,
});
if (!response.success) {
toast.error((response as any).error || "등록에 실패했습니다.");
return;
}
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 {
const response = await departmentAPI.deleteDepartment(selectedDeptCode);
if (!response.success) { toast.error((response as any).error || "삭제에 실패했습니다."); return; }
toast.success(response.message || "삭제되었습니다.");
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; }
if (!userForm.dept_code) { 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: userEditMode ? (userForm.email || null) : (userForm.email || undefined),
tel: userForm.tel || undefined,
cell_phone: userEditMode ? (userForm.cell_phone || null) : (userForm.cell_phone || undefined),
sabun: userEditMode ? (userForm.sabun || null) : (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={deptEditMode ? (deptForm.dept_code || "") : (previewCode || "")}
placeholder={deptEditMode ? "" : (numberingRuleId ? "채번 조회 중..." : "자동 생성됩니다")}
className="h-9" disabled readOnly />
</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" autoComplete="off" />
</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" autoComplete="new-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"> <span className="text-destructive">*</span></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", placeholder: "숫자 입력 (예: 3.5)" },
{ key: "volum", label: "부피", type: "text", placeholder: "숫자 입력 (예: 100)" },
{ key: "specific_gravity", label: "비중", type: "text", placeholder: "숫자 입력 (예: 7.85)" },
{ 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.placeholder || (field.disabled ? "자동 채번" : 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