Merge branch 'main' of https://g.wace.me/jskim/vexplor_dev
This commit is contained in:
@@ -107,6 +107,9 @@ settings/
|
||||
*.crt
|
||||
*.cert
|
||||
secrets/
|
||||
|
||||
# oh-my-claudecode 로컬 세션/상태 파일
|
||||
.omc/
|
||||
secrets.json
|
||||
secrets.yaml
|
||||
secrets.yml
|
||||
@@ -231,3 +234,4 @@ test-results/
|
||||
frontend/playwright.config.ts
|
||||
frontend/tests/
|
||||
frontend/test-results/
|
||||
db/checkpoints/
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"lastSentAt": "2026-03-24T02:36:44.477Z"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+339
-1
@@ -26,6 +26,7 @@
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"imapflow": "^1.2.18",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.5",
|
||||
@@ -34,6 +35,7 @@
|
||||
"mysql2": "^3.15.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-pop3": "^0.11.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
@@ -41,6 +43,7 @@
|
||||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
"redis": "^4.6.10",
|
||||
"socket.io": "^4.8.3",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
@@ -2361,6 +2364,12 @@
|
||||
"@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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
|
||||
@@ -3122,6 +3131,12 @@
|
||||
"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": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@tediousjs/connection-string/-/connection-string-0.5.0.tgz",
|
||||
@@ -3261,7 +3276,6 @@
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -3664,6 +3678,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "17.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||
@@ -3900,6 +3923,17 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
@@ -4120,6 +4154,15 @@
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||
@@ -4293,6 +4336,15 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "2.8.7",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.7.tgz",
|
||||
@@ -5673,6 +5725,45 @@
|
||||
"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": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ent/-/ent-2.2.2.tgz",
|
||||
@@ -7215,6 +7306,48 @@
|
||||
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
||||
"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": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
@@ -7291,6 +7424,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"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": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -9039,6 +9181,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.21",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.21.tgz",
|
||||
@@ -9212,6 +9366,15 @@
|
||||
"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": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -9641,6 +9804,43 @@
|
||||
"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": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
@@ -9872,6 +10072,22 @@
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"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": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
@@ -9993,6 +10209,12 @@
|
||||
],
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||
@@ -10187,6 +10409,15 @@
|
||||
"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": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
|
||||
@@ -10725,6 +10956,80 @@
|
||||
"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": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@@ -11098,6 +11403,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.260.0",
|
||||
"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_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": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"imapflow": "^1.2.18",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.5",
|
||||
@@ -48,6 +49,7 @@
|
||||
"mysql2": "^3.15.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "^2.7.0",
|
||||
"node-pop3": "^0.11.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
@@ -55,6 +57,7 @@
|
||||
"quill": "^2.0.3",
|
||||
"react-quill": "^2.0.0",
|
||||
"redis": "^4.6.10",
|
||||
"socket.io": "^4.8.3",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
|
||||
@@ -44,6 +44,8 @@ process.on("SIGTERM", () => {
|
||||
logger.info("📴 SIGTERM 시그널 수신, graceful shutdown 시작...");
|
||||
const { stopAiAssistant } = require("./utils/startAiAssistant");
|
||||
stopAiAssistant();
|
||||
const { imapConnectionPool } = require("./services/imapConnectionPool");
|
||||
imapConnectionPool.destroyAll();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -52,6 +54,8 @@ process.on("SIGINT", () => {
|
||||
logger.info("📴 SIGINT 시그널 수신, graceful shutdown 시작...");
|
||||
const { stopAiAssistant } = require("./utils/startAiAssistant");
|
||||
stopAiAssistant();
|
||||
const { imapConnectionPool } = require("./services/imapConnectionPool");
|
||||
imapConnectionPool.destroyAll();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -131,6 +135,8 @@ import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산
|
||||
import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검사 결과 관리
|
||||
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
|
||||
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
|
||||
import userMailRoutes from "./routes/userMailRoutes"; // 사용자 메일 계정
|
||||
import messengerRoutes from "./routes/messengerRoutes"; // 메신저
|
||||
import driverRoutes from "./routes/driverRoutes"; // 공차중계 운전자 관리
|
||||
import taxInvoiceRoutes from "./routes/taxInvoiceRoutes"; // 세금계산서 관리
|
||||
import cascadingRelationRoutes from "./routes/cascadingRelationRoutes"; // 연쇄 드롭다운 관계 관리
|
||||
@@ -156,6 +162,7 @@ import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현
|
||||
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
|
||||
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
|
||||
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
|
||||
import quoteRoutes from "./routes/quoteRoutes"; // 견적관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
@@ -375,10 +382,13 @@ app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재
|
||||
app.use("/api/design", designRoutes); // 설계 모듈
|
||||
app.use("/api/receiving", receivingRoutes); // 입고관리
|
||||
app.use("/api/outbound", outboundRoutes); // 출고관리
|
||||
app.use("/api/quotes", quoteRoutes); // 견적관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
app.use("/api/approval", approvalRoutes); // 결재 시스템
|
||||
app.use("/api/user-mail", userMailRoutes); // 사용자 메일 계정
|
||||
app.use("/api/messenger", messengerRoutes); // 메신저
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// 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(`🌐 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 => {
|
||||
logger.error('❌ 서비스 초기화 중 치명적 에러 발생:', err);
|
||||
@@ -421,12 +447,16 @@ async function initializeServices() {
|
||||
runTableHistoryActionMigration,
|
||||
runDtgManagementLogMigration,
|
||||
runApprovalSystemMigration,
|
||||
runUserMailAccountsMigration,
|
||||
runMessengerMigration,
|
||||
} = await import("./database/runMigration");
|
||||
|
||||
await runDashboardMigration();
|
||||
await runTableHistoryActionMigration();
|
||||
await runDtgManagementLogMigration();
|
||||
await runApprovalSystemMigration();
|
||||
await runUserMailAccountsMigration();
|
||||
await runMessengerMigration();
|
||||
} catch (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,
|
||||
},
|
||||
});
|
||||
@@ -237,7 +237,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||
|
||||
// 회사 코드 필터 (권한 그룹 멤버 관리 시 사용)
|
||||
if (companyCode && typeof companyCode === "string" && companyCode.trim()) {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
whereConditions.push(`u.company_code = $${paramIndex}`);
|
||||
queryParams.push(companyCode.trim());
|
||||
paramIndex++;
|
||||
logger.info("회사 코드 필터 적용", { companyCode });
|
||||
@@ -246,7 +246,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||
// 최고 관리자 필터링 (회사 관리자와 일반 사용자는 최고 관리자를 볼 수 없음)
|
||||
if (req.user && req.user.companyCode !== "*") {
|
||||
// 최고 관리자가 아닌 경우, company_code가 "*"인 사용자는 제외
|
||||
whereConditions.push(`company_code != '*'`);
|
||||
whereConditions.push(`u.company_code != '*'`);
|
||||
logger.info("최고 관리자 필터링 적용", {
|
||||
userCompanyCode: req.user.companyCode,
|
||||
});
|
||||
@@ -259,15 +259,15 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const searchTerm = search.trim();
|
||||
|
||||
whereConditions.push(`(
|
||||
sabun ILIKE $${paramIndex} OR
|
||||
user_type_name ILIKE $${paramIndex} OR
|
||||
dept_name ILIKE $${paramIndex} OR
|
||||
position_name ILIKE $${paramIndex} OR
|
||||
user_id ILIKE $${paramIndex} OR
|
||||
user_name ILIKE $${paramIndex} OR
|
||||
tel ILIKE $${paramIndex} OR
|
||||
cell_phone ILIKE $${paramIndex} OR
|
||||
email ILIKE $${paramIndex}
|
||||
u.sabun ILIKE $${paramIndex} OR
|
||||
u.user_type_name ILIKE $${paramIndex} OR
|
||||
u.dept_name ILIKE $${paramIndex} OR
|
||||
u.position_name ILIKE $${paramIndex} OR
|
||||
u.user_id ILIKE $${paramIndex} OR
|
||||
u.user_name ILIKE $${paramIndex} OR
|
||||
u.tel ILIKE $${paramIndex} OR
|
||||
u.cell_phone ILIKE $${paramIndex} OR
|
||||
u.email ILIKE $${paramIndex}
|
||||
)`);
|
||||
queryParams.push(`%${searchTerm}%`);
|
||||
paramIndex++;
|
||||
@@ -277,21 +277,21 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||
// 단일 필드 검색
|
||||
searchType = "single";
|
||||
const fieldMap: { [key: string]: string } = {
|
||||
sabun: "sabun",
|
||||
companyName: "user_type_name",
|
||||
deptName: "dept_name",
|
||||
positionName: "position_name",
|
||||
userId: "user_id",
|
||||
userName: "user_name",
|
||||
tel: "tel",
|
||||
cellPhone: "cell_phone",
|
||||
email: "email",
|
||||
sabun: "u.sabun",
|
||||
companyName: "u.user_type_name",
|
||||
deptName: "u.dept_name",
|
||||
positionName: "u.position_name",
|
||||
userId: "u.user_id",
|
||||
userName: "u.user_name",
|
||||
tel: "u.tel",
|
||||
cellPhone: "u.cell_phone",
|
||||
email: "u.email",
|
||||
};
|
||||
|
||||
if (fieldMap[searchField as string]) {
|
||||
if (searchField === "tel") {
|
||||
whereConditions.push(
|
||||
`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`
|
||||
`(u.tel ILIKE $${paramIndex} OR u.cell_phone ILIKE $${paramIndex})`
|
||||
);
|
||||
queryParams.push(`%${searchValue}%`);
|
||||
paramIndex++;
|
||||
@@ -307,13 +307,13 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||
} else {
|
||||
// 고급 검색 (개별 필드별 AND 조건)
|
||||
const advancedSearchFields = [
|
||||
{ param: search_sabun, field: "sabun" },
|
||||
{ param: search_companyName, field: "user_type_name" },
|
||||
{ param: search_deptName, field: "dept_name" },
|
||||
{ param: search_positionName, field: "position_name" },
|
||||
{ param: search_userId, field: "user_id" },
|
||||
{ param: search_userName, field: "user_name" },
|
||||
{ param: search_email, field: "email" },
|
||||
{ param: search_sabun, field: "u.sabun" },
|
||||
{ param: search_companyName, field: "u.user_type_name" },
|
||||
{ param: search_deptName, field: "u.dept_name" },
|
||||
{ param: search_positionName, field: "u.position_name" },
|
||||
{ param: search_userId, field: "u.user_id" },
|
||||
{ param: search_userName, field: "u.user_name" },
|
||||
{ param: search_email, field: "u.email" },
|
||||
];
|
||||
|
||||
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()) {
|
||||
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()}%`);
|
||||
paramIndex++;
|
||||
@@ -354,7 +354,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||
|
||||
// 현재 로그인한 사용자의 회사 코드 필터 (슈퍼관리자가 아닌 경우)
|
||||
if (req.user && req.user.companyCode !== "*" && !companyCode) {
|
||||
whereConditions.push(`company_code = $${paramIndex}`);
|
||||
whereConditions.push(`u.company_code = $${paramIndex}`);
|
||||
queryParams.push(req.user.companyCode);
|
||||
paramIndex++;
|
||||
logger.info("사용자 회사 코드 필터 적용", {
|
||||
@@ -364,13 +364,13 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||
|
||||
// 기존 필터들
|
||||
if (deptCode) {
|
||||
whereConditions.push(`dept_code = $${paramIndex}`);
|
||||
whereConditions.push(`u.dept_code = $${paramIndex}`);
|
||||
queryParams.push(deptCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
whereConditions.push(`status = $${paramIndex}`);
|
||||
whereConditions.push(`u.status = $${paramIndex}`);
|
||||
queryParams.push(status);
|
||||
paramIndex++;
|
||||
}
|
||||
@@ -383,7 +383,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||
// 총 개수 조회
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM user_info
|
||||
FROM user_info u
|
||||
${whereClause}
|
||||
`;
|
||||
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 usersQuery = `
|
||||
SELECT
|
||||
sabun,
|
||||
user_id,
|
||||
user_name,
|
||||
user_name_eng,
|
||||
dept_code,
|
||||
dept_name,
|
||||
position_code,
|
||||
position_name,
|
||||
email,
|
||||
tel,
|
||||
cell_phone,
|
||||
user_type,
|
||||
user_type_name,
|
||||
regdate,
|
||||
status,
|
||||
company_code,
|
||||
locale
|
||||
FROM user_info
|
||||
u.sabun,
|
||||
u.user_id,
|
||||
u.user_name,
|
||||
u.user_name_eng,
|
||||
u.dept_code,
|
||||
u.dept_name,
|
||||
u.position_code,
|
||||
u.position_name,
|
||||
u.email,
|
||||
u.tel,
|
||||
u.cell_phone,
|
||||
u.user_type,
|
||||
u.user_type_name,
|
||||
u.regdate,
|
||||
u.status,
|
||||
u.company_code,
|
||||
u.locale,
|
||||
c.company_name
|
||||
FROM user_info u
|
||||
LEFT JOIN company_mng c ON u.company_code = c.company_code
|
||||
${whereClause}
|
||||
ORDER BY regdate DESC, user_name ASC
|
||||
ORDER BY u.regdate DESC, u.user_name ASC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
@@ -436,6 +438,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||
userTypeName: user.user_type_name || null,
|
||||
status: user.status || "active",
|
||||
companyCode: user.company_code || null,
|
||||
companyName: user.company_name || null,
|
||||
locale: user.locale || null,
|
||||
regDate: user.regdate
|
||||
? new Date(user.regdate).toISOString().split("T")[0]
|
||||
@@ -1402,8 +1405,9 @@ export async function updateMenu(
|
||||
]
|
||||
);
|
||||
|
||||
// menu_url이 비어있으면 화면 할당도 해제 (screen_menu_assignments의 is_active를 'N'으로)
|
||||
if (!menuUrl) {
|
||||
// menu_url이 비어있거나 화면관리 URL이 아니면 화면 할당 해제
|
||||
const isScreenUrl = menuUrl && (menuUrl.startsWith("/screens/") || menuUrl.startsWith("/screen/"));
|
||||
if (!menuUrl || !isScreenUrl) {
|
||||
await query(
|
||||
`UPDATE screen_menu_assignments
|
||||
SET is_active = 'N'
|
||||
@@ -2696,6 +2700,35 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||
});
|
||||
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. 비밀번호 최소 길이 검증 (신규 등록 시)
|
||||
@@ -3696,17 +3729,6 @@ export const resetUserPassword = async (
|
||||
return;
|
||||
}
|
||||
|
||||
// 비밀번호 길이 검증 (최소 4자)
|
||||
if (newPassword.length < 4) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "비밀번호는 최소 4자 이상이어야 합니다.",
|
||||
msg: "비밀번호는 최소 4자 이상이어야 합니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Raw Query로 사용자 존재 여부 확인
|
||||
const currentUser = await queryOne<any>(
|
||||
@@ -3724,19 +3746,10 @@ export const resetUserPassword = async (
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 비밀번호 암호화 (기존 Java 로직과 동일)
|
||||
// 2. 비밀번호 암호화 (EncryptUtil 사용)
|
||||
let encryptedPassword: string;
|
||||
try {
|
||||
// EncryptUtil과 동일한 암호화 사용
|
||||
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();
|
||||
encryptedPassword = EncryptUtil.encrypt(newPassword);
|
||||
} catch (encryptError) {
|
||||
logger.error("비밀번호 암호화 중 오류 발생", {
|
||||
error: encryptError,
|
||||
|
||||
@@ -89,6 +89,7 @@ export class AuthController {
|
||||
userId: userInfo.userId,
|
||||
remoteAddr,
|
||||
useType: "접속",
|
||||
companyCode: userInfo.companyCode,
|
||||
}).catch(() => {});
|
||||
|
||||
// POP 랜딩 경로 조회
|
||||
|
||||
@@ -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,56 @@
|
||||
import { Request, Response } from "express";
|
||||
import { SampleService } from "../services/sampleService";
|
||||
|
||||
const sampleService = SampleService.getInstance();
|
||||
|
||||
/**
|
||||
* 목록 조회
|
||||
* GET /api/sample/list?page=1&limit=20
|
||||
*/
|
||||
export const getList = async (req: Request, res: Response): Promise<void> => {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string) || 20));
|
||||
const result = await sampleService.getList(page, limit);
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: result.items,
|
||||
total: result.total,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 등록
|
||||
* POST /api/sample
|
||||
*/
|
||||
export const create = async (req: Request, res: Response): Promise<void> => {
|
||||
const { name, description } = req.body;
|
||||
if (!name || !description) {
|
||||
res.status(400).json({ success: false, message: "name, description은 필수입니다." });
|
||||
return;
|
||||
}
|
||||
const item = await sampleService.create(name, description);
|
||||
res.status(201).json({ success: true, data: item });
|
||||
};
|
||||
|
||||
/**
|
||||
* 수정
|
||||
* PUT /api/sample/:id
|
||||
*/
|
||||
export const update = async (req: Request, res: Response): Promise<void> => {
|
||||
const { id } = req.params;
|
||||
const { name, description } = req.body;
|
||||
const item = await sampleService.update(id, name, description);
|
||||
res.status(200).json({ success: true, data: item });
|
||||
};
|
||||
|
||||
/**
|
||||
* 삭제 (soft delete)
|
||||
* DELETE /api/sample/:id
|
||||
*/
|
||||
export const remove = async (req: Request, res: Response): Promise<void> => {
|
||||
const { id } = req.params;
|
||||
await sampleService.softDelete(id);
|
||||
res.status(200).json({ success: true, 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();
|
||||
@@ -112,6 +112,64 @@ export async function runTableHistoryActionMigration() {
|
||||
/**
|
||||
* 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() {
|
||||
try {
|
||||
console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작...");
|
||||
|
||||
@@ -29,9 +29,9 @@ export const authenticateToken = async (
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Authorization 헤더에서 토큰 추출
|
||||
// Authorization 헤더 또는 query param에서 토큰 추출 (파일 다운로드용)
|
||||
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) {
|
||||
res.status(401).json({
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface AuthenticatedRequest extends Request {
|
||||
userId: string;
|
||||
userName: string;
|
||||
companyCode: string;
|
||||
userType?: string;
|
||||
userLang?: string;
|
||||
};
|
||||
}
|
||||
@@ -47,8 +48,9 @@ export const requireSuperAdmin = (
|
||||
return;
|
||||
}
|
||||
|
||||
// 슈퍼관리자 권한 확인 (회사코드가 '*'인 사용자)
|
||||
if (req.user.companyCode !== "*") {
|
||||
// 슈퍼관리자 권한 확인 (회사코드가 '*'이거나 userType이 'SUPER_ADMIN'인 사용자)
|
||||
// 회사전환 후에도 SUPER_ADMIN은 관리 기능에 접근 가능해야 함
|
||||
if (req.user.companyCode !== "*" && req.user.userType !== "SUPER_ADMIN") {
|
||||
logger.warn("DDL 실행 시도 - 권한 부족", {
|
||||
userId: req.user.userId,
|
||||
companyCode: req.user.companyCode,
|
||||
@@ -167,7 +169,7 @@ export const validateDDLPermission = (
|
||||
* 사용자가 슈퍼관리자인지 확인하는 유틸리티 함수
|
||||
*/
|
||||
export const isSuperAdmin = (user: AuthenticatedRequest["user"]): boolean => {
|
||||
return user?.companyCode === "*";
|
||||
return user?.companyCode === "*" || user?.userType === "SUPER_ADMIN";
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
getTableSchema, // 테이블 스키마 조회
|
||||
} from "../controllers/adminController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { requireSuperAdmin } from "../middleware/permissionMiddleware";
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -68,13 +69,13 @@ router.delete("/users/:userId", deleteUser); // 사용자 삭제 (soft delete)
|
||||
// 부서 관리 API
|
||||
router.get("/departments", getDepartmentList); // 부서 목록 조회
|
||||
|
||||
// 회사 관리 API
|
||||
router.get("/companies", getCompanyList);
|
||||
router.get("/companies/db", getCompanyListFromDB); // 실제 DB에서 회사 목록 조회
|
||||
router.get("/companies/:companyCode", getCompanyByCode); // 회사 단건 조회
|
||||
router.post("/companies", createCompany); // 회사 등록
|
||||
router.put("/companies/:companyCode", updateCompany); // 회사 수정
|
||||
router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제
|
||||
// 회사 관리 API (최고관리자 전용)
|
||||
router.get("/companies", requireSuperAdmin, getCompanyList);
|
||||
router.get("/companies/db", requireSuperAdmin, getCompanyListFromDB);
|
||||
router.get("/companies/:companyCode", requireSuperAdmin, getCompanyByCode);
|
||||
router.post("/companies", requireSuperAdmin, createCompany);
|
||||
router.put("/companies/:companyCode", requireSuperAdmin, updateCompany);
|
||||
router.delete("/companies/:companyCode", requireSuperAdmin, deleteCompany);
|
||||
|
||||
// 사용자 로케일 API
|
||||
router.get("/user-locale", getUserLocale);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { requireSuperAdmin } from "../middleware/permissionMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
import { FileSystemManager } from "../utils/fileSystemManager";
|
||||
@@ -7,8 +8,9 @@ import { query, queryOne } from "../database/db";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
// 모든 라우트에 인증 + 최고관리자 권한 필수
|
||||
router.use(authenticateToken);
|
||||
router.use(requireSuperAdmin);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as sampleController from "../controllers/sampleController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get("/list", sampleController.getList);
|
||||
router.post("/", sampleController.create);
|
||||
router.put("/:id", sampleController.update);
|
||||
router.delete("/:id", sampleController.remove);
|
||||
|
||||
export default router;
|
||||
@@ -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 {
|
||||
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');
|
||||
encrypted += cipher.final('hex');
|
||||
@@ -34,7 +34,7 @@ class EncryptionService {
|
||||
const iv = Buffer.from(ivHex, '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);
|
||||
|
||||
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();
|
||||
@@ -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();
|
||||
@@ -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,86 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface SampleItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
is_deleted: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// 인메모리 저장소 (실제 DB 테이블 없이도 동작)
|
||||
const store = new Map<string, SampleItem>();
|
||||
|
||||
export class SampleService {
|
||||
private static instance: SampleService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): SampleService {
|
||||
if (!SampleService.instance) {
|
||||
SampleService.instance = new SampleService();
|
||||
}
|
||||
return SampleService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 조회 (페이징, soft delete 제외)
|
||||
*/
|
||||
public async getList(page: number, limit: number): Promise<{ items: SampleItem[]; total: number }> {
|
||||
const active = Array.from(store.values()).filter((item) => !item.is_deleted);
|
||||
const total = active.length;
|
||||
const items = active.slice((page - 1) * limit, page * limit);
|
||||
logger.info(`📋 sample 목록 조회: total=${total}, page=${page}, limit=${limit}`);
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록
|
||||
*/
|
||||
public async create(name: string, description: string): Promise<SampleItem> {
|
||||
const now = new Date().toISOString();
|
||||
const item: SampleItem = {
|
||||
id: uuidv4(),
|
||||
name,
|
||||
description,
|
||||
is_deleted: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
store.set(item.id, item);
|
||||
logger.info(`✅ sample 등록: ${item.id}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수정
|
||||
*/
|
||||
public async update(id: string, name?: string, description?: string): Promise<SampleItem> {
|
||||
const item = store.get(id);
|
||||
if (!item || item.is_deleted) {
|
||||
throw new Error(`항목을 찾을 수 없습니다: ${id}`);
|
||||
}
|
||||
if (name !== undefined) item.name = name;
|
||||
if (description !== undefined) item.description = description;
|
||||
item.updated_at = new Date().toISOString();
|
||||
store.set(id, item);
|
||||
logger.info(`✅ sample 수정: ${id}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete
|
||||
*/
|
||||
public async softDelete(id: string): Promise<void> {
|
||||
const item = store.get(id);
|
||||
if (!item || item.is_deleted) {
|
||||
throw new Error(`항목을 찾을 수 없습니다: ${id}`);
|
||||
}
|
||||
item.is_deleted = true;
|
||||
item.updated_at = new Date().toISOString();
|
||||
store.set(id, item);
|
||||
logger.info(`✅ sample 삭제(soft): ${id}`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -21,7 +21,8 @@ export enum PermissionLevel {
|
||||
*/
|
||||
export function isSuperAdmin(user?: PersonBean | null): boolean {
|
||||
if (!user) return false;
|
||||
return user.companyCode === "*" && user.userType === "SUPER_ADMIN";
|
||||
// 회사전환 후에도 userType이 SUPER_ADMIN이면 최고관리자로 인정
|
||||
return user.userType === "SUPER_ADMIN";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,8 +15,11 @@ export async function sendSmartFactoryLog(params: {
|
||||
userId: string;
|
||||
remoteAddr: string;
|
||||
useType?: string;
|
||||
companyCode?: string;
|
||||
}): Promise<void> {
|
||||
const apiKey = process.env.SMART_FACTORY_API_KEY;
|
||||
// 회사별 키 우선 조회, 없으면 공통 키 폴백
|
||||
const apiKey = (params.companyCode && process.env[`SMART_FACTORY_API_KEY_${params.companyCode}`])
|
||||
|| process.env.SMART_FACTORY_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
logger.warn(
|
||||
|
||||
@@ -22,6 +22,8 @@ services:
|
||||
KMA_API_KEY: ${KMA_API_KEY}
|
||||
ITS_API_KEY: ${ITS_API_KEY}
|
||||
EXPRESSWAY_API_KEY: ${EXPRESSWAY_API_KEY:-}
|
||||
SMART_FACTORY_API_KEY_COMPANY_10: ${SMART_FACTORY_API_KEY_COMPANY_10:-}
|
||||
SMART_FACTORY_API_KEY_COMPANY_9: ${SMART_FACTORY_API_KEY_COMPANY_9:-}
|
||||
volumes:
|
||||
- backend_uploads:/app/uploads
|
||||
- backend_data:/app/data
|
||||
|
||||
@@ -23,6 +23,8 @@ services:
|
||||
- KMA_API_KEY=${KMA_API_KEY}
|
||||
- ITS_API_KEY=${ITS_API_KEY}
|
||||
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
|
||||
- SMART_FACTORY_API_KEY_COMPANY_10=${SMART_FACTORY_API_KEY_COMPANY_10:-}
|
||||
- SMART_FACTORY_API_KEY_COMPANY_9=${SMART_FACTORY_API_KEY_COMPANY_9:-}
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
||||
|
||||
@@ -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 작성, 구현 시작 |
|
||||
@@ -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 또는 최상단 마운트)
|
||||
@@ -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}` 형식으로 회사별 격리.
|
||||
@@ -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 | 최초 설계 확정 |
|
||||
@@ -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 | 초안 작성 |
|
||||
@@ -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 | 초안 작성 |
|
||||
@@ -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): 아키텍처 및 설계
|
||||
@@ -39,3 +39,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user