Merge pull request 'jskim-node' (#5) from jskim-node into main
Reviewed-on: https://g.wace.me/jskim/vexplor_dev/pulls/5
This commit is contained in:
@@ -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,10 +1405,11 @@ 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
|
||||
`UPDATE screen_menu_assignments
|
||||
SET is_active = 'N'
|
||||
WHERE menu_objid = $1 AND company_code = $2`,
|
||||
[Number(menuId), companyCode]
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { messengerService } from '../services/messengerService';
|
||||
import { AuthenticatedRequest } from '../types/auth';
|
||||
import { getIo } from '../socket/socketManager';
|
||||
import path from 'path';
|
||||
|
||||
class MessengerController {
|
||||
async getRooms(req: Request, res: Response) {
|
||||
try {
|
||||
const user = (req as AuthenticatedRequest).user!;
|
||||
const rooms = await messengerService.getRooms(user.userId, user.companyCode!);
|
||||
res.json({ success: true, data: rooms });
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('getRooms error:', err.message);
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async createRoom(req: Request, res: Response) {
|
||||
try {
|
||||
const user = (req as AuthenticatedRequest).user!;
|
||||
const room_type = req.body.room_type ?? req.body.type;
|
||||
const room_name = req.body.room_name ?? req.body.name;
|
||||
const participant_ids = req.body.participant_ids ?? req.body.participantIds;
|
||||
|
||||
if (!room_type || !participant_ids || !Array.isArray(participant_ids)) {
|
||||
return res.status(400).json({ success: false, message: 'room_type and participant_ids are required.' });
|
||||
}
|
||||
|
||||
const room = await messengerService.createRoom(user.userId, user.companyCode!, {
|
||||
room_type,
|
||||
room_name,
|
||||
participant_ids,
|
||||
});
|
||||
res.json({ success: true, data: room });
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('createRoom error:', err.message);
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getMessages(req: Request, res: Response) {
|
||||
try {
|
||||
const user = (req as AuthenticatedRequest).user!;
|
||||
const roomId = parseInt(req.params.roomId, 10);
|
||||
const limit = parseInt(req.query.limit as string, 10) || 50;
|
||||
const before = req.query.before ? parseInt(req.query.before as string, 10) : undefined;
|
||||
|
||||
const messages = await messengerService.getMessages(roomId, user.userId, user.companyCode!, limit, before);
|
||||
res.json({ success: true, data: messages });
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('getMessages error:', err.message);
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(req: Request, res: Response) {
|
||||
try {
|
||||
const user = (req as AuthenticatedRequest).user!;
|
||||
const roomId = parseInt(req.params.roomId, 10);
|
||||
const content = req.body.content;
|
||||
const messageType = req.body.type ?? req.body.message_type ?? 'text';
|
||||
const parentId = req.body.parentId ?? req.body.parent_message_id ?? null;
|
||||
|
||||
if (!content) {
|
||||
return res.status(400).json({ success: false, message: 'content is required.' });
|
||||
}
|
||||
|
||||
const message = await messengerService.sendMessage(roomId, user.userId, user.companyCode!, content, messageType, parentId);
|
||||
|
||||
// Broadcast to all room participants via Socket.IO
|
||||
const io = getIo();
|
||||
if (io) {
|
||||
io.to(`${user.companyCode}:${roomId}`).emit('new_message', message);
|
||||
}
|
||||
|
||||
res.json({ success: true, data: message });
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('sendMessage error:', err.message);
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async markAsRead(req: Request, res: Response) {
|
||||
try {
|
||||
const user = (req as AuthenticatedRequest).user!;
|
||||
const roomId = parseInt(req.params.roomId, 10);
|
||||
await messengerService.markAsRead(roomId, user.userId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('markAsRead error:', err.message);
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile(req: Request, res: Response) {
|
||||
try {
|
||||
const user = (req as AuthenticatedRequest).user!;
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return res.status(400).json({ success: false, message: 'No files uploaded.' });
|
||||
}
|
||||
|
||||
const roomId = parseInt(req.body.room_id, 10);
|
||||
if (!roomId) {
|
||||
return res.status(400).json({ success: false, message: 'room_id is required.' });
|
||||
}
|
||||
|
||||
const io = getIo();
|
||||
const savedFiles = [];
|
||||
for (const file of files) {
|
||||
// Use a readable placeholder as content to avoid filename encoding issues
|
||||
const isImage = file.mimetype.startsWith('image/');
|
||||
const content = isImage ? '[이미지]' : '[파일]';
|
||||
|
||||
// Create a file message
|
||||
const message = await messengerService.sendMessage(
|
||||
roomId,
|
||||
user.userId,
|
||||
user.companyCode!,
|
||||
content,
|
||||
'file'
|
||||
);
|
||||
|
||||
const savedFile = await messengerService.saveFile(message.id, {
|
||||
originalName: file.originalname,
|
||||
storedName: file.filename,
|
||||
filePath: file.path,
|
||||
fileSize: file.size,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
|
||||
message.files = [savedFile];
|
||||
|
||||
// Broadcast to room so recipients receive it in real-time
|
||||
io.to(`${user.companyCode}:${roomId}`).emit('new_message', message);
|
||||
|
||||
savedFiles.push({ message, file: savedFile });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: savedFiles });
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('uploadFile error:', err.message);
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFile(req: Request, res: Response) {
|
||||
try {
|
||||
const fileId = parseInt(req.params.fileId, 10);
|
||||
const file = await messengerService.getFileById(fileId);
|
||||
|
||||
if (!file) {
|
||||
return res.status(404).json({ success: false, message: 'File not found.' });
|
||||
}
|
||||
|
||||
res.download(file.file_path, file.original_name);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('downloadFile error:', err.message);
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getCompanyUsers(req: Request, res: Response) {
|
||||
try {
|
||||
const user = (req as AuthenticatedRequest).user!;
|
||||
const users = await messengerService.getCompanyUsers(user.companyCode!, user.userId);
|
||||
res.json({ success: true, data: users });
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('getCompanyUsers error:', err.message);
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async updateRoom(req: Request, res: Response) {
|
||||
try {
|
||||
const user = (req as AuthenticatedRequest).user!;
|
||||
const roomId = parseInt(req.params.roomId, 10);
|
||||
const { room_name } = req.body;
|
||||
|
||||
if (!room_name) {
|
||||
return res.status(400).json({ success: false, message: 'room_name is required.' });
|
||||
}
|
||||
|
||||
const room = await messengerService.updateRoom(roomId, user.companyCode!, room_name);
|
||||
if (!room) {
|
||||
return res.status(404).json({ success: false, message: 'Room not found.' });
|
||||
}
|
||||
|
||||
res.json({ success: true, data: room });
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('updateRoom error:', err.message);
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
async getUnreadCount(req: Request, res: Response) {
|
||||
try {
|
||||
const user = (req as AuthenticatedRequest).user!;
|
||||
const count = await messengerService.getUnreadCount(user.userId, user.companyCode!);
|
||||
res.json({ success: true, data: { unread_count: count } });
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('getUnreadCount error:', err.message);
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const messengerController = new MessengerController();
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import * as quoteService from "../services/quoteService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { search, status, startDate, endDate } = req.query as Record<string, string>;
|
||||
|
||||
const data = await quoteService.getList(companyCode, { search, status, startDate, endDate });
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("견적 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
const data = await quoteService.getById(companyCode, parseInt(id));
|
||||
if (!data) {
|
||||
return res.status(404).json({ success: false, message: "견적을 찾을 수 없습니다." });
|
||||
}
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("견적 상세 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const quoteNo = await quoteService.generateNumber(companyCode);
|
||||
return res.json({ success: true, data: { quoteNo } });
|
||||
} catch (error: any) {
|
||||
logger.error("견적번호 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
const data = await quoteService.create(companyCode, userId, req.body);
|
||||
return res.status(201).json({ success: true, data, message: "견적이 등록되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("견적 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
|
||||
await quoteService.update(companyCode, userId, parseInt(id), req.body);
|
||||
return res.json({ success: true, message: "견적이 수정되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("견적 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
await quoteService.remove(companyCode, parseInt(id));
|
||||
return res.json({ success: true, message: "견적이 삭제되었습니다." });
|
||||
} catch (error: any) {
|
||||
logger.error("견적 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthenticatedRequest } from '../types/auth';
|
||||
import { userMailAccountService } from '../services/userMailAccountService';
|
||||
import { userMailImapService } from '../services/userMailImapService';
|
||||
import { userMailSmtpService } from '../services/userMailSmtpService';
|
||||
import { encryptionService } from '../services/encryptionService';
|
||||
import { imapConnectionPool } from '../services/imapConnectionPool';
|
||||
import { mailCache } from '../services/mailCache';
|
||||
|
||||
class UserMailController {
|
||||
async listAccounts(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const protocol = req.query.protocol as string | undefined;
|
||||
const accounts = await userMailAccountService.getAccountsByUserId(userId, protocol);
|
||||
// 비밀번호 제거 후 반환
|
||||
const safe = accounts.map(({ password, ...rest }) => rest);
|
||||
res.json({ success: true, data: safe });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async createAccount(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
// 저장 전 연결 테스트 (임시 account 객체 사용, 평문 비밀번호를 암호화해서 전달)
|
||||
const tempAccount = { ...req.body, id: 0, userId, status: 'active', password: encryptionService.encrypt(req.body.password) };
|
||||
const service = userMailImapService;
|
||||
const testResult = await service.testConnection(tempAccount);
|
||||
if (!testResult.success) {
|
||||
return res.status(400).json({ success: false, message: `연결 테스트 실패: ${testResult.message}` });
|
||||
}
|
||||
|
||||
const account = await userMailAccountService.createAccount(userId, req.body);
|
||||
const { password, ...safe } = account;
|
||||
res.status(201).json({ success: true, data: safe });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateAccount(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
|
||||
// 비밀번호 변경이 포함된 경우 연결 테스트
|
||||
if (req.body.password) {
|
||||
const existing = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!existing) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
const tempAccount = { ...existing, ...req.body, password: encryptionService.encrypt(req.body.password) };
|
||||
const service = userMailImapService;
|
||||
const testResult = await service.testConnection(tempAccount);
|
||||
if (!testResult.success) {
|
||||
return res.status(400).json({ success: false, message: `연결 테스트 실패: ${testResult.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
const account = await userMailAccountService.updateAccount(accountId, userId, req.body);
|
||||
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
imapConnectionPool.destroyByAccount(accountId);
|
||||
mailCache.invalidateByPrefix(`mailList:${accountId}:`);
|
||||
mailCache.invalidateByPrefix(`mailDetail:${accountId}:`);
|
||||
|
||||
const { password, ...safe } = account;
|
||||
res.json({ success: true, data: safe });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAccount(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const deleted = await userMailAccountService.deleteAccount(accountId, userId);
|
||||
if (!deleted) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
imapConnectionPool.destroyByAccount(accountId);
|
||||
mailCache.invalidateByPrefix(`mailList:${accountId}:`);
|
||||
mailCache.invalidateByPrefix(`mailDetail:${accountId}:`);
|
||||
|
||||
res.json({ success: true, message: '계정이 삭제되었습니다.' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async testConnectionDirect(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const { protocol, host, port, useTls, username, password } = req.body;
|
||||
if (!protocol || !host || !port || !username || !password) {
|
||||
return res.status(400).json({ success: false, message: '필수 항목 누락' });
|
||||
}
|
||||
|
||||
const tempAccount = {
|
||||
id: 0, userId, displayName: '', email: '', protocol, host, port,
|
||||
useTls: useTls ?? true, username, status: 'active',
|
||||
password: encryptionService.encrypt(password),
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const service = userMailImapService;
|
||||
const result = await service.testConnection(tempAccount);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
const service = userMailImapService;
|
||||
const result = await service.testConnection(account);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async listMails(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
const service = userMailImapService;
|
||||
const mails = await service.fetchMailList(account, limit);
|
||||
res.json({ success: true, data: mails });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async streamMails(req: AuthenticatedRequest, res: Response) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders();
|
||||
|
||||
const userId = (req as any).user?.userId;
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const limit = parseInt(req.query.limit as string) || 20;
|
||||
const before = req.query.before ? parseInt(req.query.before as string) : null;
|
||||
|
||||
const account = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!account) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ message: '계정을 찾을 수 없습니다.' })}\n\n`);
|
||||
return res.end();
|
||||
}
|
||||
|
||||
let ended = false;
|
||||
req.on('close', () => { ended = true; });
|
||||
|
||||
await userMailImapService.fetchMailListStream(
|
||||
account, limit, before,
|
||||
(mail) => {
|
||||
if (!ended) res.write(`data: ${JSON.stringify(mail)}\n\n`);
|
||||
},
|
||||
() => {
|
||||
if (!ended) {
|
||||
res.write(`event: done\ndata: {}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
if (!ended) {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getMailDetail(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
const seqno = parseInt(req.params.seqno);
|
||||
const service = userMailImapService;
|
||||
const detail = await service.getMailDetail(account, seqno);
|
||||
if (!detail) return res.status(404).json({ success: false, message: '메일을 찾을 수 없습니다.' });
|
||||
|
||||
res.json({ success: true, data: detail });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async markAsRead(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
const seqno = parseInt(req.params.seqno);
|
||||
const service = userMailImapService;
|
||||
const result = await service.markAsRead(account, seqno);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMail(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req as any).user?.userId;
|
||||
if (!userId) return res.status(401).json({ success: false, message: '인증 필요' });
|
||||
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, userId);
|
||||
if (!account) return res.status(404).json({ success: false, message: '계정을 찾을 수 없습니다.' });
|
||||
|
||||
const seqno = parseInt(req.params.seqno);
|
||||
const service = userMailImapService;
|
||||
const result = await service.deleteMail(account, seqno);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, message: error instanceof Error ? error.message : '서버 오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async listFolders(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
|
||||
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
|
||||
const folders = await userMailImapService.listFolders(account);
|
||||
res.json({ success: true, data: folders });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async streamFolderMails(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const folder = decodeURIComponent(req.params.folder);
|
||||
const limit = parseInt(req.query.limit as string) || 20;
|
||||
const before = req.query.before ? parseInt(req.query.before as string) : null;
|
||||
|
||||
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
|
||||
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
|
||||
await userMailImapService.streamMailsByFolder(
|
||||
account, folder, limit, before,
|
||||
(mail) => {
|
||||
res.write(`event: message\ndata: ${JSON.stringify(mail)}\n\n`);
|
||||
},
|
||||
() => {
|
||||
res.write(`event: done\ndata: {}\n\n`);
|
||||
res.end();
|
||||
},
|
||||
(err) => {
|
||||
res.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async moveMail(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const seqno = parseInt(req.params.seqno);
|
||||
const { targetFolder } = req.body;
|
||||
if (!targetFolder) { res.status(400).json({ success: false, message: 'targetFolder 필요' }); return; }
|
||||
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
|
||||
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
|
||||
const result = await userMailImapService.moveMail(account, seqno, targetFolder);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async getAttachments(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const seqno = parseInt(req.params.seqno);
|
||||
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
|
||||
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
|
||||
const folder = (req.query.folder as string) || 'INBOX';
|
||||
const attachments = await userMailImapService.getAttachmentList(account, seqno, folder);
|
||||
res.json({ success: true, data: attachments });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
|
||||
}
|
||||
}
|
||||
|
||||
async downloadAttachment(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const seqno = parseInt(req.params.seqno);
|
||||
const partId = decodeURIComponent(req.params.partId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
|
||||
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
|
||||
const folder = (req.query.folder as string) || 'INBOX';
|
||||
const filenameHint = (req.params.filename as string | undefined) || (req.query.filename as string | undefined);
|
||||
await userMailImapService.downloadAttachment(account, seqno, partId, res, folder, filenameHint);
|
||||
} catch (err) {
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async sendMail(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const accountId = parseInt(req.params.accountId);
|
||||
const account = await userMailAccountService.getAccountById(accountId, (req as any).user!.userId);
|
||||
if (!account) { res.status(404).json({ success: false, message: '계정 없음' }); return; }
|
||||
const result = await userMailSmtpService.sendMail(account, req.body);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err instanceof Error ? err.message : '오류' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const userMailController = new UserMailController();
|
||||
@@ -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,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,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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,845 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Loader2,
|
||||
Settings,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Search,
|
||||
RotateCcw,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getProcessList,
|
||||
createProcess,
|
||||
updateProcess,
|
||||
deleteProcesses,
|
||||
getProcessEquipments,
|
||||
addProcessEquipment,
|
||||
removeProcessEquipment,
|
||||
getEquipmentList,
|
||||
type ProcessMaster,
|
||||
type ProcessEquipment,
|
||||
type Equipment,
|
||||
} from "@/lib/api/processInfo";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
const ALL_VALUE = "__all__";
|
||||
|
||||
export function ProcessMasterTab() {
|
||||
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
|
||||
const [equipmentMaster, setEquipmentMaster] = useState<Equipment[]>([]);
|
||||
const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
|
||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [loadingEquipments, setLoadingEquipments] = useState(false);
|
||||
|
||||
const [filterCode, setFilterCode] = useState("");
|
||||
const [filterName, setFilterName] = useState("");
|
||||
const [filterType, setFilterType] = useState<string>(ALL_VALUE);
|
||||
const [filterUseYn, setFilterUseYn] = useState<string>(ALL_VALUE);
|
||||
|
||||
const [selectedProcess, setSelectedProcess] = useState<ProcessMaster | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
|
||||
const [equipmentPick, setEquipmentPick] = useState<string>("");
|
||||
const [addingEquipment, setAddingEquipment] = useState(false);
|
||||
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [formMode, setFormMode] = useState<"add" | "edit">("add");
|
||||
const [savingForm, setSavingForm] = useState(false);
|
||||
const [formProcessCode, setFormProcessCode] = useState("");
|
||||
const [formProcessName, setFormProcessName] = useState("");
|
||||
const [formProcessType, setFormProcessType] = useState<string>("");
|
||||
const [formStandardTime, setFormStandardTime] = useState("");
|
||||
const [formWorkerCount, setFormWorkerCount] = useState("");
|
||||
const [formUseYn, setFormUseYn] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const processTypeMap = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
processTypeOptions.forEach((o) => m.set(o.valueCode, o.valueLabel));
|
||||
return m;
|
||||
}, [processTypeOptions]);
|
||||
|
||||
const getProcessTypeLabel = useCallback(
|
||||
(code: string) => processTypeMap.get(code) ?? code,
|
||||
[processTypeMap]
|
||||
);
|
||||
|
||||
const loadProcesses = useCallback(async () => {
|
||||
setLoadingList(true);
|
||||
try {
|
||||
const res = await getProcessList({
|
||||
processCode: filterCode.trim() || undefined,
|
||||
processName: filterName.trim() || undefined,
|
||||
processType: filterType === ALL_VALUE ? undefined : filterType,
|
||||
useYn: filterUseYn === ALL_VALUE ? undefined : filterUseYn,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "공정 목록을 불러오지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
setProcesses(res.data ?? []);
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [filterCode, filterName, filterType, filterUseYn]);
|
||||
|
||||
const loadInitial = useCallback(async () => {
|
||||
setLoadingInitial(true);
|
||||
try {
|
||||
const [procRes, eqRes] = await Promise.all([getProcessList(), getEquipmentList()]);
|
||||
if (!procRes.success) {
|
||||
toast.error(procRes.message || "공정 목록을 불러오지 못했습니다.");
|
||||
} else {
|
||||
setProcesses(procRes.data ?? []);
|
||||
}
|
||||
if (!eqRes.success) {
|
||||
toast.error(eqRes.message || "설비 목록을 불러오지 못했습니다.");
|
||||
} else {
|
||||
setEquipmentMaster(eqRes.data ?? []);
|
||||
}
|
||||
const ptRes = await getCategoryValues("process_mng", "process_type");
|
||||
if (ptRes.success && "data" in ptRes && Array.isArray(ptRes.data)) {
|
||||
const activeValues = ptRes.data.filter((v: any) => v.isActive !== false);
|
||||
const seen = new Set<string>();
|
||||
const unique = activeValues.filter((v: any) => {
|
||||
if (seen.has(v.valueCode)) return false;
|
||||
seen.add(v.valueCode);
|
||||
return true;
|
||||
});
|
||||
setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
|
||||
}
|
||||
} finally {
|
||||
setLoadingInitial(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadInitial();
|
||||
}, [loadInitial]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedProcess((prev) => {
|
||||
if (!prev) return prev;
|
||||
if (!processes.some((p) => p.id === prev.id)) return null;
|
||||
return prev;
|
||||
});
|
||||
}, [processes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEquipmentPick("");
|
||||
}, [selectedProcess?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProcess) {
|
||||
setProcessEquipments([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoadingEquipments(true);
|
||||
void (async () => {
|
||||
const res = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (cancelled) return;
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "공정 설비를 불러오지 못했습니다.");
|
||||
setProcessEquipments([]);
|
||||
} else {
|
||||
setProcessEquipments(res.data ?? []);
|
||||
}
|
||||
setLoadingEquipments(false);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProcess?.process_code]);
|
||||
|
||||
const allSelected = useMemo(() => {
|
||||
if (processes.length === 0) return false;
|
||||
return processes.every((p) => selectedIds.has(p.id));
|
||||
}, [processes, selectedIds]);
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(new Set(processes.map((p) => p.id)));
|
||||
} else {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const toggleOne = (id: string, checked: boolean) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilterCode("");
|
||||
setFilterName("");
|
||||
setFilterType(ALL_VALUE);
|
||||
setFilterUseYn(ALL_VALUE);
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
void loadProcesses();
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
setFormMode("add");
|
||||
setEditingId(null);
|
||||
setFormProcessCode("");
|
||||
setFormProcessName("");
|
||||
setFormProcessType(processTypeOptions[0]?.valueCode ?? "");
|
||||
setFormStandardTime("");
|
||||
setFormWorkerCount("");
|
||||
setFormUseYn("Y");
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = () => {
|
||||
if (!selectedProcess) {
|
||||
toast.message("수정할 공정을 좌측 목록에서 선택하세요.");
|
||||
return;
|
||||
}
|
||||
setFormMode("edit");
|
||||
setEditingId(selectedProcess.id);
|
||||
setFormProcessCode(selectedProcess.process_code);
|
||||
setFormProcessName(selectedProcess.process_name);
|
||||
setFormProcessType(selectedProcess.process_type);
|
||||
setFormStandardTime(selectedProcess.standard_time ?? "");
|
||||
setFormWorkerCount(selectedProcess.worker_count ?? "");
|
||||
setFormUseYn(selectedProcess.use_yn);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formProcessName.trim()) {
|
||||
toast.error("공정명을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingForm(true);
|
||||
try {
|
||||
if (formMode === "add") {
|
||||
const res = await createProcess({
|
||||
process_name: formProcessName.trim(),
|
||||
process_type: formProcessType,
|
||||
standard_time: formStandardTime.trim() || "0",
|
||||
worker_count: formWorkerCount.trim() || "0",
|
||||
use_yn: formUseYn,
|
||||
});
|
||||
if (!res.success || !res.data) {
|
||||
toast.error(res.message || "등록에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
toast.success("공정이 등록되었습니다.");
|
||||
setFormOpen(false);
|
||||
await loadProcesses();
|
||||
setSelectedProcess(res.data);
|
||||
setSelectedIds(new Set());
|
||||
} else if (editingId) {
|
||||
const res = await updateProcess(editingId, {
|
||||
process_name: formProcessName.trim(),
|
||||
process_type: formProcessType,
|
||||
standard_time: formStandardTime.trim() || "0",
|
||||
worker_count: formWorkerCount.trim() || "0",
|
||||
use_yn: formUseYn,
|
||||
});
|
||||
if (!res.success || !res.data) {
|
||||
toast.error(res.message || "수정에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
toast.success("공정이 수정되었습니다.");
|
||||
setFormOpen(false);
|
||||
await loadProcesses();
|
||||
setSelectedProcess(res.data);
|
||||
}
|
||||
} finally {
|
||||
setSavingForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openDelete = () => {
|
||||
if (selectedIds.size === 0) {
|
||||
toast.message("삭제할 공정을 체크박스로 선택하세요.");
|
||||
return;
|
||||
}
|
||||
setDeleteOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await deleteProcesses(ids);
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "삭제에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
toast.success(`${ids.length}건 삭제되었습니다.`);
|
||||
setDeleteOpen(false);
|
||||
setSelectedIds(new Set());
|
||||
if (selectedProcess && ids.includes(selectedProcess.id)) {
|
||||
setSelectedProcess(null);
|
||||
}
|
||||
await loadProcesses();
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const availableEquipments = useMemo(() => {
|
||||
const used = new Set(processEquipments.map((e) => e.equipment_code));
|
||||
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
|
||||
}, [equipmentMaster, processEquipments]);
|
||||
|
||||
const handleAddEquipment = async () => {
|
||||
if (!selectedProcess) return;
|
||||
if (!equipmentPick) {
|
||||
toast.message("추가할 설비를 선택하세요.");
|
||||
return;
|
||||
}
|
||||
setAddingEquipment(true);
|
||||
try {
|
||||
const res = await addProcessEquipment({
|
||||
process_code: selectedProcess.process_code,
|
||||
equipment_code: equipmentPick,
|
||||
});
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 추가에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 등록되었습니다.");
|
||||
setEquipmentPick("");
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
} finally {
|
||||
setAddingEquipment(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveEquipment = async (row: ProcessEquipment) => {
|
||||
const res = await removeProcessEquipment(row.id);
|
||||
if (!res.success) {
|
||||
toast.error(res.message || "설비 제거에 실패했습니다.");
|
||||
return;
|
||||
}
|
||||
toast.success("설비가 제거되었습니다.");
|
||||
if (selectedProcess) {
|
||||
const listRes = await getProcessEquipments(selectedProcess.process_code);
|
||||
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
|
||||
}
|
||||
};
|
||||
|
||||
const listBusy = loadingInitial || loadingList;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
|
||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
|
||||
<div className="flex shrink-0 flex-col gap-2 border-b bg-muted/30 p-3 sm:p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<span className="text-sm font-semibold sm:text-base">공정 마스터</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs sm:text-sm">공정코드</Label>
|
||||
<Input
|
||||
value={filterCode}
|
||||
onChange={(e) => setFilterCode(e.target.value)}
|
||||
placeholder="코드"
|
||||
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[140px] sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs sm:text-sm">공정명</Label>
|
||||
<Input
|
||||
value={filterName}
|
||||
onChange={(e) => setFilterName(e.target.value)}
|
||||
placeholder="이름"
|
||||
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[160px] sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs sm:text-sm">공정유형</Label>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="h-8 w-[120px] text-xs sm:h-10 sm:w-[130px] sm:text-sm">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
|
||||
전체
|
||||
</SelectItem>
|
||||
{processTypeOptions.map((o, idx) => (
|
||||
<SelectItem key={`pt-filter-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
|
||||
{o.valueLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs sm:text-sm">사용여부</Label>
|
||||
<Select value={filterUseYn} onValueChange={setFilterUseYn}>
|
||||
<SelectTrigger className="h-8 w-[100px] text-xs sm:h-10 sm:w-[110px] sm:text-sm">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
|
||||
전체
|
||||
</SelectItem>
|
||||
<SelectItem value="Y" className="text-xs sm:text-sm">사용</SelectItem>
|
||||
<SelectItem value="N" className="text-xs sm:text-sm">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={handleResetFilters}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={handleSearch}
|
||||
disabled={listBusy}
|
||||
>
|
||||
<Search className="mr-1 h-3.5 w-3.5" />
|
||||
조회
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={openAdd}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
공정 추가
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={openEdit}
|
||||
>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={openDelete}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-2 sm:p-3">
|
||||
{listBusy ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
<p className="mt-2 text-xs sm:text-sm">불러오는 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10 text-center">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
onCheckedChange={(v) => toggleAll(v === true)}
|
||||
aria-label="전체 선택"
|
||||
className="mx-auto"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs sm:text-sm">공정코드</TableHead>
|
||||
<TableHead className="text-xs sm:text-sm">공정명</TableHead>
|
||||
<TableHead className="text-xs sm:text-sm">공정유형</TableHead>
|
||||
<TableHead className="text-right text-xs sm:text-sm">표준시간(분)</TableHead>
|
||||
<TableHead className="text-right text-xs sm:text-sm">작업인원</TableHead>
|
||||
<TableHead className="text-center text-xs sm:text-sm">사용여부</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processes.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-12 text-center text-muted-foreground">
|
||||
<p className="text-xs sm:text-sm">조회된 공정이 없습니다.</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
processes.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-colors",
|
||||
selectedProcess?.id === row.id && "bg-accent"
|
||||
)}
|
||||
onClick={() => setSelectedProcess(row)}
|
||||
>
|
||||
<TableCell
|
||||
className="text-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(row.id)}
|
||||
onCheckedChange={(v) => toggleOne(row.id, v === true)}
|
||||
aria-label={`${row.process_code} 선택`}
|
||||
className="mx-auto"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-medium sm:text-sm">
|
||||
{row.process_code}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">{row.process_name}</TableCell>
|
||||
<TableCell className="text-xs sm:text-sm">
|
||||
<Badge variant="secondary" className="text-[10px] sm:text-xs">
|
||||
{getProcessTypeLabel(row.process_type)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs sm:text-sm">
|
||||
{row.standard_time ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-xs sm:text-sm">
|
||||
{row.worker_count ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs sm:text-sm">
|
||||
<Badge
|
||||
variant={row.use_yn === "N" ? "outline" : "default"}
|
||||
className="text-[10px] sm:text-xs"
|
||||
>
|
||||
{row.use_yn === "Y" ? "사용" : "미사용"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
<ResizablePanel defaultSize={50} minSize={30}>
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-2 sm:px-4 sm:py-3">
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold sm:text-base">공정별 사용설비</p>
|
||||
{selectedProcess ? (
|
||||
<p className="truncate text-xs text-muted-foreground sm:text-sm">
|
||||
{selectedProcess.process_name}{" "}
|
||||
<span className="text-muted-foreground/80">({selectedProcess.process_code})</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">공정 미선택</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedProcess ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-center text-muted-foreground">
|
||||
<Settings className="h-10 w-10 opacity-40" />
|
||||
<p className="text-sm font-medium text-foreground">좌측에서 공정을 선택하세요</p>
|
||||
<p className="max-w-xs text-xs sm:text-sm">
|
||||
목록 행을 클릭하면 이 공정에 연결된 설비를 관리할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-1 sm:max-w-xs">
|
||||
<Label className="text-xs sm:text-sm">설비 선택</Label>
|
||||
<Select
|
||||
key={selectedProcess.id}
|
||||
value={equipmentPick || undefined}
|
||||
onValueChange={setEquipmentPick}
|
||||
disabled={addingEquipment || availableEquipments.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="설비를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableEquipments.map((eq) => (
|
||||
<SelectItem
|
||||
key={eq.id}
|
||||
value={eq.equipment_code}
|
||||
className="text-xs sm:text-sm"
|
||||
>
|
||||
{eq.equipment_code} · {eq.equipment_name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
onClick={() => void handleAddEquipment()}
|
||||
disabled={addingEquipment || !equipmentPick}
|
||||
>
|
||||
{addingEquipment ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
{loadingEquipments ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Loader2 className="h-7 w-7 animate-spin" />
|
||||
<p className="mt-2 text-xs sm:text-sm">설비 목록 불러오는 중...</p>
|
||||
</div>
|
||||
) : processEquipments.length === 0 ? (
|
||||
<p className="py-8 text-center text-xs text-muted-foreground sm:text-sm">
|
||||
등록된 설비가 없습니다. 상단에서 설비를 추가하세요.
|
||||
</p>
|
||||
) : (
|
||||
<ScrollArea className="h-[min(420px,calc(100vh-20rem))] pr-3">
|
||||
<ul className="space-y-2">
|
||||
{processEquipments.map((pe) => (
|
||||
<li key={pe.id}>
|
||||
<Card className="rounded-lg border bg-card text-card-foreground shadow-sm">
|
||||
<CardContent className="flex items-center gap-3 p-3 sm:p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium sm:text-sm">
|
||||
{pe.equipment_code}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground sm:text-sm">
|
||||
{pe.equipment_name || "설비명 없음"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 text-xs sm:h-9 sm:text-sm"
|
||||
onClick={() => void handleRemoveEquipment(pe)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3.5 w-3.5" />
|
||||
제거
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{formMode === "add" ? "공정 추가" : "공정 수정"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
공정 마스터 정보를 입력합니다. 표준시간과 작업인원은 숫자로 입력하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="pm-process-name" className="text-xs sm:text-sm">
|
||||
공정명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="pm-process-name"
|
||||
value={formProcessName}
|
||||
onChange={(e) => setFormProcessName(e.target.value)}
|
||||
placeholder="공정명"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">공정유형</Label>
|
||||
<Select value={formProcessType} onValueChange={setFormProcessType}>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{processTypeOptions.map((o, idx) => (
|
||||
<SelectItem key={`pt-form-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
|
||||
{o.valueLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pm-standard-time" className="text-xs sm:text-sm">
|
||||
표준작업시간(분)
|
||||
</Label>
|
||||
<Input
|
||||
id="pm-standard-time"
|
||||
value={formStandardTime}
|
||||
onChange={(e) => setFormStandardTime(e.target.value)}
|
||||
placeholder="0"
|
||||
inputMode="numeric"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pm-worker-count" className="text-xs sm:text-sm">
|
||||
작업인원수
|
||||
</Label>
|
||||
<Input
|
||||
id="pm-worker-count"
|
||||
value={formWorkerCount}
|
||||
onChange={(e) => setFormWorkerCount(e.target.value)}
|
||||
placeholder="0"
|
||||
inputMode="numeric"
|
||||
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs sm:text-sm">사용여부</Label>
|
||||
<Select value={formUseYn} onValueChange={setFormUseYn}>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Y" className="text-xs sm:text-sm">사용</SelectItem>
|
||||
<SelectItem value="N" className="text-xs sm:text-sm">미사용</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setFormOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={savingForm}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void submitForm()}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={savingForm}
|
||||
>
|
||||
{savingForm ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">공정 삭제</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
선택한 {selectedIds.size}건의 공정을 삭제합니다. 연결된 공정-설비 매핑도 함께 삭제됩니다. 이 작업은
|
||||
되돌릴 수 없습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={deleting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => void confirmDelete()}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
삭제
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { ProcessWorkStandardComponent } from "@/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent";
|
||||
|
||||
export function ProcessWorkStandardTab() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-12rem)]">
|
||||
<ProcessWorkStandardComponent
|
||||
config={{
|
||||
itemListMode: "registered",
|
||||
screenCode: "screen_1599",
|
||||
leftPanelTitle: "등록 품목 및 공정",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Settings, GitBranch, ClipboardList } from "lucide-react";
|
||||
import { ProcessMasterTab } from "./ProcessMasterTab";
|
||||
import { ItemRoutingTab } from "./ItemRoutingTab";
|
||||
import { ProcessWorkStandardTab } from "./ProcessWorkStandardTab";
|
||||
|
||||
export default function ProcessInfoPage() {
|
||||
const [activeTab, setActiveTab] = useState("process");
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
|
||||
<div className="shrink-0 border-b bg-background px-4">
|
||||
<TabsList className="h-12 bg-transparent gap-1">
|
||||
<TabsTrigger
|
||||
value="process"
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
공정 마스터
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="routing"
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
|
||||
>
|
||||
<GitBranch className="mr-2 h-4 w-4" />
|
||||
품목별 라우팅
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="workstandard"
|
||||
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
|
||||
>
|
||||
<ClipboardList className="mr-2 h-4 w-4" />
|
||||
공정 작업기준
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="process" className="flex-1 overflow-hidden mt-0">
|
||||
<ProcessMasterTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="routing" className="flex-1 overflow-hidden mt-0">
|
||||
<ItemRoutingTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="workstandard" className="flex-1 overflow-hidden mt-0">
|
||||
<ProcessWorkStandardTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1007
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user