Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
0aef19578a
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"version": "1.0.0",
|
||||
"lastScanned": 1772609393905,
|
||||
"projectRoot": "/Users/johngreen/Dev/vexplor",
|
||||
"lastScanned": 1774313213052,
|
||||
"projectRoot": "/Users/kimjuseok/ERP-node",
|
||||
"techStack": {
|
||||
"languages": [
|
||||
{
|
||||
|
|
@ -13,7 +13,13 @@
|
|||
]
|
||||
}
|
||||
],
|
||||
"frameworks": [],
|
||||
"frameworks": [
|
||||
{
|
||||
"name": "playwright",
|
||||
"version": "1.58.2",
|
||||
"category": "testing"
|
||||
}
|
||||
],
|
||||
"packageManager": "npm",
|
||||
"runtime": null
|
||||
},
|
||||
|
|
@ -28,16 +34,14 @@
|
|||
"namingStyle": null,
|
||||
"importStyle": null,
|
||||
"testPattern": null,
|
||||
"fileOrganization": "type-based"
|
||||
"fileOrganization": null
|
||||
},
|
||||
"structure": {
|
||||
"isMonorepo": false,
|
||||
"workspaces": [],
|
||||
"mainDirectories": [
|
||||
"docs",
|
||||
"lib",
|
||||
"scripts",
|
||||
"src"
|
||||
"scripts"
|
||||
],
|
||||
"gitBranches": {
|
||||
"defaultBranch": "main",
|
||||
|
|
@ -46,37 +50,39 @@
|
|||
},
|
||||
"customNotes": [],
|
||||
"directoryMap": {
|
||||
"WebContent": {
|
||||
"path": "WebContent",
|
||||
"_local": {
|
||||
"path": "_local",
|
||||
"purpose": null,
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1774313213033,
|
||||
"keyFiles": [
|
||||
"pipeline-progress.json"
|
||||
]
|
||||
},
|
||||
"ai-assistant": {
|
||||
"path": "ai-assistant",
|
||||
"purpose": null,
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1772609393856,
|
||||
"lastAccessed": 1774313213036,
|
||||
"keyFiles": [
|
||||
"init.jsp",
|
||||
"init_jqGrid.jsp",
|
||||
"init_no_login.jsp",
|
||||
"init_toastGrid.jsp",
|
||||
"viewImage.jsp"
|
||||
"Dockerfile.win",
|
||||
"README.md",
|
||||
"package-lock.json",
|
||||
"package.json"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"path": "backend",
|
||||
"purpose": null,
|
||||
"fileCount": 6,
|
||||
"lastAccessed": 1772609393857,
|
||||
"keyFiles": [
|
||||
"Dockerfile",
|
||||
"Dockerfile.mac",
|
||||
"build.gradle",
|
||||
"gradlew",
|
||||
"gradlew.bat"
|
||||
]
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1774313213038,
|
||||
"keyFiles": []
|
||||
},
|
||||
"backend-node": {
|
||||
"path": "backend-node",
|
||||
"purpose": null,
|
||||
"fileCount": 14,
|
||||
"lastAccessed": 1772609393872,
|
||||
"fileCount": 17,
|
||||
"lastAccessed": 1774313213039,
|
||||
"keyFiles": [
|
||||
"API_연동_가이드.md",
|
||||
"API_키_정리.md",
|
||||
|
|
@ -85,70 +91,88 @@
|
|||
"README.md"
|
||||
]
|
||||
},
|
||||
"backup": {
|
||||
"path": "backup",
|
||||
"purpose": null,
|
||||
"fileCount": 6,
|
||||
"lastAccessed": 1774313213040,
|
||||
"keyFiles": [
|
||||
"Dockerfile",
|
||||
"README.md",
|
||||
"backup.py",
|
||||
"docker-compose.backup.yml"
|
||||
]
|
||||
},
|
||||
"db": {
|
||||
"path": "db",
|
||||
"purpose": null,
|
||||
"fileCount": 2,
|
||||
"lastAccessed": 1772609393873,
|
||||
"fileCount": 14,
|
||||
"lastAccessed": 1774313213041,
|
||||
"keyFiles": [
|
||||
"00-create-roles.sh",
|
||||
"migrate_company13_export.sh"
|
||||
"check_category_values.sql",
|
||||
"check_numbering_rules.sql",
|
||||
"cleanup_duplicate_screens_daejin.sql",
|
||||
"company7_screen_backup.sql"
|
||||
]
|
||||
},
|
||||
"deploy": {
|
||||
"path": "deploy",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393873,
|
||||
"lastAccessed": 1774313213041,
|
||||
"keyFiles": []
|
||||
},
|
||||
"digitalTwin": {
|
||||
"path": "digitalTwin",
|
||||
"purpose": null,
|
||||
"fileCount": 4,
|
||||
"lastAccessed": 1774313213041,
|
||||
"keyFiles": [
|
||||
"architecture-v4.md",
|
||||
"fleet-management-plan.md",
|
||||
"디지털트윈 아키텍쳐_v3.png",
|
||||
"디지털트윈 아키텍쳐_v4.png"
|
||||
]
|
||||
},
|
||||
"docker": {
|
||||
"path": "docker",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393873,
|
||||
"lastAccessed": 1774313213042,
|
||||
"keyFiles": []
|
||||
},
|
||||
"docs": {
|
||||
"path": "docs",
|
||||
"purpose": "Documentation",
|
||||
"fileCount": 23,
|
||||
"lastAccessed": 1772609393873,
|
||||
"fileCount": 35,
|
||||
"lastAccessed": 1774313213042,
|
||||
"keyFiles": [
|
||||
"AI_화면생성_시스템_설계서.md",
|
||||
"BOM_개발_현황.md",
|
||||
"DB_ARCHITECTURE_ANALYSIS.md",
|
||||
"DB_STRUCTURE_DIAGRAM.html",
|
||||
"DB_WORKFLOW_ANALYSIS.md",
|
||||
"KUBERNETES_DEPLOYMENT_GUIDE.md"
|
||||
"DB_WORKFLOW_ANALYSIS.md"
|
||||
]
|
||||
},
|
||||
"frontend": {
|
||||
"path": "frontend",
|
||||
"purpose": null,
|
||||
"fileCount": 14,
|
||||
"lastAccessed": 1772609393873,
|
||||
"fileCount": 17,
|
||||
"lastAccessed": 1774313213043,
|
||||
"keyFiles": [
|
||||
"MODAL_REPEATER_TABLE_DEBUG.md",
|
||||
"README.md",
|
||||
"approval-box-result.png",
|
||||
"components.json",
|
||||
"eslint.config.mjs",
|
||||
"middleware.ts"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"path": "hooks",
|
||||
"purpose": null,
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393879,
|
||||
"keyFiles": [
|
||||
"useScreenStandards.ts"
|
||||
"eslint.config.mjs"
|
||||
]
|
||||
},
|
||||
"k8s": {
|
||||
"path": "k8s",
|
||||
"purpose": null,
|
||||
"fileCount": 7,
|
||||
"lastAccessed": 1772609393882,
|
||||
"lastAccessed": 1774313213043,
|
||||
"keyFiles": [
|
||||
"local-path-provisioner.yaml",
|
||||
"namespace.yaml",
|
||||
|
|
@ -157,18 +181,11 @@
|
|||
"vexplor-frontend-deployment.yaml"
|
||||
]
|
||||
},
|
||||
"lib": {
|
||||
"path": "lib",
|
||||
"purpose": "Library code",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393883,
|
||||
"keyFiles": []
|
||||
},
|
||||
"mcp-agent-orchestrator": {
|
||||
"path": "mcp-agent-orchestrator",
|
||||
"purpose": null,
|
||||
"fileCount": 4,
|
||||
"lastAccessed": 1772609393883,
|
||||
"lastAccessed": 1774313213043,
|
||||
"keyFiles": [
|
||||
"README.md",
|
||||
"package-lock.json",
|
||||
|
|
@ -176,91 +193,68 @@
|
|||
"tsconfig.json"
|
||||
]
|
||||
},
|
||||
"popdocs": {
|
||||
"path": "popdocs",
|
||||
"mcp-task-queue": {
|
||||
"path": "mcp-task-queue",
|
||||
"purpose": null,
|
||||
"fileCount": 12,
|
||||
"lastAccessed": 1772609393884,
|
||||
"fileCount": 4,
|
||||
"lastAccessed": 1774313213043,
|
||||
"keyFiles": [
|
||||
"ARCHITECTURE.md",
|
||||
"CHANGELOG.md",
|
||||
"FILES.md",
|
||||
"INDEX.md",
|
||||
"PLAN.md"
|
||||
"package-lock.json",
|
||||
"package.json",
|
||||
"tsconfig.json"
|
||||
]
|
||||
},
|
||||
"mcp-task-server": {
|
||||
"path": "mcp-task-server",
|
||||
"purpose": null,
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1774313213043,
|
||||
"keyFiles": []
|
||||
},
|
||||
"scripts": {
|
||||
"path": "scripts",
|
||||
"purpose": "Build/utility scripts",
|
||||
"fileCount": 2,
|
||||
"lastAccessed": 1772609393884,
|
||||
"fileCount": 11,
|
||||
"lastAccessed": 1774313213044,
|
||||
"keyFiles": [
|
||||
"add-modal-ids.py",
|
||||
"remove-logs.js"
|
||||
"analyze-company-info-layout.js",
|
||||
"browser-test-admin-switch-button.js",
|
||||
"browser-test-customer-crud.js",
|
||||
"browser-test-customer-via-menu.js"
|
||||
]
|
||||
},
|
||||
"src": {
|
||||
"path": "src",
|
||||
"purpose": "Source code",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": []
|
||||
"test-output": {
|
||||
"path": "test-output",
|
||||
"purpose": null,
|
||||
"fileCount": 2,
|
||||
"lastAccessed": 1774313213044,
|
||||
"keyFiles": [
|
||||
"screen-149-field-type-verification-guide.md",
|
||||
"unified-field-type-config-panel-test-guide.md"
|
||||
]
|
||||
},
|
||||
"tomcat-conf": {
|
||||
"path": "tomcat-conf",
|
||||
"test-results": {
|
||||
"path": "test-results",
|
||||
"purpose": null,
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": [
|
||||
"context.xml"
|
||||
]
|
||||
},
|
||||
"backend/build": {
|
||||
"path": "backend/build",
|
||||
"purpose": "Build output",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393884,
|
||||
"lastAccessed": 1774313213044,
|
||||
"keyFiles": []
|
||||
},
|
||||
"backend/src": {
|
||||
"path": "backend/src",
|
||||
"ai-assistant/src": {
|
||||
"path": "ai-assistant/src",
|
||||
"purpose": "Source code",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": []
|
||||
},
|
||||
"backend-node/data": {
|
||||
"path": "backend-node/data",
|
||||
"purpose": "Data files",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": []
|
||||
},
|
||||
"db/migrations": {
|
||||
"path": "db/migrations",
|
||||
"purpose": "Database migrations",
|
||||
"fileCount": 16,
|
||||
"lastAccessed": 1772609393884,
|
||||
"keyFiles": [
|
||||
"046_MIGRATION_FIX.md",
|
||||
"046_QUICK_FIX.md",
|
||||
"README_1003.md"
|
||||
]
|
||||
},
|
||||
"db/scripts": {
|
||||
"path": "db/scripts",
|
||||
"purpose": "Build/utility scripts",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393884,
|
||||
"lastAccessed": 1774313213045,
|
||||
"keyFiles": [
|
||||
"README_cleanup.md"
|
||||
"app.js"
|
||||
]
|
||||
},
|
||||
"frontend/app": {
|
||||
"path": "frontend/app",
|
||||
"purpose": "Application code",
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1772609393885,
|
||||
"lastAccessed": 1774313213046,
|
||||
"keyFiles": [
|
||||
"favicon.ico",
|
||||
"globals.css",
|
||||
|
|
@ -271,7 +265,7 @@
|
|||
"path": "frontend/components",
|
||||
"purpose": "UI components",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393885,
|
||||
"lastAccessed": 1774313213046,
|
||||
"keyFiles": [
|
||||
"GlobalFileViewer.tsx"
|
||||
]
|
||||
|
|
@ -280,49 +274,174 @@
|
|||
"path": "mcp-agent-orchestrator/src",
|
||||
"purpose": "Source code",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393885,
|
||||
"lastAccessed": 1774313213047,
|
||||
"keyFiles": [
|
||||
"index.ts"
|
||||
]
|
||||
},
|
||||
"src/controllers": {
|
||||
"path": "src/controllers",
|
||||
"purpose": "Controllers",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393885,
|
||||
"keyFiles": [
|
||||
"dataflowDiagramController.ts"
|
||||
]
|
||||
},
|
||||
"src/routes": {
|
||||
"path": "src/routes",
|
||||
"purpose": "Route handlers",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393885,
|
||||
"keyFiles": [
|
||||
"dataflowDiagramRoutes.ts"
|
||||
]
|
||||
},
|
||||
"src/services": {
|
||||
"path": "src/services",
|
||||
"purpose": "Business logic services",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1772609393885,
|
||||
"keyFiles": [
|
||||
"dataflowDiagramService.ts"
|
||||
]
|
||||
},
|
||||
"src/utils": {
|
||||
"path": "src/utils",
|
||||
"purpose": "Utility functions",
|
||||
"mcp-task-queue/data": {
|
||||
"path": "mcp-task-queue/data",
|
||||
"purpose": "Data files",
|
||||
"fileCount": 2,
|
||||
"lastAccessed": 1772609393885,
|
||||
"lastAccessed": 1774313213047,
|
||||
"keyFiles": [
|
||||
"databaseValidator.ts",
|
||||
"queryBuilder.ts"
|
||||
"knowledge.json",
|
||||
"tasks.json"
|
||||
]
|
||||
},
|
||||
"mcp-task-queue/dist": {
|
||||
"path": "mcp-task-queue/dist",
|
||||
"purpose": "Distribution/build output",
|
||||
"fileCount": 28,
|
||||
"lastAccessed": 1774313213048,
|
||||
"keyFiles": [
|
||||
"agent-runner.d.ts",
|
||||
"agent-runner.d.ts.map",
|
||||
"agent-runner.js"
|
||||
]
|
||||
},
|
||||
"mcp-task-queue/node_modules": {
|
||||
"path": "mcp-task-queue/node_modules",
|
||||
"purpose": "Dependencies",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1774313213049,
|
||||
"keyFiles": []
|
||||
},
|
||||
"mcp-task-queue/src": {
|
||||
"path": "mcp-task-queue/src",
|
||||
"purpose": "Source code",
|
||||
"fileCount": 7,
|
||||
"lastAccessed": 1774313213049,
|
||||
"keyFiles": [
|
||||
"agent-runner.ts",
|
||||
"index.ts",
|
||||
"knowledge-store.ts"
|
||||
]
|
||||
},
|
||||
"mcp-task-server/data": {
|
||||
"path": "mcp-task-server/data",
|
||||
"purpose": "Data files",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1774313213049,
|
||||
"keyFiles": []
|
||||
},
|
||||
"mcp-task-server/dist": {
|
||||
"path": "mcp-task-server/dist",
|
||||
"purpose": "Distribution/build output",
|
||||
"fileCount": 6,
|
||||
"lastAccessed": 1774313213050,
|
||||
"keyFiles": [
|
||||
"index.d.ts",
|
||||
"index.js",
|
||||
"taskStore.d.ts"
|
||||
]
|
||||
},
|
||||
"mcp-task-server/node_modules": {
|
||||
"path": "mcp-task-server/node_modules",
|
||||
"purpose": "Dependencies",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1774313213050,
|
||||
"keyFiles": []
|
||||
},
|
||||
"mcp-task-server/src": {
|
||||
"path": "mcp-task-server/src",
|
||||
"purpose": "Source code",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1774313213052,
|
||||
"keyFiles": []
|
||||
}
|
||||
},
|
||||
"hotPaths": [],
|
||||
"hotPaths": [
|
||||
{
|
||||
"path": "frontend/app/(main)/sales/order/page.tsx",
|
||||
"accessCount": 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": []
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"session_id": "d2bc3862-569e-4904-a3f9-6b20e3f14c43",
|
||||
"ended_at": "2026-03-24T01:15:06.127Z",
|
||||
"reason": "other",
|
||||
"agents_spawned": 1,
|
||||
"agents_completed": 1,
|
||||
"modes_used": []
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"session_id": "d6a10e69-4ebc-48f9-b451-c1d0587badc8",
|
||||
"ended_at": "2026-03-24T01:15:07.644Z",
|
||||
"reason": "other",
|
||||
"agents_spawned": 0,
|
||||
"agents_completed": 0,
|
||||
"modes_used": []
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"timestamp": "2026-03-04T07:29:57.315Z",
|
||||
"backgroundTasks": [],
|
||||
"sessionStartTimestamp": "2026-03-04T07:29:53.176Z",
|
||||
"sessionId": "591d357c-df9d-4bbc-8dfa-1b98a9184e23"
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"session_id":"591d357c-df9d-4bbc-8dfa-1b98a9184e23","transcript_path":"/Users/johngreen/.claude/projects/-Users-johngreen-Dev-vexplor/591d357c-df9d-4bbc-8dfa-1b98a9184e23.jsonl","cwd":"/Users/johngreen/Dev/vexplor","model":{"id":"claude-opus-4-6","display_name":"Opus 4.6"},"workspace":{"current_dir":"/Users/johngreen/Dev/vexplor","project_dir":"/Users/johngreen/Dev/vexplor","added_dirs":[]},"version":"2.1.66","output_style":{"name":"default"},"cost":{"total_cost_usd":0.516748,"total_duration_ms":65256,"total_api_duration_ms":28107,"total_lines_added":0,"total_lines_removed":0},"context_window":{"total_input_tokens":604,"total_output_tokens":838,"context_window_size":200000,"current_usage":{"input_tokens":1,"output_tokens":277,"cache_creation_input_tokens":1836,"cache_read_input_tokens":55498},"used_percentage":29,"remaining_percentage":71},"exceeds_200k_tokens":false}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"lastSentAt": "2026-03-04T07:30:30.883Z"
|
||||
"lastSentAt": "2026-03-24T02:36:44.477Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"updatedAt": "2026-03-24T00:51:37.962Z",
|
||||
"missions": [
|
||||
{
|
||||
"id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none",
|
||||
"source": "session",
|
||||
"name": "none",
|
||||
"objective": "Session mission",
|
||||
"createdAt": "2026-03-24T00:50:40.568Z",
|
||||
"updatedAt": "2026-03-24T00:51:37.962Z",
|
||||
"status": "done",
|
||||
"workerCount": 1,
|
||||
"taskCounts": {
|
||||
"total": 1,
|
||||
"pending": 0,
|
||||
"blocked": 0,
|
||||
"inProgress": 0,
|
||||
"completed": 1,
|
||||
"failed": 0
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"name": "Explore:a9237b1",
|
||||
"role": "Explore",
|
||||
"ownership": "a9237b1b6af985371",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-24T00:51:37.962Z"
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"id": "session-start:a9237b1b6af985371:2026-03-24T00:50:40.568Z",
|
||||
"at": "2026-03-24T00:50:40.568Z",
|
||||
"kind": "update",
|
||||
"agent": "Explore:a9237b1",
|
||||
"detail": "started Explore:a9237b1",
|
||||
"sourceKey": "session-start:a9237b1b6af985371"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a9237b1b6af985371:2026-03-24T00:51:37.962Z",
|
||||
"at": "2026-03-24T00:51:37.962Z",
|
||||
"kind": "completion",
|
||||
"agent": "Explore:a9237b1",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a9237b1b6af985371"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -151,6 +151,7 @@ import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석
|
|||
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
|
||||
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
|
||||
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
|
||||
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
|
||||
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
|
|
@ -367,6 +368,7 @@ app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
|||
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||
app.use("/api/design", designRoutes); // 설계 모듈
|
||||
app.use("/api/receiving", receivingRoutes); // 입고관리
|
||||
app.use("/api/outbound", outboundRoutes); // 출고관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
|
|
|
|||
|
|
@ -2504,7 +2504,9 @@ export const changeUserStatus = async (
|
|||
// 필수 파라미터 검증
|
||||
if (!userId || !status) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 ID와 상태는 필수입니다.",
|
||||
msg: "사용자 ID와 상태는 필수입니다.",
|
||||
});
|
||||
return;
|
||||
|
|
@ -2513,7 +2515,9 @@ export const changeUserStatus = async (
|
|||
// 상태 값 검증
|
||||
if (!["active", "inactive"].includes(status)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
|
||||
msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
|
||||
});
|
||||
return;
|
||||
|
|
@ -2528,7 +2532,9 @@ export const changeUserStatus = async (
|
|||
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
msg: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
|
|
@ -2549,6 +2555,12 @@ export const changeUserStatus = async (
|
|||
if (updateResult.length > 0) {
|
||||
// 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
|
||||
|
||||
// inactive로 변경 시 기존 JWT 토큰 무효화
|
||||
if (status === "inactive") {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||
}
|
||||
|
||||
logger.info("사용자 상태 변경 성공", {
|
||||
userId,
|
||||
oldStatus: currentUser.status,
|
||||
|
|
@ -2571,12 +2583,16 @@ export const changeUserStatus = async (
|
|||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: true,
|
||||
message: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 상태 변경에 실패했습니다.",
|
||||
msg: "사용자 상태 변경에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
|
|
@ -2587,7 +2603,9 @@ export const changeUserStatus = async (
|
|||
status: req.body.status,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "시스템 오류가 발생했습니다.",
|
||||
msg: "시스템 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
|
|
@ -2627,12 +2645,214 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 추가 유효성 검증
|
||||
|
||||
// 1. email 형식 검증 (값이 있는 경우만)
|
||||
if (userData.email && userData.email.trim() !== "") {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(userData.email.trim())) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "이메일 형식이 올바르지 않습니다.",
|
||||
error: {
|
||||
code: "INVALID_EMAIL_FORMAT",
|
||||
details: `Invalid email format: ${userData.email}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. companyCode 존재 확인 (값이 있는 경우만)
|
||||
if (userData.companyCode && userData.companyCode.trim() !== "") {
|
||||
const companyExists = await queryOne<{ company_code: string }>(
|
||||
`SELECT company_code FROM company_mng WHERE company_code = $1`,
|
||||
[userData.companyCode.trim()]
|
||||
);
|
||||
if (!companyExists) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `존재하지 않는 회사 코드입니다: ${userData.companyCode}`,
|
||||
error: {
|
||||
code: "INVALID_COMPANY_CODE",
|
||||
details: `Company code not found: ${userData.companyCode}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. userType 유효값 검증 (값이 있는 경우만)
|
||||
if (userData.userType && userData.userType.trim() !== "") {
|
||||
const validUserTypes = ["SUPER_ADMIN", "COMPANY_ADMIN", "USER", "GUEST", "PARTNER"];
|
||||
if (!validUserTypes.includes(userData.userType.trim())) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: `유효하지 않은 사용자 유형입니다: ${userData.userType}. 허용값: ${validUserTypes.join(", ")}`,
|
||||
error: {
|
||||
code: "INVALID_USER_TYPE",
|
||||
details: `Invalid userType: ${userData.userType}. Allowed: ${validUserTypes.join(", ")}`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 비밀번호 최소 길이 검증 (신규 등록 시)
|
||||
if (!isUpdate && userData.userPassword && userData.userPassword.length < 4) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "비밀번호는 최소 4자 이상이어야 합니다.",
|
||||
error: {
|
||||
code: "PASSWORD_TOO_SHORT",
|
||||
details: "Password must be at least 4 characters long",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 비밀번호 암호화 (비밀번호가 제공된 경우에만)
|
||||
let encryptedPassword = null;
|
||||
if (userData.userPassword) {
|
||||
encryptedPassword = await EncryptUtil.encrypt(userData.userPassword);
|
||||
}
|
||||
|
||||
// PUT(수정) 요청 시 company_code / dept_code 변경 감지
|
||||
if (isUpdate) {
|
||||
const existingUser = await queryOne<{ company_code: string; dept_code: string }>(
|
||||
`SELECT company_code, dept_code FROM user_info WHERE user_id = $1`,
|
||||
[userData.userId]
|
||||
);
|
||||
|
||||
// company_code 변경 감지 → 이전 회사 권한 그룹 제거
|
||||
if (
|
||||
userData.companyCode &&
|
||||
existingUser &&
|
||||
existingUser.company_code &&
|
||||
existingUser.company_code !== userData.companyCode
|
||||
) {
|
||||
const oldCompanyCode = existingUser.company_code;
|
||||
logger.info("사용자 회사 코드 변경 감지 - 이전 회사 권한 그룹 제거", {
|
||||
userId: userData.userId,
|
||||
oldCompanyCode,
|
||||
newCompanyCode: userData.companyCode,
|
||||
});
|
||||
|
||||
// 이전 회사의 권한 그룹에서 해당 사용자 제거
|
||||
await query(
|
||||
`DELETE FROM authority_sub_user
|
||||
WHERE user_id = $1
|
||||
AND master_objid IN (
|
||||
SELECT objid FROM authority_master WHERE company_code = $2
|
||||
)`,
|
||||
[userData.userId, oldCompanyCode]
|
||||
);
|
||||
}
|
||||
|
||||
// dept_code 변경 감지 → 결재 템플릿/진행중 결재라인 경고 로그
|
||||
const newDeptCode = userData.deptCode || null;
|
||||
const oldDeptCode = existingUser?.dept_code || null;
|
||||
if (existingUser && oldDeptCode && newDeptCode && oldDeptCode !== newDeptCode) {
|
||||
logger.warn("사용자 부서 변경 감지 - 결재라인 영향 확인 시작", {
|
||||
userId: userData.userId,
|
||||
userName: userData.userName,
|
||||
oldDeptCode,
|
||||
newDeptCode,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1) 결재선 템플릿 스텝에서 해당 사용자가 결재자로 등록된 건 조회
|
||||
const templateSteps = await query<{
|
||||
template_id: number;
|
||||
step_order: number;
|
||||
approver_label: string | null;
|
||||
approver_dept_code: string | null;
|
||||
}>(
|
||||
`SELECT s.template_id, s.step_order, s.approver_label, s.approver_dept_code
|
||||
FROM approval_line_template_steps s
|
||||
WHERE s.approver_user_id = $1`,
|
||||
[userData.userId]
|
||||
);
|
||||
|
||||
if (templateSteps && templateSteps.length > 0) {
|
||||
logger.warn(
|
||||
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})가 결재선 템플릿 ${templateSteps.length}건에 결재자로 등록되어 있습니다. 수동 확인이 필요합니다.`,
|
||||
{
|
||||
userId: userData.userId,
|
||||
oldDeptCode,
|
||||
newDeptCode,
|
||||
affectedTemplates: templateSteps.map((s) => ({
|
||||
templateId: s.template_id,
|
||||
stepOrder: s.step_order,
|
||||
label: s.approver_label,
|
||||
currentDeptInStep: s.approver_dept_code,
|
||||
})),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 2) 진행중인 결재 요청에서 해당 사용자가 대기중 결재자인 건 조회
|
||||
const pendingLines = await query<{
|
||||
request_id: number;
|
||||
step_order: number;
|
||||
approver_dept: string | null;
|
||||
status: string;
|
||||
}>(
|
||||
`SELECT l.request_id, l.step_order, l.approver_dept, l.status
|
||||
FROM approval_lines l
|
||||
JOIN approval_requests r ON r.request_id = l.request_id
|
||||
WHERE l.approver_id = $1
|
||||
AND l.status = 'pending'
|
||||
AND r.status IN ('in_progress', 'pending')`,
|
||||
[userData.userId]
|
||||
);
|
||||
|
||||
if (pendingLines && pendingLines.length > 0) {
|
||||
logger.warn(
|
||||
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})에게 대기중인 결재 ${pendingLines.length}건이 있습니다. 수동 확인이 필요합니다.`,
|
||||
{
|
||||
userId: userData.userId,
|
||||
oldDeptCode,
|
||||
newDeptCode,
|
||||
pendingApprovals: pendingLines.map((l) => ({
|
||||
requestId: l.request_id,
|
||||
stepOrder: l.step_order,
|
||||
currentDeptInLine: l.approver_dept,
|
||||
})),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 감사 로그 기록
|
||||
auditLogService.log({
|
||||
companyCode: userData.companyCode || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DEPT_CHANGE_WARNING",
|
||||
resourceType: "USER",
|
||||
resourceId: userData.userId,
|
||||
resourceName: userData.userName,
|
||||
summary: `사용자 "${userData.userName}"의 부서 변경 (${oldDeptCode} → ${newDeptCode}). 결재 템플릿 ${templateSteps?.length || 0}건, 대기중 결재 ${pendingLines?.length || 0}건 영향 가능`,
|
||||
changes: {
|
||||
before: { deptCode: oldDeptCode },
|
||||
after: {
|
||||
deptCode: newDeptCode,
|
||||
affectedTemplateCount: templateSteps?.length || 0,
|
||||
pendingApprovalCount: pendingLines?.length || 0,
|
||||
},
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
} catch (approvalCheckError) {
|
||||
// 결재 테이블이 없는 환경에서도 사용자 저장은 계속 진행
|
||||
logger.warn("결재라인 영향 확인 중 오류 (사용자 저장은 계속 진행)", {
|
||||
error: approvalCheckError instanceof Error ? approvalCheckError.message : approvalCheckError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT)
|
||||
const updatePasswordClause = encryptedPassword ? "user_password = $4," : "";
|
||||
|
||||
|
|
@ -2688,6 +2908,12 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
|||
savedUser.regdate &&
|
||||
new Date(savedUser.regdate).getTime() < Date.now() - 1000;
|
||||
|
||||
// 기존 사용자의 비밀번호 변경 시 JWT 토큰 무효화
|
||||
if (encryptedPassword && isExistingUser) {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userData.userId);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료",
|
||||
{
|
||||
|
|
@ -3534,6 +3760,10 @@ export const resetUserPassword = async (
|
|||
if (updateResult.length > 0) {
|
||||
// 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
|
||||
|
||||
// 비밀번호 변경 후 기존 JWT 토큰 무효화
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||
|
||||
logger.info("비밀번호 초기화 성공", {
|
||||
userId,
|
||||
updatedBy: req.user?.userId,
|
||||
|
|
@ -4153,6 +4383,140 @@ export const saveUserWithDept = async (
|
|||
* GET /api/admin/users/:userId/with-dept
|
||||
* 사원 + 부서 정보 조회 API (수정 모달용)
|
||||
*/
|
||||
/**
|
||||
* DELETE /api/admin/users/:userId
|
||||
* 사용자 삭제 API (soft delete)
|
||||
* status = 'deleted', end_date = now() 설정
|
||||
* authority_sub_user 멤버십 제거, JWT 토큰 무효화
|
||||
*/
|
||||
export const deleteUser = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
// 1. userId 파라미터 검증
|
||||
if (!userId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 ID는 필수입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 자기 자신 삭제 방지
|
||||
if (req.user?.userId === userId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "자기 자신은 삭제할 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 사용자 존재 여부 확인
|
||||
const currentUser = await queryOne<any>(
|
||||
`SELECT user_id, user_name, status, company_code FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!currentUser) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자를 찾을 수 없습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 삭제된 사용자 체크
|
||||
if (currentUser.status === "deleted") {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "이미 삭제된 사용자입니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. soft delete: status = 'deleted', end_date = now()
|
||||
const updateResult = await query<any>(
|
||||
`UPDATE user_info
|
||||
SET status = 'deleted', end_date = NOW()
|
||||
WHERE user_id = $1
|
||||
RETURNING *`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (updateResult.length === 0) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "사용자 삭제에 실패했습니다.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. authority_sub_user에서 해당 사용자 멤버십 제거
|
||||
await query(
|
||||
`DELETE FROM authority_sub_user WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
// 6. JWT 토큰 무효화
|
||||
try {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateUserTokens(userId);
|
||||
} catch (tokenError) {
|
||||
logger.warn("토큰 무효화 중 오류 (삭제는 정상 처리됨)", { userId, error: tokenError });
|
||||
}
|
||||
|
||||
logger.info("사용자 삭제(soft delete) 성공", {
|
||||
userId,
|
||||
userName: currentUser.user_name,
|
||||
deletedBy: req.user?.userId,
|
||||
});
|
||||
|
||||
// 7. 감사 로그 기록
|
||||
auditLogService.log({
|
||||
companyCode: currentUser.company_code || req.user?.companyCode || "",
|
||||
userId: req.user?.userId || "",
|
||||
userName: req.user?.userName || "",
|
||||
action: "DELETE",
|
||||
resourceType: "USER",
|
||||
resourceId: userId,
|
||||
resourceName: currentUser.user_name,
|
||||
summary: `사용자 "${currentUser.user_name}" (${userId}) 삭제 처리`,
|
||||
changes: {
|
||||
before: { status: currentUser.status },
|
||||
after: { status: "deleted" },
|
||||
fields: ["status", "end_date"],
|
||||
},
|
||||
ipAddress: getClientIp(req),
|
||||
requestPath: req.originalUrl,
|
||||
});
|
||||
|
||||
// 8. 응답
|
||||
res.json({
|
||||
success: true,
|
||||
result: true,
|
||||
message: `사용자 "${currentUser.user_name}" (${userId})이(가) 삭제되었습니다.`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("사용자 삭제 중 오류 발생", {
|
||||
error: error.message,
|
||||
userId: req.params.userId,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
result: false,
|
||||
message: "시스템 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getUserWithDept = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
|
|
|
|||
|
|
@ -561,6 +561,34 @@ export class EntityJoinController {
|
|||
});
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용)
|
||||
* GET /api/table-management/tables/:tableName/column-values/:columnName
|
||||
*/
|
||||
async getColumnUniqueValues(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { tableName, columnName } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode;
|
||||
|
||||
const data = await tableManagementService.getColumnDistinctValues(
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`컬럼 고유값 조회 실패: ${req.params.tableName}.${req.params.columnName}`, error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "컬럼 고유값 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const entityJoinController = new EntityJoinController();
|
||||
|
|
|
|||
|
|
@ -191,18 +191,30 @@ export const getLangKeys = async (
|
|||
): Promise<void> => {
|
||||
try {
|
||||
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 키 목록 조회 요청", {
|
||||
query: req.query,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
// company_code 필터링: 비관리자는 자기 회사 + 공통(*) 키만 조회 가능
|
||||
let effectiveCompanyCode = companyCode as string;
|
||||
if (userCompanyCode !== "*") {
|
||||
// 비관리자가 다른 회사의 데이터를 요청하면 자기 회사로 제한
|
||||
if (companyCode && companyCode !== userCompanyCode && companyCode !== "*") {
|
||||
effectiveCompanyCode = userCompanyCode || "";
|
||||
}
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const langKeys = await multiLangService.getLangKeys({
|
||||
companyCode: companyCode as string,
|
||||
companyCode: effectiveCompanyCode,
|
||||
menuCode: menuCode as string,
|
||||
keyType: keyType as string,
|
||||
searchText: searchText as string,
|
||||
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
|
||||
// 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환
|
||||
userCompanyCode: userCompanyCode,
|
||||
});
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
|
|
@ -235,9 +247,24 @@ export const getLangTexts = async (
|
|||
): Promise<void> => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (keyOwner && keyOwner !== "*" && keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 키에 접근할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const langTexts = await multiLangService.getLangTexts(parseInt(keyId));
|
||||
|
||||
const response: ApiResponse<any[]> = {
|
||||
|
|
@ -270,6 +297,7 @@ export const createLangKey = async (
|
|||
): Promise<void> => {
|
||||
try {
|
||||
const keyData: CreateLangKeyRequest = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 키 생성 요청", { keyData, user: req.user });
|
||||
|
||||
// 필수 입력값 검증
|
||||
|
|
@ -285,6 +313,26 @@ export const createLangKey = async (
|
|||
return;
|
||||
}
|
||||
|
||||
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
|
||||
if (keyData.companyCode === "*" && userCompanyCode !== "*") {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 비관리자: 자기 회사 키만 생성 가능
|
||||
if (userCompanyCode !== "*" && keyData.companyCode !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 키를 생성할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
const keyId = await multiLangService.createLangKey({
|
||||
...keyData,
|
||||
|
|
@ -323,10 +371,33 @@ export const updateLangKey = async (
|
|||
try {
|
||||
const { keyId } = req.params;
|
||||
const keyData: UpdateLangKeyRequest = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키는 수정 불가)
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (!keyOwner) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "다국어 키를 찾을 수 없습니다.",
|
||||
error: { code: "KEY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 키를 수정할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await multiLangService.updateLangKey(parseInt(keyId), {
|
||||
...keyData,
|
||||
updatedBy: req.user?.userId || "system",
|
||||
|
|
@ -362,9 +433,32 @@ export const deleteLangKey = async (
|
|||
): Promise<void> => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 키 삭제 요청", { keyId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키 삭제 불가)
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (!keyOwner) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "다국어 키를 찾을 수 없습니다.",
|
||||
error: { code: "KEY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 키를 삭제할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await multiLangService.deleteLangKey(parseInt(keyId));
|
||||
|
||||
const response: ApiResponse<string> = {
|
||||
|
|
@ -397,9 +491,32 @@ export const toggleLangKey = async (
|
|||
): Promise<void> => {
|
||||
try {
|
||||
const { keyId } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user });
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 키인지 검증
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (!keyOwner) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "다국어 키를 찾을 수 없습니다.",
|
||||
error: { code: "KEY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 키를 변경할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await multiLangService.toggleLangKey(parseInt(keyId));
|
||||
|
||||
const response: ApiResponse<string> = {
|
||||
|
|
@ -433,6 +550,7 @@ export const saveLangTexts = async (
|
|||
try {
|
||||
const { keyId } = req.params;
|
||||
const textData: SaveLangTextsRequest = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user });
|
||||
|
||||
|
|
@ -454,6 +572,28 @@ export const saveLangTexts = async (
|
|||
}
|
||||
|
||||
const multiLangService = new MultiLangService();
|
||||
|
||||
// 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증
|
||||
if (userCompanyCode !== "*") {
|
||||
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
|
||||
if (!keyOwner) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: "다국어 키를 찾을 수 없습니다.",
|
||||
error: { code: "KEY_NOT_FOUND" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (keyOwner !== userCompanyCode) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: "다른 회사의 다국어 텍스트를 수정할 권한이 없습니다.",
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await multiLangService.saveLangTexts(parseInt(keyId), {
|
||||
texts: textData.texts.map((text) => ({
|
||||
...text,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,509 @@
|
|||
/**
|
||||
* 출고관리 컨트롤러
|
||||
*
|
||||
* 출고유형별 소스 테이블:
|
||||
* - 판매출고 → shipment_instruction + shipment_instruction_detail (출하지시)
|
||||
* - 반품출고 → purchase_order_mng (발주/입고)
|
||||
* - 기타출고 → item_info (품목)
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 출고 목록 조회
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const {
|
||||
outbound_type,
|
||||
outbound_status,
|
||||
search_keyword,
|
||||
date_from,
|
||||
date_to,
|
||||
} = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 전체 조회
|
||||
} else {
|
||||
conditions.push(`om.company_code = $${paramIdx}`);
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (outbound_type && outbound_type !== "all") {
|
||||
conditions.push(`om.outbound_type = $${paramIdx}`);
|
||||
params.push(outbound_type);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (outbound_status && outbound_status !== "all") {
|
||||
conditions.push(`om.outbound_status = $${paramIdx}`);
|
||||
params.push(outbound_status);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (search_keyword) {
|
||||
conditions.push(
|
||||
`(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${search_keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (date_from) {
|
||||
conditions.push(`om.outbound_date >= $${paramIdx}`);
|
||||
params.push(date_from);
|
||||
paramIdx++;
|
||||
}
|
||||
if (date_to) {
|
||||
conditions.push(`om.outbound_date <= $${paramIdx}`);
|
||||
params.push(date_to);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
om.*,
|
||||
wh.warehouse_name
|
||||
FROM outbound_mng om
|
||||
LEFT JOIN warehouse_info wh
|
||||
ON om.warehouse_code = wh.warehouse_code
|
||||
AND om.company_code = wh.company_code
|
||||
${whereClause}
|
||||
ORDER BY om.created_date DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("출고 목록 조회", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 등록 (다건)
|
||||
export async function create(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { items, outbound_number, outbound_date, warehouse_code, location_code, manager_id, memo } = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "출고 품목이 없습니다." });
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
const insertedRows: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO outbound_mng (
|
||||
company_code, outbound_number, outbound_type, outbound_date,
|
||||
reference_number, customer_code, customer_name,
|
||||
item_code, item_name, specification, material, unit,
|
||||
outbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
outbound_status, manager_id, memo,
|
||||
source_type, sales_order_id, shipment_plan_id, item_info_id,
|
||||
destination_code, delivery_destination, delivery_address,
|
||||
created_date, created_by, writer, status
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7,
|
||||
$8, $9, $10, $11, $12,
|
||||
$13, $14, $15,
|
||||
$16, $17, $18,
|
||||
$19, $20, $21,
|
||||
$22, $23, $24, $25,
|
||||
$26, $27, $28,
|
||||
NOW(), $29, $29, '출고'
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
outbound_number || item.outbound_number,
|
||||
item.outbound_type,
|
||||
outbound_date || item.outbound_date,
|
||||
item.reference_number || null,
|
||||
item.customer_code || null,
|
||||
item.customer_name || null,
|
||||
item.item_code || item.item_number || null,
|
||||
item.item_name || null,
|
||||
item.spec || item.specification || null,
|
||||
item.material || null,
|
||||
item.unit || "EA",
|
||||
item.outbound_qty || 0,
|
||||
item.unit_price || 0,
|
||||
item.total_amount || 0,
|
||||
item.lot_number || null,
|
||||
warehouse_code || item.warehouse_code || null,
|
||||
location_code || item.location_code || null,
|
||||
item.outbound_status || "대기",
|
||||
manager_id || item.manager_id || null,
|
||||
memo || item.memo || null,
|
||||
item.source_type || null,
|
||||
item.sales_order_id || null,
|
||||
item.shipment_plan_id || null,
|
||||
item.item_info_id || null,
|
||||
item.destination_code || null,
|
||||
item.delivery_destination || null,
|
||||
item.delivery_address || null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
insertedRows.push(result.rows[0]);
|
||||
|
||||
// 재고 업데이트 (inventory_stock): 출고 수량 차감
|
||||
const itemCode = item.item_code || item.item_number || null;
|
||||
const whCode = warehouse_code || item.warehouse_code || null;
|
||||
const locCode = location_code || item.location_code || null;
|
||||
const outQty = Number(item.outbound_qty) || 0;
|
||||
if (itemCode && outQty > 0) {
|
||||
const existingStock = await client.query(
|
||||
`SELECT id FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || '', locCode || '']
|
||||
);
|
||||
|
||||
if (existingStock.rows.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text),
|
||||
last_out_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2`,
|
||||
[outQty, existingStock.rows[0].id]
|
||||
);
|
||||
} else {
|
||||
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
|
||||
await client.query(
|
||||
`INSERT INTO inventory_stock (
|
||||
company_code, item_code, warehouse_code, location_code,
|
||||
current_qty, safety_qty, last_out_date,
|
||||
created_date, updated_date, writer
|
||||
) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
|
||||
[companyCode, itemCode, whCode, locCode, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 판매출고인 경우 출하지시의 ship_qty 업데이트
|
||||
if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") {
|
||||
await client.query(
|
||||
`UPDATE shipment_instruction_detail
|
||||
SET ship_qty = COALESCE(ship_qty, 0) + $1,
|
||||
updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[item.outbound_qty || 0, item.source_id, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("출고 등록 완료", {
|
||||
companyCode,
|
||||
userId,
|
||||
count: insertedRows.length,
|
||||
outbound_number,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: insertedRows,
|
||||
message: `${insertedRows.length}건 출고 등록 완료`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("출고 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 수정
|
||||
export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const {
|
||||
outbound_date, outbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
outbound_status, manager_id: mgr, memo,
|
||||
} = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`UPDATE outbound_mng SET
|
||||
outbound_date = COALESCE($1, outbound_date),
|
||||
outbound_qty = COALESCE($2, outbound_qty),
|
||||
unit_price = COALESCE($3, unit_price),
|
||||
total_amount = COALESCE($4, total_amount),
|
||||
lot_number = COALESCE($5, lot_number),
|
||||
warehouse_code = COALESCE($6, warehouse_code),
|
||||
location_code = COALESCE($7, location_code),
|
||||
outbound_status = COALESCE($8, outbound_status),
|
||||
manager_id = COALESCE($9, manager_id),
|
||||
memo = COALESCE($10, memo),
|
||||
updated_date = NOW(),
|
||||
updated_by = $11
|
||||
WHERE id = $12 AND company_code = $13
|
||||
RETURNING *`,
|
||||
[
|
||||
outbound_date, outbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
outbound_status, mgr, memo,
|
||||
userId, id, companyCode,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("출고 수정", { companyCode, userId, id });
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 출고 삭제
|
||||
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("출고 삭제", { companyCode, id });
|
||||
|
||||
return res.json({ success: true, message: "삭제 완료" });
|
||||
} catch (error: any) {
|
||||
logger.error("출고 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 판매출고용: 출하지시 데이터 조회
|
||||
export async function getShipmentInstructions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["si.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
sid.id AS detail_id,
|
||||
si.id AS instruction_id,
|
||||
si.instruction_no,
|
||||
si.instruction_date,
|
||||
si.partner_id,
|
||||
si.status AS instruction_status,
|
||||
sid.item_code,
|
||||
sid.item_name,
|
||||
sid.spec,
|
||||
sid.material,
|
||||
COALESCE(sid.plan_qty, 0) AS plan_qty,
|
||||
COALESCE(sid.ship_qty, 0) AS ship_qty,
|
||||
COALESCE(sid.order_qty, 0) AS order_qty,
|
||||
GREATEST(COALESCE(sid.plan_qty, 0) - COALESCE(sid.ship_qty, 0), 0) AS remain_qty,
|
||||
sid.source_type
|
||||
FROM shipment_instruction si
|
||||
JOIN shipment_instruction_detail sid
|
||||
ON si.id = sid.instruction_id
|
||||
AND si.company_code = sid.company_code
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0)
|
||||
ORDER BY si.instruction_date DESC, si.instruction_no`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 반품출고용: 발주(입고) 데이터 조회
|
||||
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
// 입고된 것만 (반품 대상)
|
||||
conditions.push(
|
||||
`COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`
|
||||
);
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, purchase_no, order_date, supplier_code, supplier_name,
|
||||
item_code, item_name, spec, material,
|
||||
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
|
||||
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty,
|
||||
COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price,
|
||||
status, due_date
|
||||
FROM purchase_order_mng
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY order_date DESC, purchase_no`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("발주 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 기타출고용: 품목 데이터 조회
|
||||
export async function getItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, item_number, item_name, size AS spec, material, unit,
|
||||
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name`,
|
||||
params
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("품목 데이터 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 출고번호 자동생성
|
||||
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const today = new Date();
|
||||
const yyyy = today.getFullYear();
|
||||
const prefix = `OUT-${yyyy}-`;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT outbound_number FROM outbound_mng
|
||||
WHERE company_code = $1 AND outbound_number LIKE $2
|
||||
ORDER BY outbound_number DESC LIMIT 1`,
|
||||
[companyCode, `${prefix}%`]
|
||||
);
|
||||
|
||||
let seq = 1;
|
||||
if (result.rows.length > 0) {
|
||||
const lastNo = result.rows[0].outbound_number;
|
||||
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
|
||||
if (!isNaN(lastSeq)) seq = lastSeq + 1;
|
||||
}
|
||||
|
||||
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
|
||||
|
||||
return res.json({ success: true, data: newNumber });
|
||||
} catch (error: any) {
|
||||
logger.error("출고번호 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 창고 목록 조회
|
||||
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT warehouse_code, warehouse_name, warehouse_type
|
||||
FROM warehouse_info
|
||||
WHERE company_code = $1 AND status != '삭제'
|
||||
ORDER BY warehouse_name`,
|
||||
[companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("창고 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +173,11 @@ export async function getPkgUnitItems(
|
|||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
|
||||
`SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit
|
||||
FROM pkg_unit_item pui
|
||||
LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code
|
||||
WHERE pui.pkg_code=$1 AND pui.company_code=$2
|
||||
ORDER BY pui.created_date DESC`,
|
||||
[pkgCode, companyCode]
|
||||
);
|
||||
|
||||
|
|
@ -410,7 +414,11 @@ export async function getLoadingUnitPkgs(
|
|||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
|
||||
`SELECT lup.*, pu.pkg_name, pu.pkg_type
|
||||
FROM loading_unit_pkg lup
|
||||
LEFT JOIN pkg_unit pu ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code
|
||||
WHERE lup.loading_code=$1 AND lup.company_code=$2
|
||||
ORDER BY lup.created_date DESC`,
|
||||
[loadingCode, companyCode]
|
||||
);
|
||||
|
||||
|
|
@ -476,3 +484,112 @@ export async function deleteLoadingUnitPkg(
|
|||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 품목정보 연동 (division별 item_info 조회)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
export async function getItemsByDivision(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { divisionLabel } = req.params;
|
||||
const { keyword } = req.query;
|
||||
const pool = getPool();
|
||||
|
||||
// division 카테고리에서 해당 라벨의 코드 찾기
|
||||
const catResult = await pool.query(
|
||||
`SELECT value_code FROM category_values
|
||||
WHERE table_name = 'item_info' AND column_name = 'division'
|
||||
AND value_label = $1 AND company_code = $2
|
||||
LIMIT 1`,
|
||||
[divisionLabel, companyCode]
|
||||
);
|
||||
|
||||
if (catResult.rows.length === 0) {
|
||||
res.json({ success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const divisionCode = catResult.rows[0].value_code;
|
||||
|
||||
const conditions: string[] = ["company_code = $1", `$2 = ANY(string_to_array(division, ','))`];
|
||||
const params: any[] = [companyCode, divisionCode];
|
||||
let paramIdx = 3;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name, size, material, unit, division
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name`,
|
||||
params
|
||||
);
|
||||
|
||||
logger.info(`품목 조회 (division=${divisionLabel})`, { companyCode, count: result.rowCount });
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("품목 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 일반 품목 조회 (포장재/적재함 제외, 매칭용)
|
||||
export async function getGeneralItems(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
const pool = getPool();
|
||||
|
||||
// 포장재/적재함 division 코드 조회
|
||||
const catResult = await pool.query(
|
||||
`SELECT value_code FROM category_values
|
||||
WHERE table_name = 'item_info' AND column_name = 'division'
|
||||
AND value_label IN ('포장재', '적재함') AND company_code = $1`,
|
||||
[companyCode]
|
||||
);
|
||||
const excludeCodes = catResult.rows.map((r: any) => r.value_code);
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (excludeCodes.length > 0) {
|
||||
// 다중 값(콤마 구분) 지원: 포장재/적재함 코드가 포함된 품목 제외
|
||||
const excludeConditions = excludeCodes.map((_: any, i: number) => `$${paramIdx + i} = ANY(string_to_array(division, ','))`);
|
||||
conditions.push(`(division IS NULL OR division = '' OR NOT (${excludeConditions.join(" OR ")}))`);
|
||||
params.push(...excludeCodes);
|
||||
paramIdx += excludeCodes.length;
|
||||
}
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name, size AS spec, material, unit, division
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name
|
||||
LIMIT 200`,
|
||||
params
|
||||
);
|
||||
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("일반 품목 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,9 +148,9 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo
|
|||
const { processCode } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT pe.*, ei.equipment_name
|
||||
`SELECT pe.*, em.equipment_name
|
||||
FROM process_equipment pe
|
||||
LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code
|
||||
LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code
|
||||
WHERE pe.process_code = $1 AND pe.company_code = $2
|
||||
ORDER BY pe.equipment_code`,
|
||||
[processCode, companyCode]
|
||||
|
|
@ -214,7 +214,7 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response)
|
|||
const params = companyCode === "*" ? [] : [companyCode];
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`,
|
||||
`SELECT objid AS id, equipment_code, equipment_name FROM equipment_mng ${condition} ORDER BY equipment_code`,
|
||||
params
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,28 @@ export async function getStockShortage(req: AuthenticatedRequest, res: Response)
|
|||
}
|
||||
}
|
||||
|
||||
// ─── 생산계획 목록 조회 ───
|
||||
|
||||
export async function getPlans(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { productType, status, startDate, endDate, itemCode } = req.query;
|
||||
|
||||
const data = await productionService.getPlans(companyCode, {
|
||||
productType: productType as string,
|
||||
status: status as string,
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
itemCode: itemCode as string,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("생산계획 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 생산계획 상세 조회 ───
|
||||
|
||||
export async function getPlanById(req: AuthenticatedRequest, res: Response) {
|
||||
|
|
|
|||
|
|
@ -170,6 +170,42 @@ export async function create(req: AuthenticatedRequest, res: Response) {
|
|||
|
||||
insertedRows.push(result.rows[0]);
|
||||
|
||||
// 재고 업데이트 (inventory_stock): 입고 수량 증가
|
||||
const itemCode = item.item_number || null;
|
||||
const whCode = warehouse_code || item.warehouse_code || null;
|
||||
const locCode = location_code || item.location_code || null;
|
||||
const inQty = Number(item.inbound_qty) || 0;
|
||||
if (itemCode && inQty > 0) {
|
||||
const existingStock = await client.query(
|
||||
`SELECT id FROM inventory_stock
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
|
||||
AND COALESCE(location_code, '') = COALESCE($4, '')
|
||||
LIMIT 1`,
|
||||
[companyCode, itemCode, whCode || '', locCode || '']
|
||||
);
|
||||
|
||||
if (existingStock.rows.length > 0) {
|
||||
await client.query(
|
||||
`UPDATE inventory_stock
|
||||
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
|
||||
last_in_date = NOW(),
|
||||
updated_date = NOW()
|
||||
WHERE id = $2`,
|
||||
[inQty, existingStock.rows[0].id]
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO inventory_stock (
|
||||
company_code, item_code, warehouse_code, location_code,
|
||||
current_qty, safety_qty, last_in_date,
|
||||
created_date, updated_date, writer
|
||||
) VALUES ($1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`,
|
||||
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 구매입고인 경우 발주의 received_qty 업데이트
|
||||
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
|
||||
await client.query(
|
||||
|
|
|
|||
|
|
@ -472,6 +472,10 @@ export const addRoleMembers = async (
|
|||
req.user?.userId || "SYSTEM"
|
||||
);
|
||||
|
||||
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "권한 그룹 멤버 추가 성공",
|
||||
|
|
@ -568,6 +572,13 @@ export const updateRoleMembers = async (
|
|||
);
|
||||
}
|
||||
|
||||
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||
const allAffectedUsers = [...new Set([...toAdd, ...toRemove])];
|
||||
if (allAffectedUsers.length > 0) {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(allAffectedUsers);
|
||||
}
|
||||
|
||||
logger.info("권한 그룹 멤버 일괄 업데이트 성공", {
|
||||
masterObjid,
|
||||
added: toAdd.length,
|
||||
|
|
@ -646,6 +657,10 @@ export const removeRoleMembers = async (
|
|||
req.user?.userId || "SYSTEM"
|
||||
);
|
||||
|
||||
// 권한 변경된 사용자들의 JWT 토큰 무효화
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "권한 그룹 멤버 제거 성공",
|
||||
|
|
@ -777,6 +792,18 @@ export const setMenuPermissions = async (
|
|||
req.user?.userId || "SYSTEM"
|
||||
);
|
||||
|
||||
// 해당 권한 그룹의 모든 멤버 JWT 토큰 무효화
|
||||
try {
|
||||
const members = await RoleService.getRoleMembers(authObjid);
|
||||
const memberIds = members.map((m: any) => m.userId);
|
||||
if (memberIds.length > 0) {
|
||||
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
|
||||
await TokenInvalidationService.invalidateMultipleUserTokens(memberIds);
|
||||
}
|
||||
} catch (invalidateError) {
|
||||
logger.warn("메뉴 권한 변경 후 토큰 무효화 실패 (권한 설정은 성공)", { invalidateError });
|
||||
}
|
||||
|
||||
const response: ApiResponse<null> = {
|
||||
success: true,
|
||||
message: "메뉴 권한 설정 성공",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Request, Response, NextFunction } from "express";
|
|||
import { JwtUtils } from "../utils/jwtUtils";
|
||||
import { AuthenticatedRequest, PersonBean } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
import { TokenInvalidationService } from "../services/tokenInvalidationService";
|
||||
|
||||
// AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export
|
||||
export { AuthenticatedRequest } from "../types/auth";
|
||||
|
|
@ -22,11 +23,11 @@ declare global {
|
|||
* JWT 토큰 검증 미들웨어
|
||||
* 기존 세션 방식과 동일한 효과를 제공
|
||||
*/
|
||||
export const authenticateToken = (
|
||||
export const authenticateToken = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// Authorization 헤더에서 토큰 추출
|
||||
const authHeader = req.get("Authorization");
|
||||
|
|
@ -46,6 +47,25 @@ export const authenticateToken = (
|
|||
// JWT 토큰 검증 및 사용자 정보 추출
|
||||
const userInfo: PersonBean = JwtUtils.verifyToken(token);
|
||||
|
||||
// token_version 검증 (JWT payload vs DB)
|
||||
const decoded = JwtUtils.decodeToken(token);
|
||||
const tokenVersion = decoded?.tokenVersion;
|
||||
|
||||
// tokenVersion이 undefined면 구버전 토큰이므로 통과 (하위 호환)
|
||||
if (tokenVersion !== undefined) {
|
||||
const dbVersion = await TokenInvalidationService.getUserTokenVersion(userInfo.userId);
|
||||
if (tokenVersion !== dbVersion) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: "TOKEN_INVALIDATED",
|
||||
details: "보안 정책에 의해 재로그인이 필요합니다.",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 요청 객체에 사용자 정보 설정 (기존 PersonBean과 동일)
|
||||
req.user = userInfo;
|
||||
|
||||
|
|
@ -173,11 +193,11 @@ export const requireUserOrAdmin = (targetUserId: string) => {
|
|||
* 토큰 갱신 미들웨어
|
||||
* 토큰이 곧 만료될 경우 자동으로 갱신
|
||||
*/
|
||||
export const refreshTokenIfNeeded = (
|
||||
export const refreshTokenIfNeeded = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
|
@ -191,6 +211,16 @@ export const refreshTokenIfNeeded = (
|
|||
|
||||
// 1시간(3600초) 이내에 만료되는 경우 갱신
|
||||
if (timeUntilExpiry > 0 && timeUntilExpiry < 3600) {
|
||||
// 갱신 전 token_version 검증
|
||||
if (decoded.tokenVersion !== undefined) {
|
||||
const dbVersion = await TokenInvalidationService.getUserTokenVersion(decoded.userId);
|
||||
if (decoded.tokenVersion !== dbVersion) {
|
||||
// 무효화된 토큰은 갱신하지 않음
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newToken = JwtUtils.refreshToken(token);
|
||||
|
||||
// 새로운 토큰을 응답 헤더에 포함
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
saveUser, // 사용자 등록/수정
|
||||
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
|
||||
getUserWithDept, // 사원 + 부서 조회 (NEW!)
|
||||
deleteUser, // 사용자 삭제 (soft delete)
|
||||
getCompanyList,
|
||||
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
|
||||
getCompanyByCode, // 회사 단건 조회
|
||||
|
|
@ -62,6 +63,7 @@ router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
|
|||
router.put("/profile", updateProfile); // 프로필 수정
|
||||
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
|
||||
router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화
|
||||
router.delete("/users/:userId", deleteUser); // 사용자 삭제 (soft delete)
|
||||
|
||||
// 부서 관리 API
|
||||
router.get("/departments", getDepartmentList); // 부서 목록 조회
|
||||
|
|
|
|||
|
|
@ -55,6 +55,15 @@ router.get(
|
|||
entityJoinController.getTableDataWithJoins.bind(entityJoinController)
|
||||
);
|
||||
|
||||
/**
|
||||
* 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용)
|
||||
* GET /api/table-management/tables/:tableName/column-values/:columnName
|
||||
*/
|
||||
router.get(
|
||||
"/tables/:tableName/column-values/:columnName",
|
||||
entityJoinController.getColumnUniqueValues.bind(entityJoinController)
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 🎯 Entity 조인 설정 관리
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* 출고관리 라우트
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as outboundController from "../controllers/outboundController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 출고 목록 조회
|
||||
router.get("/list", outboundController.getList);
|
||||
|
||||
// 출고번호 자동생성
|
||||
router.get("/generate-number", outboundController.generateNumber);
|
||||
|
||||
// 창고 목록 조회
|
||||
router.get("/warehouses", outboundController.getWarehouses);
|
||||
|
||||
// 소스 데이터: 출하지시 (판매출고)
|
||||
router.get("/source/shipment-instructions", outboundController.getShipmentInstructions);
|
||||
|
||||
// 소스 데이터: 발주 (반품출고)
|
||||
router.get("/source/purchase-orders", outboundController.getPurchaseOrders);
|
||||
|
||||
// 소스 데이터: 품목 (기타출고)
|
||||
router.get("/source/items", outboundController.getItems);
|
||||
|
||||
// 출고 등록
|
||||
router.post("/", outboundController.create);
|
||||
|
||||
// 출고 수정
|
||||
router.put("/:id", outboundController.update);
|
||||
|
||||
// 출고 삭제
|
||||
router.delete("/:id", outboundController.deleteOutbound);
|
||||
|
||||
export default router;
|
||||
|
|
@ -5,6 +5,7 @@ import {
|
|||
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
|
||||
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
|
||||
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
|
||||
getItemsByDivision, getGeneralItems,
|
||||
} from "../controllers/packagingController";
|
||||
|
||||
const router = Router();
|
||||
|
|
@ -33,4 +34,8 @@ router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
|
|||
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
|
||||
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
|
||||
|
||||
// 품목정보 연동 (division별)
|
||||
router.get("/items/general", getGeneralItems);
|
||||
router.get("/items/:divisionLabel", getItemsByDivision);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ router.get("/order-summary", productionController.getOrderSummary);
|
|||
// 안전재고 부족분 조회
|
||||
router.get("/stock-shortage", productionController.getStockShortage);
|
||||
|
||||
// 생산계획 목록 조회
|
||||
router.get("/plans", productionController.getPlans);
|
||||
|
||||
// 생산계획 CRUD
|
||||
router.get("/plan/:id", productionController.getPlanById);
|
||||
router.put("/plan/:id", productionController.updatePlan);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,8 @@ export type AuditAction =
|
|||
| "STATUS_CHANGE"
|
||||
| "BATCH_CREATE"
|
||||
| "BATCH_UPDATE"
|
||||
| "BATCH_DELETE";
|
||||
| "BATCH_DELETE"
|
||||
| "DEPT_CHANGE_WARNING";
|
||||
|
||||
export type AuditResourceType =
|
||||
| "MENU"
|
||||
|
|
|
|||
|
|
@ -134,12 +134,14 @@ export class AuthService {
|
|||
company_code: string | null;
|
||||
locale: string | null;
|
||||
photo: Buffer | null;
|
||||
token_version: number | null;
|
||||
}>(
|
||||
`SELECT
|
||||
sabun, user_id, user_name, user_name_eng, user_name_cn,
|
||||
dept_code, dept_name, position_code, position_name,
|
||||
email, tel, cell_phone, user_type, user_type_name,
|
||||
partner_objid, company_code, locale, photo
|
||||
partner_objid, company_code, locale, photo,
|
||||
COALESCE(token_version, 0) as token_version
|
||||
FROM user_info
|
||||
WHERE user_id = $1`,
|
||||
[userId]
|
||||
|
|
@ -210,6 +212,7 @@ export class AuthService {
|
|||
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
|
||||
: undefined,
|
||||
locale: userInfo.locale || "KR",
|
||||
tokenVersion: userInfo.token_version ?? 0,
|
||||
// 권한 레벨 정보 추가 (3단계 체계)
|
||||
isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN",
|
||||
isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*",
|
||||
|
|
|
|||
|
|
@ -673,6 +673,22 @@ export class MultiLangService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 키의 소유 회사 코드 조회 (권한 검증용)
|
||||
*/
|
||||
async getKeyCompanyCode(keyId: number): Promise<string | null> {
|
||||
try {
|
||||
const result = await queryOne<{ company_code: string }>(
|
||||
`SELECT company_code FROM multi_lang_key_master WHERE key_id = $1`,
|
||||
[keyId]
|
||||
);
|
||||
return result?.company_code || null;
|
||||
} catch (error) {
|
||||
logger.error("키 소유 회사 코드 조회 실패:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다국어 키 목록 조회
|
||||
*/
|
||||
|
|
@ -688,6 +704,10 @@ export class MultiLangService {
|
|||
if (params.companyCode) {
|
||||
whereConditions.push(`company_code = $${paramIndex++}`);
|
||||
values.push(params.companyCode);
|
||||
} else if (params.userCompanyCode && params.userCompanyCode !== "*") {
|
||||
// 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환
|
||||
whereConditions.push(`company_code IN ($${paramIndex++}, '*')`);
|
||||
values.push(params.userCompanyCode);
|
||||
}
|
||||
|
||||
// 메뉴 코드 필터
|
||||
|
|
|
|||
|
|
@ -35,6 +35,33 @@ export async function getOrderSummary(
|
|||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
// item_info에 lead_time 컬럼이 존재하는지 확인
|
||||
const leadTimeColCheck = await pool.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'item_info' AND column_name = 'lead_time'
|
||||
) AS has_lead_time
|
||||
`);
|
||||
const hasLeadTime = leadTimeColCheck.rows[0]?.has_lead_time === true;
|
||||
|
||||
const itemLeadTimeCte = hasLeadTime
|
||||
? `item_lead_time AS (
|
||||
SELECT
|
||||
item_number,
|
||||
id AS item_id,
|
||||
COALESCE(lead_time::int, 0) AS lead_time
|
||||
FROM item_info
|
||||
WHERE company_code = $1
|
||||
),`
|
||||
: `item_lead_time AS (
|
||||
SELECT
|
||||
item_number,
|
||||
id AS item_id,
|
||||
0 AS lead_time
|
||||
FROM item_info
|
||||
WHERE company_code = $1
|
||||
),`;
|
||||
|
||||
const query = `
|
||||
WITH order_summary AS (
|
||||
SELECT
|
||||
|
|
@ -49,6 +76,7 @@ export async function getOrderSummary(
|
|||
WHERE ${whereClause}
|
||||
GROUP BY so.part_code, so.part_name
|
||||
),
|
||||
${itemLeadTimeCte}
|
||||
stock_info AS (
|
||||
SELECT
|
||||
item_code,
|
||||
|
|
@ -85,10 +113,12 @@ export async function getOrderSummary(
|
|||
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
|
||||
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
|
||||
0
|
||||
) AS required_plan_qty
|
||||
) AS required_plan_qty,
|
||||
COALESCE(ilt.lead_time, 0) AS lead_time
|
||||
FROM order_summary os
|
||||
LEFT JOIN stock_info si ON os.item_code = si.item_code
|
||||
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
|
||||
LEFT JOIN item_lead_time ilt ON (os.item_code = ilt.item_number OR os.item_code = ilt.item_id)
|
||||
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
|
||||
ORDER BY os.item_code;
|
||||
`;
|
||||
|
|
@ -155,6 +185,80 @@ export async function getStockShortage(companyCode: string) {
|
|||
return result.rows;
|
||||
}
|
||||
|
||||
// ─── 생산계획 목록 조회 ───
|
||||
|
||||
export async function getPlans(
|
||||
companyCode: string,
|
||||
options?: {
|
||||
productType?: string;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
itemCode?: string;
|
||||
}
|
||||
) {
|
||||
const pool = getPool();
|
||||
const conditions: string[] = ["p.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
// 일반 회사: 자사 데이터만
|
||||
} else {
|
||||
// 최고관리자: 전체 데이터 (company_code 조건 제거)
|
||||
conditions.length = 0;
|
||||
}
|
||||
|
||||
if (options?.productType) {
|
||||
conditions.push(`COALESCE(p.product_type, '완제품') = $${paramIdx}`);
|
||||
params.push(options.productType);
|
||||
paramIdx++;
|
||||
}
|
||||
if (options?.status && options.status !== "all") {
|
||||
conditions.push(`p.status = $${paramIdx}`);
|
||||
params.push(options.status);
|
||||
paramIdx++;
|
||||
}
|
||||
if (options?.startDate) {
|
||||
conditions.push(`p.end_date >= $${paramIdx}::date`);
|
||||
params.push(options.startDate);
|
||||
paramIdx++;
|
||||
}
|
||||
if (options?.endDate) {
|
||||
conditions.push(`p.start_date <= $${paramIdx}::date`);
|
||||
params.push(options.endDate);
|
||||
paramIdx++;
|
||||
}
|
||||
if (options?.itemCode) {
|
||||
conditions.push(`(p.item_code ILIKE $${paramIdx} OR p.item_name ILIKE $${paramIdx})`);
|
||||
params.push(`%${options.itemCode}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
p.id, p.company_code, p.plan_no, p.plan_date,
|
||||
p.item_code, p.item_name, p.product_type,
|
||||
p.plan_qty, p.completed_qty, p.progress_rate,
|
||||
p.start_date, p.end_date, p.due_date,
|
||||
p.equipment_id, p.equipment_code, p.equipment_name,
|
||||
p.status, p.priority, p.work_shift,
|
||||
p.work_order_no, p.manager_name,
|
||||
p.order_no, p.parent_plan_id, p.remarks,
|
||||
p.hourly_capacity, p.daily_capacity, p.lead_time,
|
||||
p.created_date, p.updated_date
|
||||
FROM production_plan_mng p
|
||||
${whereClause}
|
||||
ORDER BY p.start_date ASC, p.item_code ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
logger.info("생산계획 목록 조회", { companyCode, count: result.rowCount });
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// ─── 생산계획 CRUD ───
|
||||
|
||||
export async function getPlanById(companyCode: string, planId: number) {
|
||||
|
|
@ -267,49 +371,81 @@ export async function previewSchedule(
|
|||
const deletedSchedules: any[] = [];
|
||||
const keptSchedules: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (options.recalculate_unstarted) {
|
||||
// 삭제 대상(planned) 상세 조회
|
||||
// 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행
|
||||
if (options.recalculate_unstarted) {
|
||||
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
|
||||
for (const itemCode of uniqueItemCodes) {
|
||||
const deleteResult = await pool.query(
|
||||
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status = 'planned'`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
deletedSchedules.push(...deleteResult.rows);
|
||||
|
||||
// 유지 대상(진행중 등) 상세 조회
|
||||
const keptResult = await pool.query(
|
||||
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status NOT IN ('planned', 'completed', 'cancelled')`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
keptSchedules.push(...keptResult.rows);
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const dailyCapacity = item.daily_capacity || 800;
|
||||
const requiredQty = item.required_qty;
|
||||
const itemLeadTime = item.lead_time || 0;
|
||||
|
||||
let requiredQty = item.required_qty;
|
||||
|
||||
// recalculate_unstarted 시, 삭제된 수량을 비율로 분배
|
||||
if (options.recalculate_unstarted) {
|
||||
const deletedQtyForItem = deletedSchedules
|
||||
.filter((d: any) => d.item_code === item.item_code)
|
||||
.reduce((sum: number, d: any) => sum + (parseFloat(d.plan_qty) || 0), 0);
|
||||
if (deletedQtyForItem > 0) {
|
||||
const totalRequestedForItem = items
|
||||
.filter((i) => i.item_code === item.item_code)
|
||||
.reduce((sum, i) => sum + i.required_qty, 0);
|
||||
if (totalRequestedForItem > 0) {
|
||||
requiredQty += Math.round(deletedQtyForItem * (item.required_qty / totalRequestedForItem));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
||||
|
||||
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
|
||||
const dueDate = new Date(item.earliest_due_date);
|
||||
const endDate = new Date(dueDate);
|
||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - productionDays);
|
||||
let startDate: Date;
|
||||
let endDate: Date;
|
||||
|
||||
if (itemLeadTime > 0) {
|
||||
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
|
||||
endDate = new Date(dueDate);
|
||||
startDate = new Date(dueDate);
|
||||
startDate.setDate(startDate.getDate() - itemLeadTime);
|
||||
} else {
|
||||
// 리드타임이 없으면 기존 로직 (생산능력 기반)
|
||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
||||
endDate = new Date(dueDate);
|
||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
||||
startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - productionDays);
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (startDate < today) {
|
||||
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
startDate.setTime(today.getTime());
|
||||
endDate.setTime(startDate.getTime());
|
||||
endDate.setDate(endDate.getDate() + productionDays);
|
||||
endDate.setDate(endDate.getDate() + duration);
|
||||
}
|
||||
|
||||
// 해당 품목의 수주 건수 확인
|
||||
|
|
@ -326,10 +462,11 @@ export async function previewSchedule(
|
|||
required_qty: requiredQty,
|
||||
daily_capacity: dailyCapacity,
|
||||
hourly_capacity: item.hourly_capacity || 100,
|
||||
production_days: productionDays,
|
||||
production_days: itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity),
|
||||
start_date: startDate.toISOString().split("T")[0],
|
||||
end_date: endDate.toISOString().split("T")[0],
|
||||
due_date: item.earliest_due_date,
|
||||
lead_time: itemLeadTime,
|
||||
order_count: orderCount,
|
||||
status: "planned",
|
||||
});
|
||||
|
|
@ -343,7 +480,7 @@ export async function previewSchedule(
|
|||
};
|
||||
|
||||
logger.info("자동 스케줄 미리보기", { companyCode, summary });
|
||||
return { summary, previews, deletedSchedules, keptSchedules };
|
||||
return { summary, schedules: previews, deletedSchedules, keptSchedules };
|
||||
}
|
||||
|
||||
export async function generateSchedule(
|
||||
|
|
@ -363,10 +500,22 @@ export async function generateSchedule(
|
|||
let deletedCount = 0;
|
||||
let keptCount = 0;
|
||||
const newSchedules: any[] = [];
|
||||
const deletedQtyByItem = new Map<string, number>();
|
||||
|
||||
// 같은 item_code에 대한 삭제는 한 번만 수행
|
||||
if (options.recalculate_unstarted) {
|
||||
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
|
||||
for (const itemCode of uniqueItemCodes) {
|
||||
const deletedQtyResult = await client.query(
|
||||
`SELECT COALESCE(SUM(COALESCE(plan_qty::numeric, 0)), 0) AS deleted_qty
|
||||
FROM production_plan_mng
|
||||
WHERE company_code = $1 AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status = 'planned'`,
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
deletedQtyByItem.set(itemCode, parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0);
|
||||
|
||||
for (const item of items) {
|
||||
// 기존 미진행(planned) 스케줄 처리
|
||||
if (options.recalculate_unstarted) {
|
||||
const deleteResult = await client.query(
|
||||
`DELETE FROM production_plan_mng
|
||||
WHERE company_code = $1
|
||||
|
|
@ -374,7 +523,7 @@ export async function generateSchedule(
|
|||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status = 'planned'
|
||||
RETURNING id`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
deletedCount += deleteResult.rowCount || 0;
|
||||
|
||||
|
|
@ -384,32 +533,58 @@ export async function generateSchedule(
|
|||
AND item_code = $2
|
||||
AND COALESCE(product_type, '완제품') = $3
|
||||
AND status NOT IN ('planned', 'completed', 'cancelled')`,
|
||||
[companyCode, item.item_code, productType]
|
||||
[companyCode, itemCode, productType]
|
||||
);
|
||||
keptCount += parseInt(keptResult.rows[0].cnt, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// 생산일수 계산
|
||||
for (const item of items) {
|
||||
// 필요 수량 계산 (삭제된 planned 수량을 비율로 분배)
|
||||
const dailyCapacity = item.daily_capacity || 800;
|
||||
const requiredQty = item.required_qty;
|
||||
const itemLeadTime = item.lead_time || 0;
|
||||
let requiredQty = item.required_qty;
|
||||
|
||||
if (options.recalculate_unstarted) {
|
||||
const deletedQty = deletedQtyByItem.get(item.item_code) || 0;
|
||||
if (deletedQty > 0) {
|
||||
const totalRequestedForItem = items
|
||||
.filter((i) => i.item_code === item.item_code)
|
||||
.reduce((sum, i) => sum + i.required_qty, 0);
|
||||
if (totalRequestedForItem > 0) {
|
||||
requiredQty += Math.round(deletedQty * (item.required_qty / totalRequestedForItem));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
||||
|
||||
// 시작일 = 납기일 - 생산일수 - 안전리드타임
|
||||
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
|
||||
const dueDate = new Date(item.earliest_due_date);
|
||||
const endDate = new Date(dueDate);
|
||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - productionDays);
|
||||
let startDate: Date;
|
||||
let endDate: Date;
|
||||
|
||||
if (itemLeadTime > 0) {
|
||||
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
|
||||
endDate = new Date(dueDate);
|
||||
startDate = new Date(dueDate);
|
||||
startDate.setDate(startDate.getDate() - itemLeadTime);
|
||||
} else {
|
||||
// 리드타임이 없으면 기존 로직 (생산능력 기반)
|
||||
const productionDays = Math.ceil(requiredQty / dailyCapacity);
|
||||
endDate = new Date(dueDate);
|
||||
endDate.setDate(endDate.getDate() - safetyLeadTime);
|
||||
startDate = new Date(endDate);
|
||||
startDate.setDate(startDate.getDate() - productionDays);
|
||||
}
|
||||
|
||||
// 시작일이 오늘보다 이전이면 오늘로 조정
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (startDate < today) {
|
||||
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
startDate.setTime(today.getTime());
|
||||
endDate.setTime(startDate.getTime());
|
||||
endDate.setDate(endDate.getDate() + productionDays);
|
||||
endDate.setDate(endDate.getDate() + duration);
|
||||
}
|
||||
|
||||
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
|
||||
|
|
@ -576,13 +751,24 @@ async function getBomChildItems(
|
|||
companyCode: string,
|
||||
itemCode: string
|
||||
) {
|
||||
// item_info에 lead_time 컬럼 존재 여부 확인
|
||||
const colCheck = await client.query(`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'item_info' AND column_name = 'lead_time'
|
||||
) AS has_lead_time
|
||||
`);
|
||||
const hasLeadTime = colCheck.rows[0]?.has_lead_time === true;
|
||||
const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time::int, 0)" : "0";
|
||||
|
||||
const bomQuery = `
|
||||
SELECT
|
||||
bd.child_item_id,
|
||||
ii.item_name AS child_item_name,
|
||||
ii.item_number AS child_item_code,
|
||||
bd.quantity AS bom_qty,
|
||||
bd.unit
|
||||
bd.unit,
|
||||
${leadTimeCol} AS child_lead_time
|
||||
FROM bom b
|
||||
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
|
||||
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
|
||||
|
|
@ -641,9 +827,12 @@ export async function previewSemiSchedule(
|
|||
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
|
||||
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
|
||||
const semiDueDate = plan.start_date;
|
||||
const semiEndDate = new Date(plan.start_date);
|
||||
const semiStartDate = new Date(plan.start_date);
|
||||
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
|
||||
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
|
||||
|
||||
previews.push({
|
||||
parent_plan_id: plan.id,
|
||||
|
|
@ -653,13 +842,14 @@ export async function previewSemiSchedule(
|
|||
item_name: bomItem.child_item_name || bomItem.child_item_id,
|
||||
plan_qty: requiredQty,
|
||||
bom_qty: parseFloat(bomItem.bom_qty) || 1,
|
||||
lead_time: childLeadTime,
|
||||
start_date: semiStartDate.toISOString().split("T")[0],
|
||||
end_date: typeof semiDueDate === "string"
|
||||
? semiDueDate.split("T")[0]
|
||||
: new Date(semiDueDate).toISOString().split("T")[0],
|
||||
: semiEndDate.toISOString().split("T")[0],
|
||||
due_date: typeof semiDueDate === "string"
|
||||
? semiDueDate.split("T")[0]
|
||||
: new Date(semiDueDate).toISOString().split("T")[0],
|
||||
: semiEndDate.toISOString().split("T")[0],
|
||||
product_type: "반제품",
|
||||
status: "planned",
|
||||
});
|
||||
|
|
@ -683,7 +873,7 @@ export async function previewSemiSchedule(
|
|||
parent_count: plansResult.rowCount,
|
||||
};
|
||||
|
||||
return { summary, previews, deletedSchedules, keptSchedules };
|
||||
return { summary, schedules: previews, deletedSchedules, keptSchedules };
|
||||
}
|
||||
|
||||
// ─── 반제품 계획 자동 생성 ───
|
||||
|
|
@ -740,10 +930,12 @@ export async function generateSemiSchedule(
|
|||
|
||||
if (requiredQty <= 0) continue;
|
||||
|
||||
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
|
||||
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
|
||||
const semiDueDate = plan.start_date;
|
||||
const semiEndDate = plan.start_date;
|
||||
const semiStartDate = new Date(plan.start_date);
|
||||
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
|
||||
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
|
||||
|
||||
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
|
||||
const planNoResult = await client.query(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { query } from "../database/db";
|
||||
import { query, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
|
|
@ -145,10 +145,19 @@ export class RoleService {
|
|||
writer: string;
|
||||
}): Promise<RoleGroup> {
|
||||
try {
|
||||
// 동일 회사 내 같은 이름의 권한 그룹 중복 체크
|
||||
const dupCheck = await query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count FROM authority_master WHERE company_code = $1 AND auth_name = $2`,
|
||||
[data.companyCode, data.authName]
|
||||
);
|
||||
if (dupCheck.length > 0 && parseInt(dupCheck[0].count, 10) > 0) {
|
||||
throw new Error(`동일 회사 내에 이미 같은 이름의 권한 그룹이 존재합니다: ${data.authName}`);
|
||||
}
|
||||
|
||||
const sql = `
|
||||
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
|
||||
VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW())
|
||||
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
|
||||
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
|
||||
company_code AS "companyCode", status, writer, regdate
|
||||
`;
|
||||
|
||||
|
|
@ -460,35 +469,37 @@ export class RoleService {
|
|||
writer: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// 기존 권한 삭제
|
||||
await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
|
||||
authObjid,
|
||||
]);
|
||||
|
||||
// 새로운 권한 삽입
|
||||
if (permissions.length > 0) {
|
||||
const values = permissions
|
||||
.map(
|
||||
(_, index) =>
|
||||
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
const params = permissions.flatMap((p) => [
|
||||
p.menuObjid,
|
||||
p.createYn,
|
||||
p.readYn,
|
||||
p.updateYn,
|
||||
p.deleteYn,
|
||||
await transaction(async (client) => {
|
||||
// 기존 권한 삭제
|
||||
await client.query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
|
||||
authObjid,
|
||||
]);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
|
||||
VALUES ${values}
|
||||
`;
|
||||
// 새로운 권한 삽입
|
||||
if (permissions.length > 0) {
|
||||
const values = permissions
|
||||
.map(
|
||||
(_, index) =>
|
||||
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
|
||||
)
|
||||
.join(", ");
|
||||
|
||||
await query(sql, [authObjid, ...params, writer]);
|
||||
}
|
||||
const params = permissions.flatMap((p) => [
|
||||
p.menuObjid,
|
||||
p.createYn,
|
||||
p.readYn,
|
||||
p.updateYn,
|
||||
p.deleteYn,
|
||||
]);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
|
||||
VALUES ${values}
|
||||
`;
|
||||
|
||||
await client.query(sql, [authObjid, ...params, writer]);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info("메뉴 권한 설정 성공", {
|
||||
authObjid,
|
||||
|
|
|
|||
|
|
@ -211,7 +211,8 @@ class TableCategoryValueService {
|
|||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
created_by AS "createdBy",
|
||||
updated_by AS "updatedBy"
|
||||
updated_by AS "updatedBy",
|
||||
path
|
||||
FROM category_values
|
||||
WHERE table_name = $1
|
||||
AND column_name = $2
|
||||
|
|
@ -1441,7 +1442,7 @@ class TableCategoryValueService {
|
|||
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
|
||||
if (companyCode === "*") {
|
||||
query = `
|
||||
SELECT DISTINCT value_code, value_label
|
||||
SELECT DISTINCT value_code, value_label, path
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
`;
|
||||
|
|
@ -1449,7 +1450,7 @@ class TableCategoryValueService {
|
|||
} else {
|
||||
const companyIdx = n + 1;
|
||||
query = `
|
||||
SELECT DISTINCT value_code, value_label
|
||||
SELECT DISTINCT value_code, value_label, path
|
||||
FROM category_values
|
||||
WHERE value_code IN (${placeholders1})
|
||||
AND (company_code = $${companyIdx} OR company_code = '*')
|
||||
|
|
@ -1460,10 +1461,15 @@ class TableCategoryValueService {
|
|||
const result = await pool.query(query, params);
|
||||
|
||||
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
|
||||
// path가 있고 '/'를 포함하면(depth>1) 전체 경로를 ' > ' 구분자로 표시
|
||||
const labels: Record<string, string> = {};
|
||||
for (const row of result.rows) {
|
||||
if (!labels[row.value_code]) {
|
||||
labels[row.value_code] = row.value_label;
|
||||
if (row.path && row.path.includes('/')) {
|
||||
labels[row.value_code] = row.path.replace(/\//g, ' > ');
|
||||
} else {
|
||||
labels[row.value_code] = row.value_label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1575,7 +1575,7 @@ export class TableManagementService {
|
|||
switch (operator) {
|
||||
case "equals":
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
|
||||
values: [actualValue],
|
||||
paramCount: 1,
|
||||
};
|
||||
|
|
@ -1859,10 +1859,10 @@ export class TableManagementService {
|
|||
};
|
||||
}
|
||||
|
||||
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
|
||||
// select 필터(equals)인 경우 — 다중 값(콤마 구분) 지원
|
||||
if (operator === "equals") {
|
||||
return {
|
||||
whereClause: `${columnName}::text = $${paramIndex}`,
|
||||
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
|
||||
values: [String(value)],
|
||||
paramCount: 1,
|
||||
};
|
||||
|
|
@ -2717,6 +2717,43 @@ export class TableManagementService {
|
|||
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
|
||||
}
|
||||
|
||||
// entity 컬럼의 display_column 자동 채우기 (예: supplier_code → supplier_name)
|
||||
try {
|
||||
const companyCode = data.company_code || "*";
|
||||
const entityColsResult = await query<any>(
|
||||
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'entity'
|
||||
AND reference_table IS NOT NULL AND reference_table != ''
|
||||
AND display_column IS NOT NULL AND display_column != ''
|
||||
AND company_code IN ($2, '*')
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
for (const ec of entityColsResult) {
|
||||
const srcVal = data[ec.column_name];
|
||||
const displayCol = ec.display_column;
|
||||
// display_column이 테이블에 존재하고, 값이 비어있거나 없으면 자동 조회
|
||||
if (srcVal && columnTypeMap.has(displayCol) && (!data[displayCol] || data[displayCol] === "")) {
|
||||
try {
|
||||
const refResult = await query<any>(
|
||||
`SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`,
|
||||
[srcVal, companyCode]
|
||||
);
|
||||
if (refResult.length > 0 && refResult[0][displayCol]) {
|
||||
data[displayCol] = refResult[0][displayCol];
|
||||
logger.info(`Entity auto-fill: ${tableName}.${displayCol} = ${data[displayCol]} (from ${ec.reference_table})`);
|
||||
}
|
||||
} catch (refErr: any) {
|
||||
logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (entityErr: any) {
|
||||
logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`);
|
||||
}
|
||||
|
||||
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
|
||||
const skippedColumns: string[] = [];
|
||||
const existingColumns = Object.keys(data).filter((col) => {
|
||||
|
|
@ -2868,6 +2905,42 @@ export class TableManagementService {
|
|||
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
|
||||
}
|
||||
|
||||
// entity 컬럼의 display_column 자동 채우기 (수정 시)
|
||||
try {
|
||||
const companyCode = updatedData.company_code || originalData.company_code || "*";
|
||||
const entityColsResult = await query<any>(
|
||||
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column
|
||||
FROM table_type_columns
|
||||
WHERE table_name = $1 AND input_type = 'entity'
|
||||
AND reference_table IS NOT NULL AND reference_table != ''
|
||||
AND display_column IS NOT NULL AND display_column != ''
|
||||
AND company_code IN ($2, '*')
|
||||
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
|
||||
[tableName, companyCode]
|
||||
);
|
||||
|
||||
for (const ec of entityColsResult) {
|
||||
const srcVal = updatedData[ec.column_name];
|
||||
const displayCol = ec.display_column;
|
||||
if (srcVal && columnTypeMap.has(displayCol) && (!updatedData[displayCol] || updatedData[displayCol] === "")) {
|
||||
try {
|
||||
const refResult = await query<any>(
|
||||
`SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`,
|
||||
[srcVal, companyCode]
|
||||
);
|
||||
if (refResult.length > 0 && refResult[0][displayCol]) {
|
||||
updatedData[displayCol] = refResult[0][displayCol];
|
||||
logger.info(`Entity auto-fill (edit): ${tableName}.${displayCol} = ${updatedData[displayCol]}`);
|
||||
}
|
||||
} catch (refErr: any) {
|
||||
logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (entityErr: any) {
|
||||
logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`);
|
||||
}
|
||||
|
||||
// SET 절 생성 (수정할 데이터) - 먼저 생성
|
||||
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
|
||||
const setConditions: string[] = [];
|
||||
|
|
@ -3357,16 +3430,20 @@ export class TableManagementService {
|
|||
const safeColumn = `main."${columnName}"`;
|
||||
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
case "equals": {
|
||||
const safeVal = String(value).replace(/'/g, "''");
|
||||
filterConditions.push(
|
||||
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
|
||||
`('${safeVal}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal}')`
|
||||
);
|
||||
break;
|
||||
case "not_equals":
|
||||
}
|
||||
case "not_equals": {
|
||||
const safeVal2 = String(value).replace(/'/g, "''");
|
||||
filterConditions.push(
|
||||
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
|
||||
`NOT ('${safeVal2}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal2}')`
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "in": {
|
||||
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
|
||||
if (inArr.length > 0) {
|
||||
|
|
@ -3408,6 +3485,31 @@ export class TableManagementService {
|
|||
case "is_not_null":
|
||||
filterConditions.push(`${safeColumn} IS NOT NULL`);
|
||||
break;
|
||||
case "not_contains":
|
||||
filterConditions.push(
|
||||
`${safeColumn}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`
|
||||
);
|
||||
break;
|
||||
case "greater_than":
|
||||
filterConditions.push(
|
||||
`(${safeColumn})::numeric > ${parseFloat(String(value))}`
|
||||
);
|
||||
break;
|
||||
case "less_than":
|
||||
filterConditions.push(
|
||||
`(${safeColumn})::numeric < ${parseFloat(String(value))}`
|
||||
);
|
||||
break;
|
||||
case "greater_or_equal":
|
||||
filterConditions.push(
|
||||
`(${safeColumn})::numeric >= ${parseFloat(String(value))}`
|
||||
);
|
||||
break;
|
||||
case "less_or_equal":
|
||||
filterConditions.push(
|
||||
`(${safeColumn})::numeric <= ${parseFloat(String(value))}`
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3424,6 +3526,89 @@ export class TableManagementService {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 filterGroups 처리 (런타임 필터 빌더 - 그룹별 AND/OR 지원)
|
||||
if (
|
||||
options.dataFilter &&
|
||||
options.dataFilter.filterGroups &&
|
||||
options.dataFilter.filterGroups.length > 0
|
||||
) {
|
||||
const groupConditions: string[] = [];
|
||||
|
||||
for (const group of options.dataFilter.filterGroups) {
|
||||
if (!group.conditions || group.conditions.length === 0) continue;
|
||||
|
||||
const conditions: string[] = [];
|
||||
|
||||
for (const condition of group.conditions) {
|
||||
const { columnName, operator, value } = condition;
|
||||
if (!columnName) continue;
|
||||
|
||||
const safeCol = `main."${columnName}"`;
|
||||
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
conditions.push(`${safeCol}::text = '${String(value).replace(/'/g, "''")}'`);
|
||||
break;
|
||||
case "not_equals":
|
||||
conditions.push(`${safeCol}::text != '${String(value).replace(/'/g, "''")}'`);
|
||||
break;
|
||||
case "contains":
|
||||
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}%'`);
|
||||
break;
|
||||
case "not_contains":
|
||||
conditions.push(`${safeCol}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`);
|
||||
break;
|
||||
case "starts_with":
|
||||
conditions.push(`${safeCol}::text LIKE '${String(value).replace(/'/g, "''")}%'`);
|
||||
break;
|
||||
case "ends_with":
|
||||
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}'`);
|
||||
break;
|
||||
case "greater_than":
|
||||
conditions.push(`(${safeCol})::numeric > ${parseFloat(String(value))}`);
|
||||
break;
|
||||
case "less_than":
|
||||
conditions.push(`(${safeCol})::numeric < ${parseFloat(String(value))}`);
|
||||
break;
|
||||
case "greater_or_equal":
|
||||
conditions.push(`(${safeCol})::numeric >= ${parseFloat(String(value))}`);
|
||||
break;
|
||||
case "less_or_equal":
|
||||
conditions.push(`(${safeCol})::numeric <= ${parseFloat(String(value))}`);
|
||||
break;
|
||||
case "is_null":
|
||||
conditions.push(`(${safeCol} IS NULL OR ${safeCol}::text = '')`);
|
||||
break;
|
||||
case "is_not_null":
|
||||
conditions.push(`(${safeCol} IS NOT NULL AND ${safeCol}::text != '')`);
|
||||
break;
|
||||
case "in": {
|
||||
const inArr = Array.isArray(value) ? value : [String(value)];
|
||||
if (inArr.length > 0) {
|
||||
const vals = inArr.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(", ");
|
||||
conditions.push(`${safeCol}::text IN (${vals})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
const logic = group.logic === "OR" ? " OR " : " AND ";
|
||||
groupConditions.push(`(${conditions.join(logic)})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupConditions.length > 0) {
|
||||
const groupWhere = groupConditions.join(" AND ");
|
||||
whereClause = whereClause
|
||||
? `${whereClause} AND ${groupWhere}`
|
||||
: groupWhere;
|
||||
|
||||
logger.info(`🔍 필터 그룹 적용 (Entity 조인): ${groupWhere}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
|
||||
if (options.excludeFilter && options.excludeFilter.enabled) {
|
||||
const {
|
||||
|
|
@ -5387,4 +5572,40 @@ export class TableManagementService {
|
|||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼의 고유값 목록 조회 (헤더 필터 드롭다운용)
|
||||
*/
|
||||
async getColumnDistinctValues(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
companyCode?: string
|
||||
): Promise<{ value: string; label: string }[]> {
|
||||
try {
|
||||
// 테이블명/컬럼명 안전성 검증 (영문, 숫자, 언더스코어만 허용)
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) {
|
||||
logger.warn(`잘못된 테이블/컬럼명: ${tableName}.${columnName}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
let sql = `SELECT DISTINCT "${columnName}"::text as value FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND "${columnName}"::text != ''`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (companyCode) {
|
||||
params.push(companyCode);
|
||||
sql += ` AND "company_code" = $${params.length}`;
|
||||
}
|
||||
|
||||
sql += ` ORDER BY value LIMIT 500`;
|
||||
|
||||
const rows = await query<{ value: string }>(sql, params);
|
||||
return rows.map((row) => ({
|
||||
value: row.value,
|
||||
label: row.value,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error(`컬럼 고유값 조회 실패: ${tableName}.${columnName}`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
// JWT 토큰 무효화 서비스
|
||||
// user_info.token_version 기반으로 기존 JWT 토큰을 무효화
|
||||
|
||||
import { query } from "../database/db";
|
||||
import { cache } from "../utils/cache";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const TOKEN_VERSION_CACHE_TTL = 2 * 60 * 1000; // 2분 캐시
|
||||
|
||||
export class TokenInvalidationService {
|
||||
/**
|
||||
* 캐시 키 생성
|
||||
*/
|
||||
static cacheKey(userId: string): string {
|
||||
return `token_version:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 사용자의 토큰 무효화 (token_version +1)
|
||||
*/
|
||||
static async invalidateUserTokens(userId: string): Promise<void> {
|
||||
try {
|
||||
await query(
|
||||
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
cache.delete(this.cacheKey(userId));
|
||||
logger.info(`토큰 무효화: ${userId}`);
|
||||
} catch (error) {
|
||||
logger.error(`토큰 무효화 실패: ${userId}`, { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 사용자의 토큰 일괄 무효화
|
||||
*/
|
||||
static async invalidateMultipleUserTokens(userIds: string[]): Promise<void> {
|
||||
if (userIds.length === 0) return;
|
||||
try {
|
||||
const placeholders = userIds.map((_, i) => `$${i + 1}`).join(", ");
|
||||
await query(
|
||||
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id IN (${placeholders})`,
|
||||
userIds
|
||||
);
|
||||
userIds.forEach((id) => cache.delete(this.cacheKey(id)));
|
||||
logger.info(`토큰 일괄 무효화: ${userIds.length}명`);
|
||||
} catch (error) {
|
||||
logger.error(`토큰 일괄 무효화 실패`, { error, userIds });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 token_version 조회 (캐시 사용)
|
||||
*/
|
||||
static async getUserTokenVersion(userId: string): Promise<number> {
|
||||
const cacheKey = this.cacheKey(userId);
|
||||
const cached = cache.get<number>(cacheKey);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await query<{ token_version: number | null }>(
|
||||
`SELECT token_version FROM user_info WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
const version = result.length > 0 ? (result[0].token_version ?? 0) : 0;
|
||||
cache.set(cacheKey, version, TOKEN_VERSION_CACHE_TTL);
|
||||
return version;
|
||||
} catch (error) {
|
||||
logger.error(`token_version 조회 실패: ${userId}`, { error });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -64,6 +64,7 @@ export interface PersonBean {
|
|||
companyName?: string; // 회사명 추가
|
||||
photo?: string;
|
||||
locale?: string;
|
||||
tokenVersion?: number; // JWT 토큰 무효화용 버전
|
||||
// 권한 레벨 정보 (3단계 체계)
|
||||
isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN')
|
||||
isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN')
|
||||
|
|
@ -98,6 +99,7 @@ export interface JwtPayload {
|
|||
companyName?: string; // 회사명 추가
|
||||
userType?: string;
|
||||
userTypeName?: string;
|
||||
tokenVersion?: number; // JWT 토큰 무효화용 버전
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
aud?: string;
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ export interface GetLangKeysParams {
|
|||
includeOverrides?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
userCompanyCode?: string; // 요청 사용자의 회사 코드 (비관리자 필터링용)
|
||||
}
|
||||
|
||||
export interface GetUserTextParams {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export class JwtUtils {
|
|||
companyName: userInfo.companyName, // 회사명 추가
|
||||
userType: userInfo.userType,
|
||||
userTypeName: userInfo.userTypeName,
|
||||
tokenVersion: userInfo.tokenVersion ?? 0,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, config.jwt.secret, {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":110167}
|
||||
{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":196548}
|
||||
{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":253997}
|
||||
{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":339528}
|
||||
{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":380641}
|
||||
{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":413980}
|
||||
{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":401646}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":59735}
|
||||
{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":93607}
|
||||
{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":136249}
|
||||
{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":261624}
|
||||
{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_start","parent_mode":"none"}
|
||||
{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":139427}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"lastSentAt": "2026-03-25T05:06:13.529Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"tool_name": "Bash",
|
||||
"tool_input_preview": "{\"command\":\"wc -l /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx\",\"description\":\"Get total line count of the file\"}",
|
||||
"error": "Exit code 1\n(eval):1: no matches found: /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx",
|
||||
"timestamp": "2026-03-25T05:00:38.410Z",
|
||||
"retry_count": 1
|
||||
}
|
||||
|
|
@ -0,0 +1,281 @@
|
|||
{
|
||||
"updatedAt": "2026-03-25T05:06:35.487Z",
|
||||
"missions": [
|
||||
{
|
||||
"id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none",
|
||||
"source": "session",
|
||||
"name": "none",
|
||||
"objective": "Session mission",
|
||||
"createdAt": "2026-03-25T00:33:45.197Z",
|
||||
"updatedAt": "2026-03-25T01:37:19.659Z",
|
||||
"status": "done",
|
||||
"workerCount": 5,
|
||||
"taskCounts": {
|
||||
"total": 5,
|
||||
"pending": 0,
|
||||
"blocked": 0,
|
||||
"inProgress": 0,
|
||||
"completed": 5,
|
||||
"failed": 0
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"name": "Explore:ad233db",
|
||||
"role": "Explore",
|
||||
"ownership": "ad233db7fa6f059dd",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T00:34:44.932Z"
|
||||
},
|
||||
{
|
||||
"name": "Explore:a31a0f7",
|
||||
"role": "Explore",
|
||||
"ownership": "a31a0f729d328643f",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T00:35:24.588Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a9510b7",
|
||||
"role": "executor",
|
||||
"ownership": "a9510b7d8ec5a1ce7",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T00:42:01.730Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a1c1d18",
|
||||
"role": "executor",
|
||||
"ownership": "a1c1d186f0eb6dfc1",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T00:40:12.608Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a9a231d",
|
||||
"role": "executor",
|
||||
"ownership": "a9a231d40fd5a150b",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T01:37:19.659Z"
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"id": "session-stop:a1c1d186f0eb6dfc1:2026-03-25T00:40:12.608Z",
|
||||
"at": "2026-03-25T00:40:12.608Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a1c1d18",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a1c1d186f0eb6dfc1"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a9510b7d8ec5a1ce7:2026-03-25T00:42:01.730Z",
|
||||
"at": "2026-03-25T00:42:01.730Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a9510b7",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a9510b7d8ec5a1ce7"
|
||||
},
|
||||
{
|
||||
"id": "session-start:a9a231d40fd5a150b:2026-03-25T01:35:00.232Z",
|
||||
"at": "2026-03-25T01:35:00.232Z",
|
||||
"kind": "update",
|
||||
"agent": "executor:a9a231d",
|
||||
"detail": "started executor:a9a231d",
|
||||
"sourceKey": "session-start:a9a231d40fd5a150b"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a9a231d40fd5a150b:2026-03-25T01:37:19.659Z",
|
||||
"at": "2026-03-25T01:37:19.659Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a9a231d",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a9a231d40fd5a150b"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "session:037169c7-72ba-4843-8e9a-417ca1423715:none",
|
||||
"source": "session",
|
||||
"name": "none",
|
||||
"objective": "Session mission",
|
||||
"createdAt": "2026-03-25T04:59:24.101Z",
|
||||
"updatedAt": "2026-03-25T05:06:35.487Z",
|
||||
"status": "done",
|
||||
"workerCount": 7,
|
||||
"taskCounts": {
|
||||
"total": 7,
|
||||
"pending": 0,
|
||||
"blocked": 0,
|
||||
"inProgress": 0,
|
||||
"completed": 7,
|
||||
"failed": 0
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"name": "executor:a32b34c",
|
||||
"role": "executor",
|
||||
"ownership": "a32b34c341b854da5",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:06:18.081Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:ad2c89c",
|
||||
"role": "executor",
|
||||
"ownership": "ad2c89cf14936ea42",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:02:45.524Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a2c140c",
|
||||
"role": "executor",
|
||||
"ownership": "a2c140c5a5adb0719",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:05:13.388Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a2e5213",
|
||||
"role": "executor",
|
||||
"ownership": "a2e52136ea8f04385",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:03:53.163Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a3735bf",
|
||||
"role": "executor",
|
||||
"ownership": "a3735bf51a74d6fc8",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:01:33.817Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a77742b",
|
||||
"role": "executor",
|
||||
"ownership": "a77742ba65fd2451c",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:06:09.324Z"
|
||||
},
|
||||
{
|
||||
"name": "executor:a4eb932",
|
||||
"role": "executor",
|
||||
"ownership": "a4eb932c438b898c0",
|
||||
"status": "done",
|
||||
"currentStep": null,
|
||||
"latestUpdate": "completed",
|
||||
"completedSummary": null,
|
||||
"updatedAt": "2026-03-25T05:06:35.487Z"
|
||||
}
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"id": "session-start:a3735bf51a74d6fc8:2026-03-25T04:59:43.650Z",
|
||||
"at": "2026-03-25T04:59:43.650Z",
|
||||
"kind": "update",
|
||||
"agent": "executor:a3735bf",
|
||||
"detail": "started executor:a3735bf",
|
||||
"sourceKey": "session-start:a3735bf51a74d6fc8"
|
||||
},
|
||||
{
|
||||
"id": "session-start:a77742ba65fd2451c:2026-03-25T04:59:48.683Z",
|
||||
"at": "2026-03-25T04:59:48.683Z",
|
||||
"kind": "update",
|
||||
"agent": "executor:a77742b",
|
||||
"detail": "started executor:a77742b",
|
||||
"sourceKey": "session-start:a77742ba65fd2451c"
|
||||
},
|
||||
{
|
||||
"id": "session-start:a4eb932c438b898c0:2026-03-25T04:59:53.841Z",
|
||||
"at": "2026-03-25T04:59:53.841Z",
|
||||
"kind": "update",
|
||||
"agent": "executor:a4eb932",
|
||||
"detail": "started executor:a4eb932",
|
||||
"sourceKey": "session-start:a4eb932c438b898c0"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a3735bf51a74d6fc8:2026-03-25T05:01:33.817Z",
|
||||
"at": "2026-03-25T05:01:33.817Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a3735bf",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a3735bf51a74d6fc8"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:ad2c89cf14936ea42:2026-03-25T05:02:45.524Z",
|
||||
"at": "2026-03-25T05:02:45.524Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:ad2c89c",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:ad2c89cf14936ea42"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a2e52136ea8f04385:2026-03-25T05:03:53.163Z",
|
||||
"at": "2026-03-25T05:03:53.163Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a2e5213",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a2e52136ea8f04385"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a2c140c5a5adb0719:2026-03-25T05:05:13.388Z",
|
||||
"at": "2026-03-25T05:05:13.388Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a2c140c",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a2c140c5a5adb0719"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a77742ba65fd2451c:2026-03-25T05:06:09.324Z",
|
||||
"at": "2026-03-25T05:06:09.324Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a77742b",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a77742ba65fd2451c"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a32b34c341b854da5:2026-03-25T05:06:18.081Z",
|
||||
"at": "2026-03-25T05:06:18.081Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a32b34c",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a32b34c341b854da5"
|
||||
},
|
||||
{
|
||||
"id": "session-stop:a4eb932c438b898c0:2026-03-25T05:06:35.487Z",
|
||||
"at": "2026-03-25T05:06:35.487Z",
|
||||
"kind": "completion",
|
||||
"agent": "executor:a4eb932",
|
||||
"detail": "completed",
|
||||
"sourceKey": "session-stop:a4eb932c438b898c0"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
{
|
||||
"agents": [
|
||||
{
|
||||
"agent_id": "ad233db7fa6f059dd",
|
||||
"agent_type": "Explore",
|
||||
"started_at": "2026-03-25T00:33:45.197Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T00:34:44.932Z",
|
||||
"duration_ms": 59735
|
||||
},
|
||||
{
|
||||
"agent_id": "a31a0f729d328643f",
|
||||
"agent_type": "Explore",
|
||||
"started_at": "2026-03-25T00:33:50.981Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T00:35:24.588Z",
|
||||
"duration_ms": 93607
|
||||
},
|
||||
{
|
||||
"agent_id": "a9510b7d8ec5a1ce7",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T00:37:40.106Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T00:42:01.730Z",
|
||||
"duration_ms": 261624
|
||||
},
|
||||
{
|
||||
"agent_id": "a1c1d186f0eb6dfc1",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T00:37:56.359Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T00:40:12.608Z",
|
||||
"duration_ms": 136249
|
||||
},
|
||||
{
|
||||
"agent_id": "a9a231d40fd5a150b",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T01:35:00.232Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T01:37:19.659Z",
|
||||
"duration_ms": 139427
|
||||
},
|
||||
{
|
||||
"agent_id": "a32b34c341b854da5",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:24.101Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:06:18.081Z",
|
||||
"duration_ms": 413980
|
||||
},
|
||||
{
|
||||
"agent_id": "ad2c89cf14936ea42",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:28.976Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:02:45.524Z",
|
||||
"duration_ms": 196548
|
||||
},
|
||||
{
|
||||
"agent_id": "a2c140c5a5adb0719",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:33.860Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:05:13.388Z",
|
||||
"duration_ms": 339528
|
||||
},
|
||||
{
|
||||
"agent_id": "a2e52136ea8f04385",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:39.166Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:03:53.163Z",
|
||||
"duration_ms": 253997
|
||||
},
|
||||
{
|
||||
"agent_id": "a3735bf51a74d6fc8",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:43.650Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:01:33.817Z",
|
||||
"duration_ms": 110167
|
||||
},
|
||||
{
|
||||
"agent_id": "a77742ba65fd2451c",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:48.683Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:06:09.324Z",
|
||||
"duration_ms": 380641
|
||||
},
|
||||
{
|
||||
"agent_id": "a4eb932c438b898c0",
|
||||
"agent_type": "oh-my-claudecode:executor",
|
||||
"started_at": "2026-03-25T04:59:53.841Z",
|
||||
"parent_mode": "none",
|
||||
"status": "completed",
|
||||
"completed_at": "2026-03-25T05:06:35.487Z",
|
||||
"duration_ms": 401646
|
||||
}
|
||||
],
|
||||
"total_spawned": 12,
|
||||
"total_completed": 12,
|
||||
"total_failed": 0,
|
||||
"last_updated": "2026-03-25T05:06:35.589Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,752 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 설비정보 — 하드코딩 페이지
|
||||
*
|
||||
* 좌측: 설비 목록 (equipment_mng)
|
||||
* 우측: 탭 (기본정보 / 점검항목 / 소모품)
|
||||
* 점검항목 복사 기능 포함
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||
Wrench, ClipboardCheck, Package, Copy, Info, Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
|
||||
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
|
||||
import { ImageUpload } from "@/components/common/ImageUpload";
|
||||
|
||||
const EQUIP_TABLE = "equipment_mng";
|
||||
const INSPECTION_TABLE = "equipment_inspection_item";
|
||||
const CONSUMABLE_TABLE = "equipment_consumable";
|
||||
|
||||
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "equipment_code", label: "설비코드", width: "w-[110px]" },
|
||||
{ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]" },
|
||||
{ key: "equipment_type", label: "설비유형", width: "w-[90px]" },
|
||||
{ key: "manufacturer", label: "제조사", width: "w-[100px]" },
|
||||
{ key: "installation_location", label: "설치장소", width: "w-[100px]" },
|
||||
{ key: "operation_status", label: "가동상태", width: "w-[80px]" },
|
||||
];
|
||||
|
||||
const INSPECTION_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "inspection_item", label: "점검항목", minWidth: "min-w-[120px]", editable: true },
|
||||
{ key: "inspection_cycle", label: "점검주기", width: "w-[80px]" },
|
||||
{ key: "inspection_method", label: "점검방법", width: "w-[80px]" },
|
||||
{ key: "lower_limit", label: "하한치", width: "w-[70px]", editable: true },
|
||||
{ key: "upper_limit", label: "상한치", width: "w-[70px]", editable: true },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
|
||||
{ key: "inspection_content", label: "점검내용", minWidth: "min-w-[150px]", editable: true },
|
||||
];
|
||||
|
||||
const CONSUMABLE_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "image_path", label: "이미지", width: "w-[50px]", renderType: "image", sortable: false, filterable: false },
|
||||
{ key: "consumable_name", label: "소모품명", minWidth: "min-w-[120px]", editable: true },
|
||||
{ key: "replacement_cycle", label: "교체주기", width: "w-[90px]", editable: true },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
|
||||
{ key: "specification", label: "규격", width: "w-[100px]", editable: true },
|
||||
{ key: "manufacturer", label: "제조사", width: "w-[100px]", editable: true },
|
||||
];
|
||||
|
||||
export default function EquipmentInfoPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
// 좌측
|
||||
const [equipments, setEquipments] = useState<any[]>([]);
|
||||
const [equipLoading, setEquipLoading] = useState(false);
|
||||
const [equipCount, setEquipCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
|
||||
|
||||
// 우측 탭
|
||||
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
|
||||
const [inspections, setInspections] = useState<any[]>([]);
|
||||
const [inspectionLoading, setInspectionLoading] = useState(false);
|
||||
const [consumables, setConsumables] = useState<any[]>([]);
|
||||
const [consumableLoading, setConsumableLoading] = useState(false);
|
||||
|
||||
// 카테고리
|
||||
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// 모달
|
||||
const [equipModalOpen, setEquipModalOpen] = useState(false);
|
||||
const [equipEditMode, setEquipEditMode] = useState(false);
|
||||
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 기본정보 탭 편집 폼
|
||||
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
|
||||
const [infoSaving, setInfoSaving] = useState(false);
|
||||
|
||||
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
|
||||
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
|
||||
|
||||
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
|
||||
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
|
||||
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
|
||||
|
||||
// 점검항목 복사
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
const [copySourceEquip, setCopySourceEquip] = useState("");
|
||||
const [copyItems, setCopyItems] = useState<any[]>([]);
|
||||
const [copyChecked, setCopyChecked] = useState<Set<string>>(new Set());
|
||||
const [copyLoading, setCopyLoading] = useState(false);
|
||||
|
||||
// 엑셀
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
|
||||
const [excelDetecting, setExcelDetecting] = useState(false);
|
||||
|
||||
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||
setFilterConfig(settings.filters);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings("equipment-info");
|
||||
if (saved) applyTableSettings(saved);
|
||||
}, []);
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
// equipment_mng 카테고리
|
||||
for (const col of ["equipment_type", "operation_status"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${EQUIP_TABLE}/${col}/values`);
|
||||
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
// inspection 카테고리
|
||||
for (const col of ["inspection_cycle", "inspection_method"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/${col}/values`);
|
||||
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setCatOptions(optMap);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const resolve = (col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
return catOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// 설비 조회
|
||||
const fetchEquipments = useCallback(async () => {
|
||||
setEquipLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setEquipments(raw.map((r: any) => ({
|
||||
...r,
|
||||
equipment_type: resolve("equipment_type", r.equipment_type),
|
||||
operation_status: resolve("operation_status", r.operation_status),
|
||||
})));
|
||||
setEquipCount(res.data?.data?.total || raw.length);
|
||||
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
|
||||
}, [searchFilters, catOptions]);
|
||||
|
||||
useEffect(() => { fetchEquipments(); }, [fetchEquipments]);
|
||||
|
||||
const selectedEquip = equipments.find((e) => e.id === selectedEquipId);
|
||||
|
||||
// 기본정보 탭 폼 초기화 (설비 선택 변경 시)
|
||||
useEffect(() => {
|
||||
if (selectedEquip) setInfoForm({ ...selectedEquip });
|
||||
else setInfoForm({});
|
||||
}, [selectedEquipId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 기본정보 저장
|
||||
const handleInfoSave = async () => {
|
||||
if (!infoForm.id) return;
|
||||
setInfoSaving(true);
|
||||
try {
|
||||
const { id, created_date, updated_date, writer, company_code, ...fields } = infoForm;
|
||||
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
|
||||
toast.success("저장되었습니다.");
|
||||
fetchEquipments();
|
||||
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); }
|
||||
finally { setInfoSaving(false); }
|
||||
};
|
||||
|
||||
// 우측: 점검항목 조회
|
||||
useEffect(() => {
|
||||
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
|
||||
const fetch = async () => {
|
||||
setInspectionLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setInspections(raw.map((r: any) => ({
|
||||
...r,
|
||||
inspection_cycle: resolve("inspection_cycle", r.inspection_cycle),
|
||||
inspection_method: resolve("inspection_method", r.inspection_method),
|
||||
})));
|
||||
} catch { setInspections([]); } finally { setInspectionLoading(false); }
|
||||
};
|
||||
fetch();
|
||||
}, [selectedEquip?.equipment_code, catOptions]);
|
||||
|
||||
// 우측: 소모품 조회
|
||||
useEffect(() => {
|
||||
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
|
||||
const fetch = async () => {
|
||||
setConsumableLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
setConsumables(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setConsumables([]); } finally { setConsumableLoading(false); }
|
||||
};
|
||||
fetch();
|
||||
}, [selectedEquip?.equipment_code]);
|
||||
|
||||
// 새로고침 헬퍼
|
||||
const refreshRight = () => {
|
||||
const eid = selectedEquipId;
|
||||
setSelectedEquipId(null);
|
||||
setTimeout(() => setSelectedEquipId(eid), 50);
|
||||
};
|
||||
|
||||
// 설비 등록/수정
|
||||
const openEquipRegister = () => { setEquipForm({}); setEquipEditMode(false); setEquipModalOpen(true); };
|
||||
const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); };
|
||||
|
||||
const handleEquipSave = async () => {
|
||||
if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const { id, created_date, updated_date, writer, company_code, ...fields } = equipForm;
|
||||
if (equipEditMode && id) {
|
||||
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
|
||||
toast.success("수정되었습니다.");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, fields);
|
||||
toast.success("등록되었습니다.");
|
||||
}
|
||||
setEquipModalOpen(false); fetchEquipments();
|
||||
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleEquipDelete = async () => {
|
||||
if (!selectedEquipId) return;
|
||||
const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] });
|
||||
toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// 점검항목 추가
|
||||
const handleInspectionSave = async () => {
|
||||
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
|
||||
});
|
||||
toast.success("추가되었습니다."); setInspectionModalOpen(false); refreshRight();
|
||||
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// 소모품 추가
|
||||
// 소모품 품목 로드 (item_info에서 type 또는 division 라벨이 "소모품"인 것)
|
||||
const loadConsumableItems = async () => {
|
||||
try {
|
||||
const flatten = (vals: any[]): any[] => {
|
||||
const r: any[] = [];
|
||||
for (const v of vals) { r.push(v); if (v.children?.length) r.push(...flatten(v.children)); }
|
||||
return r;
|
||||
};
|
||||
|
||||
// type과 division 카테고리 모두에서 "소모품" 코드 찾기
|
||||
const [typeRes, divRes] = await Promise.all([
|
||||
apiClient.get(`/table-categories/item_info/type/values`),
|
||||
apiClient.get(`/table-categories/item_info/division/values`),
|
||||
]);
|
||||
const consumableType = flatten(typeRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
|
||||
const consumableDiv = flatten(divRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
|
||||
|
||||
if (!consumableType && !consumableDiv) { setConsumableItemOptions([]); return; }
|
||||
|
||||
// 두 필터 결과를 합산 (중복 제거)
|
||||
const filters: any[] = [];
|
||||
if (consumableType) filters.push({ columnName: "type", operator: "equals", value: consumableType.valueCode });
|
||||
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
|
||||
|
||||
const results = await Promise.all(filters.map((f) =>
|
||||
apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [f] },
|
||||
autoFilter: true,
|
||||
})
|
||||
));
|
||||
|
||||
const allItems = new Map<string, any>();
|
||||
for (const res of results) {
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
for (const row of rows) allItems.set(row.id, row);
|
||||
}
|
||||
setConsumableItemOptions(Array.from(allItems.values()));
|
||||
} catch { setConsumableItemOptions([]); }
|
||||
};
|
||||
|
||||
const handleConsumableSave = async () => {
|
||||
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
|
||||
...consumableForm, equipment_code: selectedEquip?.equipment_code,
|
||||
});
|
||||
toast.success("추가되었습니다."); setConsumableModalOpen(false); refreshRight();
|
||||
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// 점검항목 복사: 소스 설비 선택 시 점검항목 로드
|
||||
const loadCopyItems = async (equipCode: string) => {
|
||||
setCopySourceEquip(equipCode);
|
||||
setCopyChecked(new Set());
|
||||
if (!equipCode) { setCopyItems([]); return; }
|
||||
setCopyLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
setCopyItems(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setCopyItems([]); } finally { setCopyLoading(false); }
|
||||
};
|
||||
|
||||
const handleCopyApply = async () => {
|
||||
const selected = copyItems.filter((i) => copyChecked.has(i.id));
|
||||
if (selected.length === 0) { toast.error("복사할 항목을 선택해주세요."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const item of selected) {
|
||||
const { id, created_date, updated_date, writer, company_code, equipment_code, ...fields } = item;
|
||||
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
|
||||
...fields, equipment_code: selectedEquip?.equipment_code,
|
||||
});
|
||||
}
|
||||
toast.success(`${selected.length}개 점검항목이 복사되었습니다.`);
|
||||
setCopyModalOpen(false); refreshRight();
|
||||
} catch { toast.error("복사 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// 엑셀
|
||||
const handleExcelDownload = async () => {
|
||||
if (equipments.length === 0) return;
|
||||
await exportToExcel(equipments.map((e) => ({
|
||||
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
|
||||
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
|
||||
도입일자: e.introduction_date, 가동상태: e.operation_status,
|
||||
})), "설비정보.xlsx", "설비");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
// 셀렉트 렌더링 헬퍼
|
||||
const catSelect = (key: string, value: string, onChange: (v: string) => void, placeholder: string) => (
|
||||
<Select value={value || ""} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(catOptions[key] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
<DynamicSearchFilter tableName={EQUIP_TABLE} filterId="equipment-info" onFilterChange={setSearchFilters} dataCount={equipCount}
|
||||
externalFilterConfig={filterConfig}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
|
||||
onClick={async () => {
|
||||
setExcelDetecting(true);
|
||||
try {
|
||||
const r = await autoDetectMultiTableConfig(EQUIP_TABLE);
|
||||
if (r.success && r.data) { setExcelChainConfig(r.data); setExcelUploadOpen(true); }
|
||||
else toast.error("테이블 구조 분석 실패");
|
||||
} catch { toast.error("오류"); } finally { setExcelDetecting(false); }
|
||||
}}>
|
||||
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />} 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 좌측: 설비 목록 */}
|
||||
<ResizablePanel defaultSize={40} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Wrench className="w-4 h-4" /> 설비 목록 <Badge variant="secondary" className="font-normal">{equipCount}건</Badge>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={openEquipRegister}><Plus className="w-3.5 h-3.5 mr-1" /> 등록</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> 수정</Button>
|
||||
<Button variant="destructive" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataGrid gridId="equip-left" columns={LEFT_COLUMNS} data={equipments} loading={equipLoading}
|
||||
selectedId={selectedEquipId} onSelect={setSelectedEquipId} onRowDoubleClick={() => openEquipEdit()}
|
||||
emptyMessage="등록된 설비가 없습니다" />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 탭 */}
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-2 border-b bg-muted/10 shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
|
||||
<button key={tab} onClick={() => setRightTab(tab)}
|
||||
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1",
|
||||
rightTab === tab ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
|
||||
<Icon className="w-3.5 h-3.5" />{label}
|
||||
{tab === "inspection" && inspections.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{inspections.length}</Badge>}
|
||||
{tab === "consumable" && consumables.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{consumables.length}</Badge>}
|
||||
</button>
|
||||
))}
|
||||
{selectedEquip && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedEquip.equipment_name}</Badge>}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{rightTab === "inspection" && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionModalOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
|
||||
<Copy className="w-3.5 h-3.5 mr-1" /> 복사
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{rightTab === "consumable" && (
|
||||
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); loadConsumableItems(); setConsumableModalOpen(true); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedEquipId ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">좌측에서 설비를 선택하세요</div>
|
||||
) : rightTab === "info" ? (
|
||||
<div className="p-4 overflow-auto">
|
||||
<div className="flex justify-end mb-3">
|
||||
<Button size="sm" onClick={handleInfoSave} disabled={infoSaving}>
|
||||
{infoSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm text-muted-foreground">설비코드</Label>
|
||||
<Input value={infoForm.equipment_code || ""} className="h-9 bg-muted/50" disabled />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">설비명</Label>
|
||||
<Input value={infoForm.equipment_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">설비유형</Label>
|
||||
{catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">설치장소</Label>
|
||||
<Input value={infoForm.installation_location || ""} onChange={(e) => setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">제조사</Label>
|
||||
<Input value={infoForm.manufacturer || ""} onChange={(e) => setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">모델명</Label>
|
||||
<Input value={infoForm.model_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">도입일자</Label>
|
||||
<FormDatePicker value={infoForm.introduction_date || ""} onChange={(v) => setInfoForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">가동상태</Label>
|
||||
{catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")}
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-sm">비고</Label>
|
||||
<Input value={infoForm.remarks || ""} onChange={(e) => setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<Label className="text-sm">이미지</Label>
|
||||
<ImageUpload value={infoForm.image_path} onChange={(v) => setInfoForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : rightTab === "inspection" ? (
|
||||
<DataGrid gridId="equip-inspection" columns={INSPECTION_COLUMNS} data={inspections} loading={inspectionLoading}
|
||||
showRowNumber={false} tableName={INSPECTION_TABLE} emptyMessage="점검항목이 없습니다"
|
||||
onCellEdit={() => refreshRight()} />
|
||||
) : (
|
||||
<DataGrid gridId="equip-consumable" columns={CONSUMABLE_COLUMNS} data={consumables} loading={consumableLoading}
|
||||
showRowNumber={false} tableName={CONSUMABLE_TABLE} emptyMessage="소모품이 없습니다"
|
||||
onCellEdit={() => refreshRight()} />
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* 설비 등록/수정 모달 */}
|
||||
<FullscreenDialog open={equipModalOpen} onOpenChange={setEquipModalOpen}
|
||||
title={equipEditMode ? "설비 수정" : "설비 등록"} description={equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={<><Button variant="outline" onClick={() => setEquipModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장</Button></>}>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">설비코드</Label>
|
||||
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">설비명 <span className="text-destructive">*</span></Label>
|
||||
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">설비유형</Label>
|
||||
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">가동상태</Label>
|
||||
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">설치장소</Label>
|
||||
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">제조사</Label>
|
||||
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">모델명</Label>
|
||||
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">도입일자</Label>
|
||||
<FormDatePicker value={equipForm.introduction_date || ""} onChange={(v) => setEquipForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" /></div>
|
||||
<div className="space-y-1.5 col-span-2"><Label className="text-sm">비고</Label>
|
||||
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
|
||||
<div className="space-y-1.5 col-span-2"><Label className="text-sm">이미지</Label>
|
||||
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
|
||||
{/* 점검항목 추가 모달 */}
|
||||
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>점검항목 추가</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 점검항목을 추가합니다.</DialogDescription></DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검항목 <span className="text-destructive">*</span></Label>
|
||||
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" /></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검주기</Label>
|
||||
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검방법</Label>
|
||||
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">하한치</Label>
|
||||
<Input value={inspectionForm.lower_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">상한치</Label>
|
||||
<Input value={inspectionForm.upper_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">단위</Label>
|
||||
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
|
||||
</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">점검내용</Label>
|
||||
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setInspectionModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 소모품 추가 모달 */}
|
||||
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader><DialogTitle>소모품 추가</DialogTitle><DialogDescription>{selectedEquip?.equipment_name}에 소모품을 추가합니다.</DialogDescription></DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-1.5 col-span-2"><Label className="text-sm">소모품명 <span className="text-destructive">*</span></Label>
|
||||
{consumableItemOptions.length > 0 ? (
|
||||
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
|
||||
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
|
||||
setConsumableForm((p) => ({
|
||||
...p,
|
||||
consumable_name: v,
|
||||
specification: item?.size || p.specification || "",
|
||||
unit: item?.unit || p.unit || "",
|
||||
manufacturer: item?.manufacturer || p.manufacturer || "",
|
||||
}));
|
||||
}}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="소모품 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{consumableItemOptions.map((item) => (
|
||||
<SelectItem key={item.id} value={item.item_name || item.item_number}>
|
||||
{item.item_name}{item.size ? ` (${item.size})` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div>
|
||||
<Input value={consumableForm.consumable_name || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))}
|
||||
placeholder="소모품명 직접 입력" className="h-9" />
|
||||
<p className="text-xs text-muted-foreground mt-1">품목정보에 소모품 타입 품목을 등록하면 선택 가능합니다</p>
|
||||
</div>
|
||||
)}</div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">교체주기</Label>
|
||||
<Input value={consumableForm.replacement_cycle || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">단위</Label>
|
||||
<Input value={consumableForm.unit || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">규격</Label>
|
||||
<Input value={consumableForm.specification || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" /></div>
|
||||
<div className="space-y-1.5"><Label className="text-sm">제조사</Label>
|
||||
<Input value={consumableForm.manufacturer || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
|
||||
<div className="space-y-1.5 col-span-2"><Label className="text-sm">이미지</Label>
|
||||
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
|
||||
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
|
||||
</div>
|
||||
<DialogFooter><Button variant="outline" onClick={() => setConsumableModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> 저장</Button></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 점검항목 복사 모달 */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh]">
|
||||
<DialogHeader><DialogTitle>점검항목 복사</DialogTitle>
|
||||
<DialogDescription>다른 설비의 점검항목을 선택하여 {selectedEquip?.equipment_name}에 복사합니다.</DialogDescription></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">소스 설비 선택</Label>
|
||||
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="복사할 설비 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{equipments.filter((e) => e.equipment_code !== selectedEquip?.equipment_code).map((e) => (
|
||||
<SelectItem key={e.equipment_code} value={e.equipment_code}>{e.equipment_name} ({e.equipment_code})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-auto max-h-[300px]">
|
||||
{copyLoading ? (
|
||||
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
|
||||
) : copyItems.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없습니다" : "설비를 선택하세요"}</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
|
||||
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
|
||||
</TableHead>
|
||||
<TableHead>점검항목</TableHead><TableHead className="w-[80px]">점검주기</TableHead>
|
||||
<TableHead className="w-[80px]">점검방법</TableHead><TableHead className="w-[70px]">하한</TableHead>
|
||||
<TableHead className="w-[70px]">상한</TableHead><TableHead className="w-[60px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyItems.map((item) => (
|
||||
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
|
||||
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
|
||||
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
|
||||
<TableCell className="text-sm">{item.inspection_item}</TableCell>
|
||||
<TableCell className="text-xs">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
|
||||
<TableCell className="text-xs">{resolve("inspection_method", item.inspection_method)}</TableCell>
|
||||
<TableCell className="text-xs">{item.lower_limit || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{item.upper_limit || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{item.unit || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<span className="text-sm text-muted-foreground">{copyChecked.size}개 선택됨</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleCopyApply} disabled={saving || copyChecked.size === 0}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Copy className="w-4 h-4 mr-1.5" />} 복사 적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 엑셀 업로드 (멀티테이블) */}
|
||||
{excelChainConfig && (
|
||||
<MultiTableExcelUploadModal open={excelUploadOpen}
|
||||
onOpenChange={(open) => { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }}
|
||||
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
|
||||
)}
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={EQUIP_TABLE}
|
||||
settingsId="equipment-info"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,926 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
ResizableHandle, ResizablePanel, ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandItem } from "@/components/ui/command";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import {
|
||||
Search, Plus, Trash2, RotateCcw, Loader2, Package, Box, X, Save, Edit2, Download, ChevronsUpDown, Check,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
|
||||
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
|
||||
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
|
||||
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
|
||||
getItemsByDivision, getGeneralItems,
|
||||
type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg,
|
||||
} from "@/lib/api/packaging";
|
||||
|
||||
// --- 코드 → 라벨 매핑 ---
|
||||
const PKG_TYPE_LABEL: Record<string, string> = {
|
||||
BOX: "박스", PACK: "팩", CANBOARD: "캔보드", AIRCAP: "에어캡",
|
||||
ZIPCOS: "집코스", CYLINDER: "원통형", POLYCARTON: "포리/카톤",
|
||||
};
|
||||
const LOADING_TYPE_LABEL: Record<string, string> = {
|
||||
PALLET: "파렛트", WOOD_PALLET: "목재파렛트", PLASTIC_PALLET: "플라스틱파렛트",
|
||||
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
|
||||
CAGE: "케이지", ETC: "기타",
|
||||
};
|
||||
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
|
||||
|
||||
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-emerald-100 text-emerald-800" : "bg-gray-100 text-gray-600";
|
||||
const fmtSize = (w: any, l: any, h: any) => {
|
||||
const vals = [w, l, h].map(v => Number(v) || 0);
|
||||
return vals.some(v => v > 0) ? vals.join("×") : "-";
|
||||
};
|
||||
|
||||
// 규격 문자열에서 치수 파싱
|
||||
function parseSpecDimensions(spec: string | null) {
|
||||
if (!spec) return { w: 0, l: 0, h: 0 };
|
||||
const m3 = spec.match(/(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)/i);
|
||||
if (m3) return { w: parseInt(m3[1]), l: parseInt(m3[2]), h: parseInt(m3[3]) };
|
||||
const m2 = spec.match(/(\d+)\s*[x×]\s*(\d+)/i);
|
||||
if (m2) return { w: parseInt(m2[1]), l: parseInt(m2[2]), h: 0 };
|
||||
return { w: 0, l: 0, h: 0 };
|
||||
}
|
||||
|
||||
export default function PackagingPage() {
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const [activeTab, setActiveTab] = useState<"packing" | "loading">("packing");
|
||||
|
||||
// 검색
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
|
||||
// 포장재 데이터
|
||||
const [pkgUnits, setPkgUnits] = useState<PkgUnit[]>([]);
|
||||
const [pkgLoading, setPkgLoading] = useState(false);
|
||||
const [selectedPkg, setSelectedPkg] = useState<PkgUnit | null>(null);
|
||||
const [pkgItems, setPkgItems] = useState<PkgUnitItem[]>([]);
|
||||
const [pkgItemsLoading, setPkgItemsLoading] = useState(false);
|
||||
|
||||
// 적재함 데이터
|
||||
const [loadingUnits, setLoadingUnits] = useState<LoadingUnit[]>([]);
|
||||
const [loadingLoading, setLoadingLoading] = useState(false);
|
||||
const [selectedLoading, setSelectedLoading] = useState<LoadingUnit | null>(null);
|
||||
const [loadingPkgs, setLoadingPkgs] = useState<LoadingUnitPkg[]>([]);
|
||||
const [loadingPkgsLoading, setLoadingPkgsLoading] = useState(false);
|
||||
|
||||
// 모달
|
||||
const [pkgModalOpen, setPkgModalOpen] = useState(false);
|
||||
const [pkgModalMode, setPkgModalMode] = useState<"create" | "edit">("create");
|
||||
const [pkgForm, setPkgForm] = useState<Record<string, any>>({});
|
||||
const [pkgItemOptions, setPkgItemOptions] = useState<ItemInfoForPkg[]>([]);
|
||||
const [pkgItemPopoverOpen, setPkgItemPopoverOpen] = useState(false);
|
||||
|
||||
const [loadModalOpen, setLoadModalOpen] = useState(false);
|
||||
const [loadModalMode, setLoadModalMode] = useState<"create" | "edit">("create");
|
||||
const [loadForm, setLoadForm] = useState<Record<string, any>>({});
|
||||
const [loadItemOptions, setLoadItemOptions] = useState<ItemInfoForPkg[]>([]);
|
||||
const [loadItemPopoverOpen, setLoadItemPopoverOpen] = useState(false);
|
||||
|
||||
const [itemMatchModalOpen, setItemMatchModalOpen] = useState(false);
|
||||
const [itemMatchKeyword, setItemMatchKeyword] = useState("");
|
||||
const [itemMatchResults, setItemMatchResults] = useState<ItemInfoForPkg[]>([]);
|
||||
const [itemMatchSelected, setItemMatchSelected] = useState<ItemInfoForPkg | null>(null);
|
||||
const [itemMatchQty, setItemMatchQty] = useState(1);
|
||||
|
||||
const [pkgMatchModalOpen, setPkgMatchModalOpen] = useState(false);
|
||||
const [pkgMatchQty, setPkgMatchQty] = useState(1);
|
||||
const [pkgMatchMethod, setPkgMatchMethod] = useState("");
|
||||
const [pkgMatchSelected, setPkgMatchSelected] = useState<PkgUnit | null>(null);
|
||||
const [pkgMatchSearchKw, setPkgMatchSearchKw] = useState("");
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// --- 데이터 로드 ---
|
||||
const fetchPkgUnits = useCallback(async () => {
|
||||
setPkgLoading(true);
|
||||
try {
|
||||
const res = await getPkgUnits();
|
||||
if (res.success) setPkgUnits(res.data);
|
||||
} catch { /* ignore */ } finally { setPkgLoading(false); }
|
||||
}, []);
|
||||
|
||||
const fetchLoadingUnits = useCallback(async () => {
|
||||
setLoadingLoading(true);
|
||||
try {
|
||||
const res = await getLoadingUnits();
|
||||
if (res.success) setLoadingUnits(res.data);
|
||||
} catch { /* ignore */ } finally { setLoadingLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchPkgUnits(); fetchLoadingUnits(); }, [fetchPkgUnits, fetchLoadingUnits]);
|
||||
|
||||
// 포장재 선택 시 매칭 품목 로드
|
||||
const selectPkg = useCallback(async (pkg: PkgUnit) => {
|
||||
setSelectedPkg(pkg);
|
||||
setPkgItemsLoading(true);
|
||||
try {
|
||||
const res = await getPkgUnitItems(pkg.pkg_code);
|
||||
if (res.success) setPkgItems(res.data);
|
||||
} catch { setPkgItems([]); } finally { setPkgItemsLoading(false); }
|
||||
}, []);
|
||||
|
||||
// 적재함 선택 시 포장구성 로드
|
||||
const selectLoading = useCallback(async (lu: LoadingUnit) => {
|
||||
setSelectedLoading(lu);
|
||||
setLoadingPkgsLoading(true);
|
||||
try {
|
||||
const res = await getLoadingUnitPkgs(lu.loading_code);
|
||||
if (res.success) setLoadingPkgs(res.data);
|
||||
} catch { setLoadingPkgs([]); } finally { setLoadingPkgsLoading(false); }
|
||||
}, []);
|
||||
|
||||
// 검색 필터 적용
|
||||
const filteredPkgUnits = pkgUnits.filter((p) => {
|
||||
if (!searchKeyword) return true;
|
||||
const kw = searchKeyword.toLowerCase();
|
||||
return (p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw));
|
||||
});
|
||||
|
||||
const filteredLoadingUnits = loadingUnits.filter((l) => {
|
||||
if (!searchKeyword) return true;
|
||||
const kw = searchKeyword.toLowerCase();
|
||||
return (l.loading_code?.toLowerCase().includes(kw) || l.loading_name?.toLowerCase().includes(kw));
|
||||
});
|
||||
|
||||
// --- 포장재 등록/수정 모달 ---
|
||||
const openPkgModal = async (mode: "create" | "edit") => {
|
||||
setPkgModalMode(mode);
|
||||
if (mode === "edit" && selectedPkg) {
|
||||
setPkgForm({ ...selectedPkg });
|
||||
} else {
|
||||
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
|
||||
}
|
||||
setPkgItemPopoverOpen(false);
|
||||
try {
|
||||
const res = await getItemsByDivision("포장재");
|
||||
if (res.success) setPkgItemOptions(res.data);
|
||||
} catch { setPkgItemOptions([]); }
|
||||
setPkgModalOpen(true);
|
||||
};
|
||||
|
||||
const onPkgItemSelect = (item: ItemInfoForPkg) => {
|
||||
setPkgItemPopoverOpen(false);
|
||||
const dims = parseSpecDimensions(item.size);
|
||||
setPkgForm((prev) => ({
|
||||
...prev,
|
||||
pkg_code: item.item_number,
|
||||
pkg_name: item.item_name,
|
||||
width_mm: dims.w || prev.width_mm,
|
||||
length_mm: dims.l || prev.length_mm,
|
||||
height_mm: dims.h || prev.height_mm,
|
||||
}));
|
||||
};
|
||||
|
||||
const savePkgUnit = async () => {
|
||||
if (!pkgForm.pkg_code || !pkgForm.pkg_name) { toast.error("포장코드와 포장명은 필수입니다."); return; }
|
||||
if (!pkgForm.pkg_type) { toast.error("포장유형을 선택해주세요."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (pkgModalMode === "create") {
|
||||
const res = await createPkgUnit(pkgForm);
|
||||
if (res.success) { toast.success("포장재 등록 완료"); setPkgModalOpen(false); fetchPkgUnits(); }
|
||||
} else {
|
||||
const res = await updatePkgUnit(pkgForm.id, pkgForm);
|
||||
if (res.success) { toast.success("포장재 수정 완료"); setPkgModalOpen(false); fetchPkgUnits(); setSelectedPkg(res.data); }
|
||||
}
|
||||
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDeletePkg = async (pkg: PkgUnit) => {
|
||||
const ok = await confirm(`"${pkg.pkg_name}" 포장재를 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deletePkgUnit(pkg.id);
|
||||
toast.success("삭제 완료");
|
||||
setSelectedPkg(null); setPkgItems([]);
|
||||
fetchPkgUnits();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// --- 적재함 등록/수정 모달 ---
|
||||
const openLoadModal = async (mode: "create" | "edit") => {
|
||||
setLoadModalMode(mode);
|
||||
if (mode === "edit" && selectedLoading) {
|
||||
setLoadForm({ ...selectedLoading });
|
||||
} else {
|
||||
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
|
||||
}
|
||||
setLoadItemPopoverOpen(false);
|
||||
try {
|
||||
const res = await getItemsByDivision("적재함");
|
||||
if (res.success) setLoadItemOptions(res.data);
|
||||
} catch { setLoadItemOptions([]); }
|
||||
setLoadModalOpen(true);
|
||||
};
|
||||
|
||||
const onLoadItemSelect = (item: ItemInfoForPkg) => {
|
||||
setLoadItemPopoverOpen(false);
|
||||
const dims = parseSpecDimensions(item.size);
|
||||
setLoadForm((prev) => ({
|
||||
...prev,
|
||||
loading_code: item.item_number,
|
||||
loading_name: item.item_name,
|
||||
width_mm: dims.w || prev.width_mm,
|
||||
length_mm: dims.l || prev.length_mm,
|
||||
height_mm: dims.h || prev.height_mm,
|
||||
}));
|
||||
};
|
||||
|
||||
const saveLoadingUnit = async () => {
|
||||
if (!loadForm.loading_code || !loadForm.loading_name) { toast.error("적재함코드와 적재함명은 필수입니다."); return; }
|
||||
if (!loadForm.loading_type) { toast.error("적재유형을 선택해주세요."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (loadModalMode === "create") {
|
||||
const res = await createLoadingUnit(loadForm);
|
||||
if (res.success) { toast.success("적재함 등록 완료"); setLoadModalOpen(false); fetchLoadingUnits(); }
|
||||
} else {
|
||||
const res = await updateLoadingUnit(loadForm.id, loadForm);
|
||||
if (res.success) { toast.success("적재함 수정 완료"); setLoadModalOpen(false); fetchLoadingUnits(); setSelectedLoading(res.data); }
|
||||
}
|
||||
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDeleteLoading = async (lu: LoadingUnit) => {
|
||||
const ok = await confirm(`"${lu.loading_name}" 적재함을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteLoadingUnit(lu.id);
|
||||
toast.success("삭제 완료");
|
||||
setSelectedLoading(null); setLoadingPkgs([]);
|
||||
fetchLoadingUnits();
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// --- 품목 추가 모달 (포장재 매칭) ---
|
||||
const openItemMatchModal = async () => {
|
||||
setItemMatchKeyword(""); setItemMatchSelected(null); setItemMatchQty(1);
|
||||
setItemMatchModalOpen(true);
|
||||
try {
|
||||
const res = await getGeneralItems();
|
||||
if (res.success) setItemMatchResults(res.data);
|
||||
} catch { setItemMatchResults([]); }
|
||||
};
|
||||
|
||||
const searchItemsForMatch = async () => {
|
||||
try {
|
||||
const res = await getGeneralItems(itemMatchKeyword || undefined);
|
||||
if (res.success) setItemMatchResults(res.data);
|
||||
} catch { setItemMatchResults([]); }
|
||||
};
|
||||
|
||||
const saveItemMatch = async () => {
|
||||
if (!selectedPkg || !itemMatchSelected) { toast.error("품목을 선택해주세요."); return; }
|
||||
if (itemMatchQty <= 0) { toast.error("포장수량을 입력해주세요."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await createPkgUnitItem({
|
||||
pkg_code: selectedPkg.pkg_code,
|
||||
item_number: itemMatchSelected.item_number,
|
||||
pkg_qty: itemMatchQty,
|
||||
});
|
||||
if (res.success) { toast.success("품목 추가 완료"); setItemMatchModalOpen(false); selectPkg(selectedPkg); }
|
||||
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDeletePkgItem = async (item: PkgUnitItem) => {
|
||||
const ok = await confirm("매칭 품목을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deletePkgUnitItem(item.id);
|
||||
toast.success("삭제 완료");
|
||||
if (selectedPkg) selectPkg(selectedPkg);
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
// --- 포장단위 추가 모달 (적재함 구성) ---
|
||||
const openPkgMatchModal = () => {
|
||||
setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod(""); setPkgMatchSearchKw("");
|
||||
setPkgMatchModalOpen(true);
|
||||
};
|
||||
|
||||
const savePkgMatch = async () => {
|
||||
if (!selectedLoading || !pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; }
|
||||
if (pkgMatchQty <= 0) { toast.error("최대적재수량을 입력해주세요."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await createLoadingUnitPkg({
|
||||
loading_code: selectedLoading.loading_code,
|
||||
pkg_code: pkgMatchSelected.pkg_code,
|
||||
max_load_qty: pkgMatchQty,
|
||||
load_method: pkgMatchMethod || undefined,
|
||||
});
|
||||
if (res.success) { toast.success("포장단위 추가 완료"); setPkgMatchModalOpen(false); selectLoading(selectedLoading); }
|
||||
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDeleteLoadPkg = async (lp: LoadingUnitPkg) => {
|
||||
const ok = await confirm("적재 구성을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await deleteLoadingUnitPkg(lp.id);
|
||||
toast.success("삭제 완료");
|
||||
if (selectedLoading) selectLoading(selectedLoading);
|
||||
} catch { toast.error("삭제 실패"); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-4">
|
||||
{/* 검색 바 */}
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-card p-3">
|
||||
<Input
|
||||
placeholder="포장코드 / 포장명 / 적재함명 검색"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
className="h-9 w-[280px] text-xs"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setSearchKeyword("")} className="h-9">
|
||||
<RotateCcw className="mr-1 h-4 w-4" /> 초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-1 border-b">
|
||||
{([["packing", "포장재 관리", filteredPkgUnits.length] as const, ["loading", "적재함 관리", filteredLoadingUnits.length] as const]).map(([tab, label, count]) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
|
||||
activeTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{tab === "packing" ? <Package className="h-4 w-4" /> : <Box className="h-4 w-4" />}
|
||||
{label}
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5">{count}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab === "packing" ? (
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||
{/* 좌측: 포장재 목록 */}
|
||||
<ResizablePanel defaultSize={45} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||
<span className="text-sm font-semibold">포장재 목록 <span className="text-muted-foreground font-normal">({filteredPkgUnits.length}건)</span></span>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("create")}>
|
||||
<Plus className="mr-1 h-3 w-3" /> 등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-[11px] bg-muted/50">
|
||||
<TableHead className="p-2">품목코드</TableHead>
|
||||
<TableHead className="p-2">포장명</TableHead>
|
||||
<TableHead className="p-2 w-[70px]">유형</TableHead>
|
||||
<TableHead className="p-2 w-[90px]">크기(mm)</TableHead>
|
||||
<TableHead className="p-2 w-[70px] text-right">최대중량</TableHead>
|
||||
<TableHead className="p-2 w-[55px] text-center">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pkgLoading ? (
|
||||
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
||||
) : filteredPkgUnits.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs">등록된 포장재가 없습니다</TableCell></TableRow>
|
||||
) : filteredPkgUnits.map((p) => (
|
||||
<TableRow
|
||||
key={p.id}
|
||||
className={cn("cursor-pointer text-xs", selectedPkg?.id === p.id && "bg-primary/5")}
|
||||
onClick={() => selectPkg(p)}
|
||||
>
|
||||
<TableCell className="p-2 font-medium truncate max-w-[100px]">{p.pkg_code}</TableCell>
|
||||
<TableCell className="p-2 truncate max-w-[120px]">{p.pkg_name}</TableCell>
|
||||
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"}</TableCell>
|
||||
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
|
||||
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(p.status))}>{STATUS_LABEL[p.status] || p.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
{/* 우측: 상세 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
{!selectedPkg ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Package className="h-12 w-12 opacity-20 mb-2" />
|
||||
<p className="text-sm">좌측 목록에서 포장재를 선택하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 요약 헤더 */}
|
||||
<div className="flex items-center justify-between border-b bg-blue-50 dark:bg-blue-950/20 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-5 w-5 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-bold text-sm">{selectedPkg.pkg_name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{selectedPkg.pkg_code} · {PKG_TYPE_LABEL[selectedPkg.pkg_type] || selectedPkg.pkg_type} · {fmtSize(selectedPkg.width_mm, selectedPkg.length_mm, selectedPkg.height_mm)}mm</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("edit")}>
|
||||
<Edit2 className="mr-1 h-3 w-3" /> 수정
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeletePkg(selectedPkg)}>
|
||||
<Trash2 className="mr-1 h-3 w-3" /> 삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 매칭 품목 */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<span className="text-xs font-semibold text-muted-foreground">매칭 품목 ({pkgItems.length}건)</span>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openItemMatchModal}>
|
||||
<Plus className="mr-1 h-3 w-3" /> 품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{pkgItemsLoading ? (
|
||||
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
|
||||
) : pkgItems.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs">매칭된 품목이 없습니다</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-[11px]">
|
||||
<TableHead className="p-2">품목코드</TableHead>
|
||||
<TableHead className="p-2">품목명</TableHead>
|
||||
<TableHead className="p-2 w-[70px]">규격</TableHead>
|
||||
<TableHead className="p-2 w-[50px]">단위</TableHead>
|
||||
<TableHead className="p-2 w-[80px] text-right">포장수량</TableHead>
|
||||
<TableHead className="p-2 w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pkgItems.map((item) => (
|
||||
<TableRow key={item.id} className="text-xs">
|
||||
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
|
||||
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
|
||||
<TableCell className="p-2">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
|
||||
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
/* 적재함 관리 탭 */
|
||||
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
|
||||
<ResizablePanel defaultSize={45} minSize={30}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b px-4 py-2.5">
|
||||
<span className="text-sm font-semibold">적재함 목록 <span className="text-muted-foreground font-normal">({filteredLoadingUnits.length}건)</span></span>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("create")}>
|
||||
<Plus className="mr-1 h-3 w-3" /> 등록
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-[11px] bg-muted/50">
|
||||
<TableHead className="p-2">품목코드</TableHead>
|
||||
<TableHead className="p-2">적재함명</TableHead>
|
||||
<TableHead className="p-2 w-[80px]">유형</TableHead>
|
||||
<TableHead className="p-2 w-[90px]">크기(mm)</TableHead>
|
||||
<TableHead className="p-2 w-[70px] text-right">최대적재</TableHead>
|
||||
<TableHead className="p-2 w-[55px] text-center">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loadingLoading ? (
|
||||
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
||||
) : filteredLoadingUnits.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs">등록된 적재함이 없습니다</TableCell></TableRow>
|
||||
) : filteredLoadingUnits.map((l) => (
|
||||
<TableRow
|
||||
key={l.id}
|
||||
className={cn("cursor-pointer text-xs", selectedLoading?.id === l.id && "bg-primary/5")}
|
||||
onClick={() => selectLoading(l)}
|
||||
>
|
||||
<TableCell className="p-2 font-medium truncate max-w-[100px]">{l.loading_code}</TableCell>
|
||||
<TableCell className="p-2 truncate max-w-[120px]">{l.loading_name}</TableCell>
|
||||
<TableCell className="p-2">{LOADING_TYPE_LABEL[l.loading_type] || l.loading_type || "-"}</TableCell>
|
||||
<TableCell className="p-2 text-[10px]">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
|
||||
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(l.status))}>{STATUS_LABEL[l.status] || l.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
{!selectedLoading ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Box className="h-12 w-12 opacity-20 mb-2" />
|
||||
<p className="text-sm">좌측 목록에서 적재함을 선택하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b bg-green-50 dark:bg-green-950/20 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Box className="h-5 w-5 text-green-600" />
|
||||
<div>
|
||||
<div className="font-bold text-sm">{selectedLoading.loading_name}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{selectedLoading.loading_code} · {LOADING_TYPE_LABEL[selectedLoading.loading_type] || selectedLoading.loading_type} · {fmtSize(selectedLoading.width_mm, selectedLoading.length_mm, selectedLoading.height_mm)}mm</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("edit")}><Edit2 className="mr-1 h-3 w-3" /> 수정</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeleteLoading(selectedLoading)}><Trash2 className="mr-1 h-3 w-3" /> 삭제</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b">
|
||||
<span className="text-xs font-semibold text-muted-foreground">적재 가능 포장단위 ({loadingPkgs.length}건)</span>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openPkgMatchModal}><Plus className="mr-1 h-3 w-3" /> 포장단위 추가</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loadingPkgsLoading ? (
|
||||
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
|
||||
) : loadingPkgs.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs">등록된 포장단위가 없습니다</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-[11px]">
|
||||
<TableHead className="p-2">포장코드</TableHead>
|
||||
<TableHead className="p-2">포장명</TableHead>
|
||||
<TableHead className="p-2 w-[70px]">유형</TableHead>
|
||||
<TableHead className="p-2 w-[80px] text-right">최대수량</TableHead>
|
||||
<TableHead className="p-2 w-[70px]">적재방향</TableHead>
|
||||
<TableHead className="p-2 w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loadingPkgs.map((lp) => (
|
||||
<TableRow key={lp.id} className="text-xs">
|
||||
<TableCell className="p-2 font-medium">{lp.pkg_code}</TableCell>
|
||||
<TableCell className="p-2">{lp.pkg_name || "-"}</TableCell>
|
||||
<TableCell className="p-2">{PKG_TYPE_LABEL[lp.pkg_type || ""] || lp.pkg_type || "-"}</TableCell>
|
||||
<TableCell className="p-2 text-right font-semibold">{Number(lp.max_load_qty).toLocaleString()}</TableCell>
|
||||
<TableCell className="p-2">{lp.load_method || "-"}</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeleteLoadPkg(lp)}><X className="h-3 w-3" /></Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 포장재 등록/수정 모달 */}
|
||||
<FullscreenDialog open={pkgModalOpen} onOpenChange={setPkgModalOpen}
|
||||
title={pkgModalMode === "create" ? "포장재 등록" : "포장재 수정"}
|
||||
description="품목정보에서 포장재를 선택하면 코드와 이름이 자동 연동됩니다."
|
||||
footer={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setPkgModalOpen(false)}>취소</Button>
|
||||
<Button onClick={savePkgUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} 저장</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 p-6">
|
||||
{/* 품목정보 연결 */}
|
||||
{pkgModalMode === "create" && (
|
||||
<div className="rounded-lg border bg-blue-50 dark:bg-blue-950/20 p-4">
|
||||
<Label className="text-xs font-semibold mb-2 block">품목정보 연결 (구분: 포장재)</Label>
|
||||
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
|
||||
{pkgForm.pkg_code
|
||||
? `${pkgForm.pkg_name} (${pkgForm.pkg_code})`
|
||||
: "품목정보에서 포장재를 선택하세요"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||
<Command filter={(value, search) => {
|
||||
const item = pkgItemOptions.find((i) => i.id === value);
|
||||
if (!item) return 0;
|
||||
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
|
||||
return target.includes(search.toLowerCase()) ? 1 : 0;
|
||||
}}>
|
||||
<CommandInput placeholder="품목코드 / 품목명 검색..." />
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty>검색 결과가 없습니다</CommandEmpty>
|
||||
{pkgItemOptions.map((item) => (
|
||||
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
|
||||
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
|
||||
<span className="font-medium">{item.item_name}</span>
|
||||
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
|
||||
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><Label className="text-xs">품목코드</Label><Input value={pkgForm.pkg_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||
<div><Label className="text-xs">포장명</Label><Input value={pkgForm.pkg_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||
<div>
|
||||
<Label className="text-xs">포장유형 <span className="text-destructive">*</span></Label>
|
||||
<Select value={pkgForm.pkg_type || ""} onValueChange={(v) => setPkgForm((p) => ({ ...p, pkg_type: v }))}>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>{Object.entries(PKG_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">상태</Label>
|
||||
<Select value={pkgForm.status || "ACTIVE"} onValueChange={(v) => setPkgForm((p) => ({ ...p, status: v }))}>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-semibold">규격정보</Label>
|
||||
<div className="grid grid-cols-3 gap-3 mt-2">
|
||||
<div><Label className="text-[10px]">가로(mm)</Label><Input type="number" value={pkgForm.width_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||
<div><Label className="text-[10px]">세로(mm)</Label><Input type="number" value={pkgForm.length_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||
<div><Label className="text-[10px]">높이(mm)</Label><Input type="number" value={pkgForm.height_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||
<div><Label className="text-[10px]">자체중량(kg)</Label><Input type="number" value={pkgForm.self_weight_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||
<div><Label className="text-[10px]">최대적재중량(kg)</Label><Input type="number" value={pkgForm.max_load_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||
<div><Label className="text-[10px]">내용적(L)</Label><Input type="number" value={pkgForm.volume_l || ""} onChange={(e) => setPkgForm((p) => ({ ...p, volume_l: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div><Label className="text-xs">비고</Label><Input value={pkgForm.remarks || ""} onChange={(e) => setPkgForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
|
||||
{/* 적재함 등록/수정 모달 */}
|
||||
<FullscreenDialog open={loadModalOpen} onOpenChange={setLoadModalOpen}
|
||||
title={loadModalMode === "create" ? "적재함 등록" : "적재함 수정"}
|
||||
description="품목정보에서 적재함을 선택하면 코드와 이름이 자동 연동됩니다."
|
||||
footer={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setLoadModalOpen(false)}>취소</Button>
|
||||
<Button onClick={saveLoadingUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} 저장</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 p-6">
|
||||
{loadModalMode === "create" && (
|
||||
<div className="rounded-lg border bg-green-50 dark:bg-green-950/20 p-4">
|
||||
<Label className="text-xs font-semibold mb-2 block">품목정보 연결 (구분: 적재함)</Label>
|
||||
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
|
||||
{loadForm.loading_code
|
||||
? `${loadForm.loading_name} (${loadForm.loading_code})`
|
||||
: "품목정보에서 적재함을 선택하세요"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
|
||||
<Command filter={(value, search) => {
|
||||
const item = loadItemOptions.find((i) => i.id === value);
|
||||
if (!item) return 0;
|
||||
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
|
||||
return target.includes(search.toLowerCase()) ? 1 : 0;
|
||||
}}>
|
||||
<CommandInput placeholder="품목코드 / 품목명 검색..." />
|
||||
<CommandList className="max-h-[200px]">
|
||||
<CommandEmpty>검색 결과가 없습니다</CommandEmpty>
|
||||
{loadItemOptions.map((item) => (
|
||||
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
|
||||
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
|
||||
<span className="font-medium">{item.item_name}</span>
|
||||
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
|
||||
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><Label className="text-xs">적재함코드</Label><Input value={loadForm.loading_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||
<div><Label className="text-xs">적재함명</Label><Input value={loadForm.loading_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||
<div>
|
||||
<Label className="text-xs">적재유형 <span className="text-destructive">*</span></Label>
|
||||
<Select value={loadForm.loading_type || ""} onValueChange={(v) => setLoadForm((p) => ({ ...p, loading_type: v }))}>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>{Object.entries(LOADING_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">상태</Label>
|
||||
<Select value={loadForm.status || "ACTIVE"} onValueChange={(v) => setLoadForm((p) => ({ ...p, status: v }))}>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs font-semibold">규격정보</Label>
|
||||
<div className="grid grid-cols-3 gap-3 mt-2">
|
||||
<div><Label className="text-[10px]">가로(mm)</Label><Input type="number" value={loadForm.width_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||
<div><Label className="text-[10px]">세로(mm)</Label><Input type="number" value={loadForm.length_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||
<div><Label className="text-[10px]">높이(mm)</Label><Input type="number" value={loadForm.height_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
|
||||
<div><Label className="text-[10px]">자체중량(kg)</Label><Input type="number" value={loadForm.self_weight_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||
<div><Label className="text-[10px]">최대적재중량(kg)</Label><Input type="number" value={loadForm.max_load_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
|
||||
<div><Label className="text-[10px]">최대단수</Label><Input type="number" value={loadForm.max_stack || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_stack: e.target.value }))} className="h-8 text-xs" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<div><Label className="text-xs">비고</Label><Input value={loadForm.remarks || ""} onChange={(e) => setLoadForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
|
||||
{/* 품목 추가 모달 (포장재 매칭) */}
|
||||
<Dialog open={itemMatchModalOpen} onOpenChange={setItemMatchModalOpen}>
|
||||
<DialogContent className="max-w-[900px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>품목 추가 — {selectedPkg?.pkg_name}</DialogTitle>
|
||||
<DialogDescription>포장재에 매칭할 품목을 검색하여 추가합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input placeholder="품목코드 / 품목명 검색 (입력 시 자동 검색)" value={itemMatchKeyword}
|
||||
onChange={(e) => {
|
||||
setItemMatchKeyword(e.target.value);
|
||||
const kw = e.target.value;
|
||||
clearTimeout((window as any).__itemMatchTimer);
|
||||
(window as any).__itemMatchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await getGeneralItems(kw || undefined);
|
||||
if (res.success) setItemMatchResults(res.data);
|
||||
} catch { /* ignore */ }
|
||||
}, 300);
|
||||
}}
|
||||
className="h-9 text-xs" />
|
||||
<div className="max-h-[300px] overflow-auto border rounded">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-[11px]">
|
||||
<TableHead className="p-2 w-[30px]" />
|
||||
<TableHead className="p-2 w-[130px]">품목코드</TableHead>
|
||||
<TableHead className="p-2">품목명</TableHead>
|
||||
<TableHead className="p-2 w-[100px]">규격</TableHead>
|
||||
<TableHead className="p-2 w-[80px]">재질</TableHead>
|
||||
<TableHead className="p-2 w-[50px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16">검색 결과가 없습니다</TableCell></TableRow>
|
||||
) : itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).map((item) => (
|
||||
<TableRow key={item.id} className={cn("cursor-pointer text-xs", itemMatchSelected?.id === item.id && "bg-primary/10")}
|
||||
onClick={() => setItemMatchSelected(item)}>
|
||||
<TableCell className="p-2 text-center">{itemMatchSelected?.id === item.id ? "✓" : ""}</TableCell>
|
||||
<TableCell className="p-2 font-medium truncate max-w-[130px]">{item.item_number}</TableCell>
|
||||
<TableCell className="p-2 truncate max-w-[200px]">{item.item_name}</TableCell>
|
||||
<TableCell className="p-2 truncate">{item.spec || "-"}</TableCell>
|
||||
<TableCell className="p-2 truncate">{item.material || "-"}</TableCell>
|
||||
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">선택된 품목</Label>
|
||||
<Input value={itemMatchSelected ? `${itemMatchSelected.item_name} (${itemMatchSelected.item_number})` : ""} readOnly className="h-9 bg-muted text-xs" />
|
||||
</div>
|
||||
<div className="w-[120px]">
|
||||
<Label htmlFor="pkg-item-match-qty" className="text-xs">포장수량(EA) <span className="text-destructive">*</span></Label>
|
||||
<Input id="pkg-item-match-qty" type="number" value={itemMatchQty} onChange={(e) => setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setItemMatchModalOpen(false)}>취소</Button>
|
||||
<Button type="button" data-action-type="custom" onClick={saveItemMatch} disabled={saving || !itemMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} 추가</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 포장단위 추가 모달 (적재함 구성) */}
|
||||
<Dialog open={pkgMatchModalOpen} onOpenChange={setPkgMatchModalOpen}>
|
||||
<DialogContent className="max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>포장단위 추가 — {selectedLoading?.loading_name}</DialogTitle>
|
||||
<DialogDescription>적재함에 적재할 포장단위를 선택합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="포장코드 / 포장명 검색"
|
||||
value={pkgMatchSearchKw}
|
||||
onChange={(e) => setPkgMatchSearchKw(e.target.value)}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
<div className="max-h-[300px] overflow-auto border rounded">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="text-[11px]">
|
||||
<TableHead className="p-2 w-[30px]" />
|
||||
<TableHead className="p-2 w-[120px]">포장코드</TableHead>
|
||||
<TableHead className="p-2">포장명</TableHead>
|
||||
<TableHead className="p-2 w-[70px]">유형</TableHead>
|
||||
<TableHead className="p-2 w-[100px]">크기(mm)</TableHead>
|
||||
<TableHead className="p-2 w-[80px] text-right">최대중량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(() => {
|
||||
const kw = pkgMatchSearchKw.toLowerCase();
|
||||
const filtered = pkgUnits.filter(p =>
|
||||
p.status === "ACTIVE"
|
||||
&& !loadingPkgs.some(lp => lp.pkg_code === p.pkg_code)
|
||||
&& (!kw || p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw))
|
||||
);
|
||||
return filtered.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16">추가 가능한 포장단위가 없습니다</TableCell></TableRow>
|
||||
) : filtered.map((p) => (
|
||||
<TableRow key={p.id} className={cn("cursor-pointer text-xs", pkgMatchSelected?.id === p.id && "bg-primary/10")}
|
||||
onClick={() => setPkgMatchSelected(p)}>
|
||||
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
|
||||
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
|
||||
<TableCell className="p-2">{p.pkg_name}</TableCell>
|
||||
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
|
||||
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
|
||||
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
})()}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-end gap-4">
|
||||
<div className="w-[150px]">
|
||||
<Label htmlFor="loading-pkg-match-qty" className="text-xs">최대적재수량 <span className="text-destructive">*</span></Label>
|
||||
<Input id="loading-pkg-match-qty" type="number" value={pkgMatchQty} onChange={(e) => setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs">적재방향</Label>
|
||||
<Input value={pkgMatchMethod} onChange={(e) => setPkgMatchMethod(e.target.value)} placeholder="수직/수평/혼합" className="h-9 text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setPkgMatchModalOpen(false)}>취소</Button>
|
||||
<Button type="button" data-action-type="custom" onClick={savePkgMatch} disabled={saving || !pkgMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} 추가</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,14 +18,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -693,14 +686,51 @@ export default function ReceivingPage() {
|
|||
</div>
|
||||
|
||||
{/* 입고 등록 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="flex h-[90vh] max-w-[95vw] flex-col p-0 sm:max-w-[1600px]">
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle className="text-lg">입고 등록</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가하세요.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FullscreenDialog
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
title="입고 등록"
|
||||
description="입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가하세요."
|
||||
defaultMaxWidth="sm:max-w-[1600px]"
|
||||
defaultWidth="w-[95vw]"
|
||||
className="h-[90vh] p-0"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between px-6 py-3">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{selectedItems.length > 0 ? (
|
||||
<>
|
||||
{totalSummary.count}건 | 수량 합계:{" "}
|
||||
{totalSummary.qty.toLocaleString()} | 금액 합계:{" "}
|
||||
{totalSummary.amount.toLocaleString()}원
|
||||
</>
|
||||
) : (
|
||||
"품목을 추가해주세요"
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="h-9 text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || selectedItems.length === 0}
|
||||
className="h-9 text-sm"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
{/* 입고유형 선택 */}
|
||||
<div className="flex items-center gap-4 border-b px-6 py-3">
|
||||
|
|
@ -974,43 +1004,7 @@ export default function ReceivingPage() {
|
|||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<DialogFooter className="flex items-center justify-between border-t px-6 py-3">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{selectedItems.length > 0 ? (
|
||||
<>
|
||||
{totalSummary.count}건 | 수량 합계:{" "}
|
||||
{totalSummary.qty.toLocaleString()} | 금액 합계:{" "}
|
||||
{totalSummary.amount.toLocaleString()}원
|
||||
</>
|
||||
) : (
|
||||
"품목을 추가해주세요"
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="h-9 text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || selectedItems.length === 0}
|
||||
className="h-9 text-sm"
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-1 h-4 w-4" />
|
||||
)}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</FullscreenDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,498 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 부서관리 — 하드코딩 페이지
|
||||
*
|
||||
* 좌측: 부서 목록 (dept_info)
|
||||
* 우측: 선택한 부서의 인원 목록 (user_info)
|
||||
*
|
||||
* 모달: 부서 등록(dept_info), 사원 추가(user_info)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||
Building2, Users, Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
|
||||
|
||||
const DEPT_TABLE = "dept_info";
|
||||
const USER_TABLE = "user_info";
|
||||
|
||||
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
|
||||
{ key: "dept_name", label: "부서명", minWidth: "min-w-[150px]" },
|
||||
{ key: "parent_dept_code", label: "상위부서", width: "w-[100px]" },
|
||||
{ key: "status", label: "상태", width: "w-[70px]" },
|
||||
];
|
||||
|
||||
const RIGHT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "sabun", label: "사번", width: "w-[80px]" },
|
||||
{ key: "user_name", label: "이름", width: "w-[90px]" },
|
||||
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
|
||||
{ key: "position_name", label: "직급", width: "w-[80px]" },
|
||||
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]" },
|
||||
{ key: "email", label: "이메일", minWidth: "min-w-[150px]" },
|
||||
];
|
||||
|
||||
export default function DepartmentPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
// 좌측: 부서
|
||||
const [depts, setDepts] = useState<any[]>([]);
|
||||
const [deptLoading, setDeptLoading] = useState(false);
|
||||
const [deptCount, setDeptCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
|
||||
// 우측: 사원
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [memberLoading, setMemberLoading] = useState(false);
|
||||
|
||||
// 부서 모달
|
||||
const [deptModalOpen, setDeptModalOpen] = useState(false);
|
||||
const [deptEditMode, setDeptEditMode] = useState(false);
|
||||
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 사원 모달
|
||||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||||
const [userEditMode, setUserEditMode] = useState(false);
|
||||
const [userForm, setUserForm] = useState<Record<string, any>>({});
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// 엑셀
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||
setFilterConfig(settings.filters);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings("department");
|
||||
if (saved) applyTableSettings(saved);
|
||||
}, []);
|
||||
|
||||
// 부서 조회
|
||||
const fetchDepts = useCallback(async () => {
|
||||
setDeptLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑
|
||||
const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code }));
|
||||
setDepts(data);
|
||||
setDeptCount(res.data?.data?.total || data.length);
|
||||
} catch (err) {
|
||||
console.error("부서 조회 실패:", err);
|
||||
toast.error("부서 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setDeptLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchDepts(); }, [fetchDepts]);
|
||||
|
||||
// 선택된 부서
|
||||
const selectedDept = depts.find((d) => d.id === selectedDeptId);
|
||||
const selectedDeptCode = selectedDept?.dept_code || null;
|
||||
|
||||
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
|
||||
const fetchMembers = useCallback(async () => {
|
||||
setMemberLoading(true);
|
||||
try {
|
||||
const filters = selectedDeptCode
|
||||
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
|
||||
: [];
|
||||
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { setMembers([]); } finally { setMemberLoading(false); }
|
||||
}, [selectedDeptCode]);
|
||||
|
||||
useEffect(() => { fetchMembers(); }, [fetchMembers]);
|
||||
|
||||
// 부서 등록
|
||||
const openDeptRegister = () => {
|
||||
setDeptForm({});
|
||||
setDeptEditMode(false);
|
||||
setDeptModalOpen(true);
|
||||
};
|
||||
|
||||
const openDeptEdit = () => {
|
||||
if (!selectedDept) return;
|
||||
setDeptForm({ ...selectedDept });
|
||||
setDeptEditMode(true);
|
||||
setDeptModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeptSave = async () => {
|
||||
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (deptEditMode && deptForm.dept_code) {
|
||||
await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, {
|
||||
originalData: { dept_code: deptForm.dept_code },
|
||||
updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null },
|
||||
});
|
||||
toast.success("수정되었습니다.");
|
||||
} else {
|
||||
await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, {
|
||||
dept_code: deptForm.dept_code || "",
|
||||
dept_name: deptForm.dept_name,
|
||||
parent_dept_code: deptForm.parent_dept_code || null,
|
||||
});
|
||||
toast.success("등록되었습니다.");
|
||||
}
|
||||
setDeptModalOpen(false);
|
||||
fetchDepts();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 부서 삭제
|
||||
const handleDeptDelete = async () => {
|
||||
if (!selectedDeptCode) return;
|
||||
const ok = await confirm("부서를 삭제하시겠습니까?", {
|
||||
description: "해당 부서에 소속된 사원 정보는 유지됩니다.",
|
||||
variant: "destructive", confirmText: "삭제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, {
|
||||
data: [{ dept_code: selectedDeptCode }],
|
||||
});
|
||||
toast.success("삭제되었습니다.");
|
||||
setSelectedDeptId(null);
|
||||
fetchDepts();
|
||||
} catch { toast.error("삭제에 실패했습니다."); }
|
||||
};
|
||||
|
||||
// 사원 추가
|
||||
const openUserModal = (editData?: any) => {
|
||||
if (editData) {
|
||||
setUserEditMode(true);
|
||||
setUserForm({ ...editData, user_password: "" });
|
||||
} else {
|
||||
setUserEditMode(false);
|
||||
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
|
||||
}
|
||||
setFormErrors({});
|
||||
setUserModalOpen(true);
|
||||
};
|
||||
|
||||
const handleUserFormChange = (field: string, value: string) => {
|
||||
const formatted = formatField(field, value);
|
||||
setUserForm((prev) => ({ ...prev, [field]: formatted }));
|
||||
const error = validateField(field, formatted);
|
||||
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
|
||||
};
|
||||
|
||||
const handleUserSave = async () => {
|
||||
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
|
||||
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
|
||||
const errors = validateForm(userForm, ["cell_phone", "email"]);
|
||||
setFormErrors(errors);
|
||||
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// 비밀번호 미입력 시 기본값 (신규만)
|
||||
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
|
||||
|
||||
await apiClient.post("/admin/users/with-dept", {
|
||||
userInfo: {
|
||||
user_id: userForm.user_id,
|
||||
user_name: userForm.user_name,
|
||||
user_name_eng: userForm.user_name_eng || undefined,
|
||||
user_password: password || undefined,
|
||||
email: userForm.email || undefined,
|
||||
tel: userForm.tel || undefined,
|
||||
cell_phone: userForm.cell_phone || undefined,
|
||||
sabun: userForm.sabun || undefined,
|
||||
position_name: userForm.position_name || undefined,
|
||||
dept_code: userForm.dept_code || undefined,
|
||||
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
|
||||
status: userForm.status || "active",
|
||||
},
|
||||
mainDept: userForm.dept_code ? {
|
||||
dept_code: userForm.dept_code,
|
||||
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
|
||||
position_name: userForm.position_name || undefined,
|
||||
} : undefined,
|
||||
isUpdate: userEditMode,
|
||||
});
|
||||
toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다.");
|
||||
setUserModalOpen(false);
|
||||
fetchMembers();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 엑셀 다운로드
|
||||
const handleExcelDownload = async () => {
|
||||
if (depts.length === 0) return;
|
||||
const data = depts.map((d) => ({
|
||||
부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status,
|
||||
}));
|
||||
await exportToExcel(data, "부서관리.xlsx", "부서");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={DEPT_TABLE}
|
||||
filterId="department"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={deptCount}
|
||||
externalFilterConfig={filterConfig}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 분할 패널 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 좌측: 부서 */}
|
||||
<ResizablePanel defaultSize={40} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Building2 className="w-4 h-4" /> 부서
|
||||
<Badge variant="secondary" className="font-normal">{deptCount}건</Badge>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button size="sm" onClick={openDeptRegister}><Plus className="w-3.5 h-3.5 mr-1" /> 등록</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> 수정</Button>
|
||||
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={handleDeptDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> 삭제</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataGrid
|
||||
gridId="dept-left"
|
||||
columns={LEFT_COLUMNS}
|
||||
data={depts}
|
||||
loading={deptLoading}
|
||||
selectedId={selectedDeptId}
|
||||
onSelect={(id) => {
|
||||
setSelectedDeptId((prev) => (prev === id ? null : id));
|
||||
}}
|
||||
onRowDoubleClick={() => openDeptEdit()}
|
||||
emptyMessage="등록된 부서가 없습니다"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 사원 */}
|
||||
<ResizablePanel defaultSize={60} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4" />
|
||||
{selectedDept ? "부서 인원" : "전체 사원"}
|
||||
{selectedDept && <Badge variant="outline" className="font-normal">{selectedDept.dept_name}</Badge>}
|
||||
{members.length > 0 && <Badge variant="secondary" className="font-normal">{members.length}명</Badge>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 사원 추가
|
||||
</Button>
|
||||
</div>
|
||||
<DataGrid
|
||||
gridId="dept-right"
|
||||
columns={RIGHT_COLUMNS}
|
||||
data={members}
|
||||
loading={memberLoading}
|
||||
showRowNumber={false}
|
||||
tableName={USER_TABLE}
|
||||
emptyMessage={selectedDeptCode ? "소속 사원이 없습니다" : "등록된 사원이 없습니다"}
|
||||
onRowDoubleClick={(row) => openUserModal(row)}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* 부서 등록/수정 모달 */}
|
||||
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
|
||||
<DialogDescription>{deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">부서코드</Label>
|
||||
<Input value={deptForm.dept_code || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_code: e.target.value }))}
|
||||
placeholder="부서코드" className="h-9" disabled={deptEditMode} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">부서명 <span className="text-destructive">*</span></Label>
|
||||
<Input value={deptForm.dept_name || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
|
||||
placeholder="부서명" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">상위부서</Label>
|
||||
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">없음</SelectItem>
|
||||
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
|
||||
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeptModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleDeptSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 사원 추가 모달 */}
|
||||
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
|
||||
<DialogDescription>{userEditMode ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사용자 ID <span className="text-destructive">*</span></Label>
|
||||
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
|
||||
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이름 <span className="text-destructive">*</span></Label>
|
||||
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
|
||||
placeholder="이름" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사번</Label>
|
||||
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
|
||||
placeholder="사번" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">비밀번호</Label>
|
||||
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
|
||||
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">직급</Label>
|
||||
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
|
||||
placeholder="직급" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">부서</Label>
|
||||
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">휴대폰</Label>
|
||||
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
|
||||
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
|
||||
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">이메일</Label>
|
||||
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
|
||||
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
|
||||
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">입사일</Label>
|
||||
<FormDatePicker value={userForm.regdate || ""} onChange={(v) => setUserForm((p) => ({ ...p, regdate: v }))} placeholder="입사일" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">퇴사일</Label>
|
||||
<FormDatePicker value={userForm.end_date || ""} onChange={(v) => setUserForm((p) => ({ ...p, end_date: v }))} placeholder="퇴사일" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUserModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleUserSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 엑셀 업로드 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
tableName={DEPT_TABLE}
|
||||
userId={user?.userId}
|
||||
onSuccess={() => fetchDepts()}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={DEPT_TABLE}
|
||||
settingsId="department"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,517 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Plus, Trash2, RotateCcw, Save, Search, Loader2, FileSpreadsheet, Download,
|
||||
Package, Pencil, Copy,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const TABLE_COLUMNS = [
|
||||
{ key: "item_number", label: "품목코드", width: "w-[120px]" },
|
||||
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
|
||||
{ key: "division", label: "관리품목", width: "w-[100px]" },
|
||||
{ key: "type", label: "품목구분", width: "w-[100px]" },
|
||||
{ key: "size", label: "규격", width: "w-[100px]" },
|
||||
{ key: "unit", label: "단위", width: "w-[80px]" },
|
||||
{ key: "material", label: "재질", width: "w-[100px]" },
|
||||
{ key: "status", label: "상태", width: "w-[80px]" },
|
||||
{ key: "selling_price", label: "판매가격", width: "w-[100px]" },
|
||||
{ key: "standard_price", label: "기준단가", width: "w-[100px]" },
|
||||
{ key: "weight", label: "중량", width: "w-[80px]" },
|
||||
{ key: "inventory_unit", label: "재고단위", width: "w-[80px]" },
|
||||
{ key: "user_type01", label: "대분류", width: "w-[100px]" },
|
||||
{ key: "user_type02", label: "중분류", width: "w-[100px]" },
|
||||
];
|
||||
|
||||
// 등록 모달 필드 정의
|
||||
const FORM_FIELDS = [
|
||||
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
|
||||
{ key: "item_name", label: "품명", type: "text", required: true },
|
||||
{ key: "division", label: "관리품목", type: "category" },
|
||||
{ key: "type", label: "품목구분", type: "category" },
|
||||
{ key: "size", label: "규격", type: "text" },
|
||||
{ key: "unit", label: "단위", type: "category" },
|
||||
{ key: "material", label: "재질", type: "category" },
|
||||
{ key: "status", label: "상태", type: "category" },
|
||||
{ key: "weight", label: "중량", type: "text" },
|
||||
{ key: "volum", label: "부피", type: "text" },
|
||||
{ key: "specific_gravity", label: "비중", type: "text" },
|
||||
{ key: "inventory_unit", label: "재고단위", type: "category" },
|
||||
{ key: "selling_price", label: "판매가격", type: "text" },
|
||||
{ key: "standard_price", label: "기준단가", type: "text" },
|
||||
{ key: "currency_code", label: "통화", type: "category" },
|
||||
{ key: "user_type01", label: "대분류", type: "category" },
|
||||
{ key: "user_type02", label: "중분류", type: "category" },
|
||||
{ key: "meno", label: "메모", type: "textarea" },
|
||||
];
|
||||
|
||||
const TABLE_NAME = "item_info";
|
||||
|
||||
export default function ItemInfoPage() {
|
||||
const { user } = useAuth();
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 검색
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [searchDivision, setSearchDivision] = useState("all");
|
||||
const [searchType, setSearchType] = useState("all");
|
||||
const [searchStatus, setSearchStatus] = useState("all");
|
||||
|
||||
// 모달
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [editId, setEditId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
// 카테고리 옵션 (API에서 로드)
|
||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// 선택된 행
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// 카테고리 컬럼 목록
|
||||
const CATEGORY_COLUMNS = ["division", "type", "unit", "material", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
|
||||
|
||||
// 카테고리 옵션 로드 (table_name + column_name 기반)
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
CATEGORY_COLUMNS.map(async (colName) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
optMap[colName] = flatten(res.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
})
|
||||
);
|
||||
setCategoryOptions(optMap);
|
||||
} catch (err) {
|
||||
console.error("카테고리 로드 실패:", err);
|
||||
}
|
||||
};
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// 데이터 조회
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (searchKeyword) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
|
||||
}
|
||||
if (searchDivision !== "all") {
|
||||
filters.push({ columnName: "division", operator: "equals", value: searchDivision });
|
||||
}
|
||||
if (searchType !== "all") {
|
||||
filters.push({ columnName: "type", operator: "equals", value: searchType });
|
||||
}
|
||||
if (searchStatus !== "all") {
|
||||
filters.push({ columnName: "status", operator: "equals", value: searchStatus });
|
||||
}
|
||||
|
||||
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
|
||||
page: 1,
|
||||
size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// 카테고리 코드→라벨 변환
|
||||
const resolve = (col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
const data = raw.map((r: any) => {
|
||||
const converted = { ...r };
|
||||
for (const col of CATEGORY_COLUMNS) {
|
||||
if (converted[col]) converted[col] = resolve(col, converted[col]);
|
||||
}
|
||||
return converted;
|
||||
});
|
||||
setItems(data);
|
||||
setTotalCount(res.data?.data?.total || raw.length);
|
||||
} catch (err) {
|
||||
console.error("품목 조회 실패:", err);
|
||||
toast.error("품목 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchKeyword, searchDivision, searchType, searchStatus, categoryOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
// 카테고리 코드 → 라벨 변환
|
||||
const getCategoryLabel = (columnName: string, code: string) => {
|
||||
if (!code) return "";
|
||||
const opts = categoryOptions[columnName];
|
||||
if (!opts) return code;
|
||||
const found = opts.find((o) => o.code === code);
|
||||
return found?.label || code;
|
||||
};
|
||||
|
||||
// 등록 모달 열기
|
||||
const openRegisterModal = () => {
|
||||
setFormData({});
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const openEditModal = (item: any) => {
|
||||
setFormData({ ...item });
|
||||
setIsEditMode(true);
|
||||
setEditId(item.id);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 복사 모달 열기
|
||||
const openCopyModal = (item: any) => {
|
||||
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
|
||||
setFormData(rest);
|
||||
setIsEditMode(false);
|
||||
setEditId(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
if (!formData.item_name) {
|
||||
toast.error("품명은 필수 입력입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (isEditMode && editId) {
|
||||
// 수정
|
||||
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
|
||||
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
|
||||
originalData: { id: editId },
|
||||
updatedData: updateFields,
|
||||
});
|
||||
toast.success("수정되었습니다.");
|
||||
} else {
|
||||
// 등록
|
||||
const { id, created_date, updated_date, ...insertFields } = formData;
|
||||
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, insertFields);
|
||||
toast.success("등록되었습니다.");
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchItems();
|
||||
} catch (err: any) {
|
||||
console.error("저장 실패:", err);
|
||||
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = async () => {
|
||||
if (!selectedId) {
|
||||
toast.error("삭제할 품목을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
if (!confirm("선택한 품목을 삭제하시겠습니까?")) return;
|
||||
|
||||
try {
|
||||
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
|
||||
data: [{ id: selectedId }],
|
||||
});
|
||||
toast.success("삭제되었습니다.");
|
||||
setSelectedId(null);
|
||||
fetchItems();
|
||||
} catch (err) {
|
||||
console.error("삭제 실패:", err);
|
||||
toast.error("삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 엑셀 다운로드
|
||||
const handleExcelDownload = async () => {
|
||||
if (items.length === 0) {
|
||||
toast.error("다운로드할 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
const exportData = items.map((item) => {
|
||||
const row: Record<string, any> = {};
|
||||
for (const col of TABLE_COLUMNS) {
|
||||
row[col.label] = getCategoryLabel(col.key, item[col.key]) || item[col.key] || "";
|
||||
}
|
||||
return row;
|
||||
});
|
||||
await exportToExcel(exportData, "품목정보.xlsx", "품목정보");
|
||||
toast.success("엑셀 다운로드 완료");
|
||||
};
|
||||
|
||||
// 검색 초기화
|
||||
const handleResetSearch = () => {
|
||||
setSearchKeyword("");
|
||||
setSearchDivision("all");
|
||||
setSearchType("all");
|
||||
setSearchStatus("all");
|
||||
};
|
||||
|
||||
// 카테고리 셀렉트 렌더링
|
||||
const renderCategorySelect = (field: typeof FORM_FIELDS[0]) => {
|
||||
const options = categoryOptions[field.key] || [];
|
||||
return (
|
||||
<Select
|
||||
value={formData[field.key] || ""}
|
||||
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder={`${field.label} 선택`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.code} value={opt.code}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<Card className="shrink-0">
|
||||
<CardContent className="p-4 flex flex-wrap items-end gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">품명/품목코드</Label>
|
||||
<Input
|
||||
placeholder="검색"
|
||||
className="w-[180px] h-9"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && fetchItems()}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">관리품목</Label>
|
||||
<Select value={searchDivision} onValueChange={setSearchDivision}>
|
||||
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{(categoryOptions["division"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">품목구분</Label>
|
||||
<Select value={searchType} onValueChange={setSearchType}>
|
||||
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{(categoryOptions["type"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground">상태</Label>
|
||||
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||||
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{(categoryOptions["status"] || []).map((o) => (
|
||||
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" /> 초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Package className="w-5 h-5" /> 품목 목록
|
||||
<Badge variant="secondary" className="font-normal">{totalCount}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-4 h-4 mr-1.5" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||
<Download className="w-4 h-4 mr-1.5" /> 엑셀 다운로드
|
||||
</Button>
|
||||
<Button size="sm" onClick={openRegisterModal}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> 품목 등록
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
|
||||
const item = items.find((i) => i.id === selectedId);
|
||||
if (item) openCopyModal(item);
|
||||
}}>
|
||||
<Copy className="w-4 h-4 mr-1.5" /> 복사
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
|
||||
const item = items.find((i) => i.id === selectedId);
|
||||
if (item) openEditModal(item);
|
||||
}}>
|
||||
<Pencil className="w-4 h-4 mr-1.5" /> 수정
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" /> 삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground gap-2">
|
||||
<Package className="w-8 h-8 opacity-50" />
|
||||
<span>등록된 품목이 없습니다</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">No</TableHead>
|
||||
{TABLE_COLUMNS.map((col) => (
|
||||
<TableHead key={col.key} className={col.width}>{col.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, idx) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={cn("cursor-pointer", selectedId === item.id && "bg-primary/5")}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
onDoubleClick={() => openEditModal(item)}
|
||||
>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||
{TABLE_COLUMNS.map((col) => (
|
||||
<TableCell key={col.key} className="text-sm">
|
||||
{["division", "type", "unit", "material", "status", "inventory_unit", "user_type01", "user_type02", "currency_code"].includes(col.key)
|
||||
? getCategoryLabel(col.key, item[col.key])
|
||||
: item[col.key] || ""}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEditMode ? "품목 정보를 수정합니다." : "새로운 품목을 등록합니다."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
{FORM_FIELDS.map((field) => (
|
||||
<div key={field.key} className={cn("space-y-1.5", field.type === "textarea" && "col-span-2")}>
|
||||
<Label className="text-sm">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
{field.type === "category" ? (
|
||||
renderCategorySelect(field)
|
||||
) : field.type === "textarea" ? (
|
||||
<Textarea
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.label}
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={formData[field.key] || ""}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
placeholder={field.disabled ? field.placeholder : field.label}
|
||||
disabled={field.disabled && !isEditMode}
|
||||
className="h-9"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 엑셀 업로드 모달 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
tableName={TABLE_NAME}
|
||||
userId={user?.userId}
|
||||
onSuccess={() => {
|
||||
fetchItems();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,534 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 외주품목정보 — 하드코딩 페이지
|
||||
*
|
||||
* 좌측: 품목 목록 (subcontractor_item_mapping 기반 품목, item_info 조인)
|
||||
* 우측: 선택한 품목의 외주업체 정보 (subcontractor_item_mapping → subcontractor_mng 조인)
|
||||
*
|
||||
* 외주업체관리와 양방향 연동 (같은 subcontractor_item_mapping 테이블)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, Settings2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
const ITEM_TABLE = "item_info";
|
||||
const MAPPING_TABLE = "subcontractor_item_mapping";
|
||||
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
|
||||
|
||||
// 좌측: 품목 컬럼
|
||||
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "item_number", label: "품번", width: "w-[110px]" },
|
||||
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
|
||||
{ key: "size", label: "규격", width: "w-[90px]" },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||
{ key: "status", label: "상태", width: "w-[60px]" },
|
||||
];
|
||||
|
||||
// 우측: 외주업체 정보 컬럼
|
||||
const RIGHT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "subcontractor_code", label: "외주업체코드", width: "w-[110px]" },
|
||||
{ key: "subcontractor_name", label: "외주업체명", minWidth: "min-w-[120px]" },
|
||||
{ key: "subcontractor_item_code", label: "외주품번", width: "w-[100px]" },
|
||||
{ key: "subcontractor_item_name", label: "외주품명", width: "w-[100px]" },
|
||||
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||
];
|
||||
|
||||
export default function SubcontractorItemPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
// 좌측: 품목
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [itemLoading, setItemLoading] = useState(false);
|
||||
const [itemCount, setItemCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
|
||||
// 우측: 외주업체
|
||||
const [subcontractorItems, setSubcontractorItems] = useState<any[]>([]);
|
||||
const [subcontractorLoading, setSubcontractorLoading] = useState(false);
|
||||
|
||||
// 카테고리
|
||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// 외주업체 추가 모달
|
||||
const [subSelectOpen, setSubSelectOpen] = useState(false);
|
||||
const [subSearchKeyword, setSubSearchKeyword] = useState("");
|
||||
const [subSearchResults, setSubSearchResults] = useState<any[]>([]);
|
||||
const [subSearchLoading, setSubSearchLoading] = useState(false);
|
||||
const [subCheckedIds, setSubCheckedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 품목 수정 모달
|
||||
const [editItemOpen, setEditItemOpen] = useState(false);
|
||||
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 엑셀
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||
setFilterConfig(settings.filters);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings("subcontractor-item");
|
||||
if (saved) applyTableSettings(saved);
|
||||
}, []);
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
|
||||
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setCategoryOptions(optMap);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const resolve = (col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
|
||||
const outsourcingDivisionCode = categoryOptions["division"]?.find(
|
||||
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
|
||||
)?.code;
|
||||
|
||||
const fetchItems = useCallback(async () => {
|
||||
setItemLoading(true);
|
||||
try {
|
||||
const filters: any[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
// division = 외주관리 필터 추가
|
||||
if (outsourcingDivisionCode) {
|
||||
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
|
||||
const data = raw.map((r: any) => {
|
||||
const converted = { ...r };
|
||||
for (const col of CATS) {
|
||||
if (converted[col]) converted[col] = resolve(col, converted[col]);
|
||||
}
|
||||
return converted;
|
||||
});
|
||||
setItems(data);
|
||||
setItemCount(res.data?.data?.total || raw.length);
|
||||
} catch (err) {
|
||||
console.error("품목 조회 실패:", err);
|
||||
toast.error("품목 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setItemLoading(false);
|
||||
}
|
||||
}, [searchFilters, categoryOptions, outsourcingDivisionCode]);
|
||||
|
||||
useEffect(() => { fetchItems(); }, [fetchItems]);
|
||||
|
||||
// 선택된 품목
|
||||
const selectedItem = items.find((i) => i.id === selectedItemId);
|
||||
|
||||
// 우측: 외주업체 목록 조회
|
||||
useEffect(() => {
|
||||
if (!selectedItem?.item_number) { setSubcontractorItems([]); return; }
|
||||
const itemKey = selectedItem.item_number;
|
||||
const fetchSubcontractorItems = async () => {
|
||||
setSubcontractorLoading(true);
|
||||
try {
|
||||
// subcontractor_item_mapping에서 해당 품목의 매핑 조회
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
|
||||
// subcontractor_id → subcontractor_mng 조인 (외주업체명)
|
||||
const subIds = [...new Set(mappings.map((m: any) => m.subcontractor_id).filter(Boolean))];
|
||||
let subMap: Record<string, any> = {};
|
||||
if (subIds.length > 0) {
|
||||
try {
|
||||
const subRes = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
|
||||
page: 1, size: subIds.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "subcontractor_code", operator: "in", value: subIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
for (const s of (subRes.data?.data?.data || subRes.data?.data?.rows || [])) {
|
||||
subMap[s.subcontractor_code] = s;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
setSubcontractorItems(mappings.map((m: any) => ({
|
||||
...m,
|
||||
subcontractor_code: m.subcontractor_id,
|
||||
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
|
||||
})));
|
||||
} catch (err) {
|
||||
console.error("외주업체 조회 실패:", err);
|
||||
} finally {
|
||||
setSubcontractorLoading(false);
|
||||
}
|
||||
};
|
||||
fetchSubcontractorItems();
|
||||
}, [selectedItem?.item_number]);
|
||||
|
||||
// 외주업체 검색
|
||||
const searchSubcontractors = async () => {
|
||||
setSubSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (subSearchKeyword) filters.push({ columnName: "subcontractor_name", operator: "contains", value: subSearchKeyword });
|
||||
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
|
||||
page: 1, size: 50,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const all = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// 이미 등록된 외주업체 제외
|
||||
const existing = new Set(subcontractorItems.map((s: any) => s.subcontractor_id || s.subcontractor_code));
|
||||
setSubSearchResults(all.filter((s: any) => !existing.has(s.subcontractor_code)));
|
||||
} catch { /* skip */ } finally { setSubSearchLoading(false); }
|
||||
};
|
||||
|
||||
// 외주업체 추가 저장
|
||||
const addSelectedSubcontractors = async () => {
|
||||
const selected = subSearchResults.filter((s) => subCheckedIds.has(s.id));
|
||||
if (selected.length === 0 || !selectedItem) return;
|
||||
try {
|
||||
for (const sub of selected) {
|
||||
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
||||
subcontractor_id: sub.subcontractor_code,
|
||||
item_id: selectedItem.item_number,
|
||||
});
|
||||
}
|
||||
toast.success(`${selected.length}개 외주업체가 추가되었습니다.`);
|
||||
setSubCheckedIds(new Set());
|
||||
setSubSelectOpen(false);
|
||||
// 우측 새로고침
|
||||
const sid = selectedItemId;
|
||||
setSelectedItemId(null);
|
||||
setTimeout(() => setSelectedItemId(sid), 50);
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "외주업체 추가에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 품목 수정
|
||||
const openEditItem = () => {
|
||||
if (!selectedItem) return;
|
||||
setEditItemForm({ ...selectedItem });
|
||||
setEditItemOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!editItemForm.id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
|
||||
originalData: { id: editItemForm.id },
|
||||
updatedData: {
|
||||
selling_price: editItemForm.selling_price || null,
|
||||
standard_price: editItemForm.standard_price || null,
|
||||
currency_code: editItemForm.currency_code || null,
|
||||
},
|
||||
});
|
||||
toast.success("수정되었습니다.");
|
||||
setEditItemOpen(false);
|
||||
fetchItems();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 엑셀 다운로드
|
||||
const handleExcelDownload = async () => {
|
||||
if (items.length === 0) return;
|
||||
const data = items.map((i) => ({
|
||||
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
|
||||
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
|
||||
}));
|
||||
await exportToExcel(data, "외주품목정보.xlsx", "외주품목");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={ITEM_TABLE}
|
||||
filterId="subcontractor-item"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={itemCount}
|
||||
externalFilterConfig={filterConfig}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 분할 패널 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 좌측: 외주품목 목록 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Package className="w-4 h-4" /> 외주품목 목록
|
||||
<Badge variant="secondary" className="font-normal">{itemCount}건</Badge>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataGrid
|
||||
gridId="subcontractor-item-left"
|
||||
columns={LEFT_COLUMNS}
|
||||
data={items}
|
||||
loading={itemLoading}
|
||||
selectedId={selectedItemId}
|
||||
onSelect={setSelectedItemId}
|
||||
onRowDoubleClick={() => openEditItem()}
|
||||
emptyMessage="등록된 외주품목이 없습니다"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 외주업체 정보 */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4" /> 외주업체 정보
|
||||
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled={!selectedItemId}
|
||||
onClick={() => { setSubCheckedIds(new Set()); setSubSelectOpen(true); searchSubcontractors(); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 외주업체 추가
|
||||
</Button>
|
||||
</div>
|
||||
{!selectedItemId ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
좌측에서 품목을 선택하세요
|
||||
</div>
|
||||
) : (
|
||||
<DataGrid
|
||||
gridId="subcontractor-item-right"
|
||||
columns={RIGHT_COLUMNS}
|
||||
data={subcontractorItems}
|
||||
loading={subcontractorLoading}
|
||||
showRowNumber={false}
|
||||
emptyMessage="등록된 외주업체가 없습니다"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* 품목 수정 모달 */}
|
||||
<FullscreenDialog
|
||||
open={editItemOpen}
|
||||
onOpenChange={setEditItemOpen}
|
||||
title="외주품목 수정"
|
||||
description={`${editItemForm.item_number || ""} — ${editItemForm.item_name || ""}`}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditItemOpen(false)}>취소</Button>
|
||||
<Button onClick={handleEditSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
{[
|
||||
{ key: "item_number", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "material", label: "재질" },
|
||||
{ key: "status", label: "상태" },
|
||||
].map((f) => (
|
||||
<div key={f.key} className="space-y-1.5">
|
||||
<Label className="text-sm text-muted-foreground">{f.label}</Label>
|
||||
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="col-span-2 border-t my-2" />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">판매가격</Label>
|
||||
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
|
||||
placeholder="판매가격" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">기준단가</Label>
|
||||
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
|
||||
placeholder="기준단가" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">통화</Label>
|
||||
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
|
||||
{/* 외주업체 추가 모달 */}
|
||||
<Dialog open={subSelectOpen} onOpenChange={setSubSelectOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>외주업체 선택</DialogTitle>
|
||||
<DialogDescription>품목에 추가할 외주업체를 선택하세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Input placeholder="외주업체명 검색" value={subSearchKeyword}
|
||||
onChange={(e) => setSubSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchSubcontractors()}
|
||||
className="h-9 flex-1" />
|
||||
<Button size="sm" onClick={searchSubcontractors} disabled={subSearchLoading} className="h-9">
|
||||
{subSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<input type="checkbox"
|
||||
checked={subSearchResults.length > 0 && subCheckedIds.size === subSearchResults.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setSubCheckedIds(new Set(subSearchResults.map((s) => s.id)));
|
||||
else setSubCheckedIds(new Set());
|
||||
}} />
|
||||
</TableHead>
|
||||
<TableHead className="w-[110px]">외주업체코드</TableHead>
|
||||
<TableHead className="min-w-[130px]">외주업체명</TableHead>
|
||||
<TableHead className="w-[80px]">거래유형</TableHead>
|
||||
<TableHead className="w-[80px]">담당자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subSearchResults.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||
) : subSearchResults.map((s) => (
|
||||
<TableRow key={s.id} className={cn("cursor-pointer", subCheckedIds.has(s.id) && "bg-primary/5")}
|
||||
onClick={() => setSubCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(s.id)) next.delete(s.id); else next.add(s.id);
|
||||
return next;
|
||||
})}>
|
||||
<TableCell className="text-center"><input type="checkbox" checked={subCheckedIds.has(s.id)} readOnly /></TableCell>
|
||||
<TableCell className="text-xs">{s.subcontractor_code}</TableCell>
|
||||
<TableCell className="text-sm">{s.subcontractor_name}</TableCell>
|
||||
<TableCell className="text-xs">{s.division}</TableCell>
|
||||
<TableCell className="text-xs">{s.contact_person}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<span className="text-sm text-muted-foreground">{subCheckedIds.size}개 선택됨</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setSubSelectOpen(false)}>취소</Button>
|
||||
<Button onClick={addSelectedSubcontractors} disabled={subCheckedIds.size === 0}>
|
||||
<Plus className="w-4 h-4 mr-1" /> {subCheckedIds.size}개 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 엑셀 업로드 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
tableName={ITEM_TABLE}
|
||||
userId={user?.userId}
|
||||
onSuccess={() => fetchItems()}
|
||||
/>
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={ITEM_TABLE}
|
||||
settingsId="subcontractor-item"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -733,15 +733,17 @@ export default function WorkInstructionPage() {
|
|||
<div className="max-h-[280px] overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead className="w-[100px] text-right">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
||||
<TableRow><TableHead className="w-[60px]">순번</TableHead><TableHead className="w-[120px]">품목코드</TableHead><TableHead>품목명</TableHead><TableHead className="w-[100px]">규격</TableHead><TableHead className="w-[100px] text-right">수량</TableHead><TableHead>비고</TableHead><TableHead className="w-[60px]" /></TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{editItems.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">품목이 없습니다</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={7} className="text-center py-8 text-muted-foreground text-sm">품목이 없습니다</TableCell></TableRow>
|
||||
) : editItems.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
|
||||
<TableCell className="text-xs max-w-[180px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
|
||||
<TableCell className="text-xs max-w-[100px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
|
||||
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
|
||||
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
|
||||
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,947 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
// Card, CardContent 제거 — DynamicSearchFilter가 대체
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download,
|
||||
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
|
||||
const DETAIL_TABLE = "sales_order_detail";
|
||||
|
||||
// 천단위 구분자 표시용 (입력 중에는 콤마 포함 표시, 저장 시 숫자만)
|
||||
const formatNumber = (val: string) => {
|
||||
const num = val.replace(/[^\d.-]/g, "");
|
||||
if (!num) return "";
|
||||
const parts = num.split(".");
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
return parts.join(".");
|
||||
};
|
||||
const parseNumber = (val: string) => val.replace(/,/g, "");
|
||||
const MASTER_TABLE = "sales_order_mng";
|
||||
|
||||
// 메인 목록 테이블 컬럼 (sales_order_detail 기준)
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "order_no", label: "수주번호", width: "w-[120px]" },
|
||||
{ key: "part_code", label: "품번", width: "w-[120px]", editable: true },
|
||||
{ key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true },
|
||||
{ key: "spec", label: "규격", width: "w-[120px]", editable: true },
|
||||
{ key: "unit", label: "단위", width: "w-[70px]", editable: true },
|
||||
{ key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
|
||||
{ key: "ship_qty", label: "출하수량", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||
{ key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||
{ key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
|
||||
{ key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" },
|
||||
{ key: "due_date", label: "납기일", width: "w-[110px]" },
|
||||
{ key: "memo", label: "메모", width: "w-[100px]", editable: true },
|
||||
];
|
||||
|
||||
// 조건부 레이어 설정 (input_mode, sell_mode에 따라 표시 필드가 달라짐)
|
||||
// Zone 10: input_mode → 공급업체우선(CAT_MLZWPH5R_983R) / 품목우선(CAT_MLZWPUQC_PB8Z)
|
||||
// Zone 17: sell_mode → 해외판매(CAT_MLZWFF2Z_BQCV)
|
||||
|
||||
export default function SalesOrderPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// 검색 필터 (DynamicSearchFilter에서 관리)
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
|
||||
// 모달
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
// isModalFullscreen 제거됨 — FullscreenDialog 사용
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
|
||||
const [detailRows, setDetailRows] = useState<any[]>([]);
|
||||
|
||||
// 품목 선택 모달 (리피터에서 품목 추가용)
|
||||
const [itemSelectOpen, setItemSelectOpen] = useState(false);
|
||||
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
|
||||
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
|
||||
const [itemSearchLoading, setItemSearchLoading] = useState(false);
|
||||
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
// 출하계획 모달
|
||||
const [shippingPlanOpen, setShippingPlanOpen] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// 체크된 행 (다중선택)
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
// 테이블 설정
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(GRID_COLUMNS);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
|
||||
// 테이블 설정 적용 (컬럼 + 필터)
|
||||
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||
// 컬럼 표시/숨김/순서/너비
|
||||
const colMap = new Map(GRID_COLUMNS.map((c) => [c.key, c]));
|
||||
const applied: DataGridColumn[] = [];
|
||||
for (const cs of settings.columns) {
|
||||
if (!cs.visible) continue;
|
||||
const orig = colMap.get(cs.columnName);
|
||||
if (orig) {
|
||||
applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined });
|
||||
}
|
||||
}
|
||||
const settingKeys = new Set(settings.columns.map((c) => c.columnName));
|
||||
for (const col of GRID_COLUMNS) {
|
||||
if (!settingKeys.has(col.key)) applied.push(col);
|
||||
}
|
||||
setGridColumns(applied.length > 0 ? applied : GRID_COLUMNS);
|
||||
|
||||
// 필터 설정 → DynamicSearchFilter에 전달
|
||||
setFilterConfig(settings.filters);
|
||||
}, []);
|
||||
|
||||
// 마운트 시 저장된 설정 복원
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings("sales-order");
|
||||
if (saved) applyTableSettings(saved);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
const catColumns = ["sell_mode", "input_mode", "price_mode", "incoterms", "payment_term"];
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
// 라벨 치환 + 중복 제거 (같은 label이면 첫 번째만 유지)
|
||||
const LABEL_REPLACE: Record<string, string> = {
|
||||
"공급업체 우선": "거래처 우선",
|
||||
"공급업체우선": "거래처 우선",
|
||||
};
|
||||
const dedup = (items: { code: string; label: string }[]) => {
|
||||
const seen = new Set<string>();
|
||||
return items
|
||||
.map((item) => ({ ...item, label: LABEL_REPLACE[item.label] || item.label }))
|
||||
.filter((item) => {
|
||||
const key = item.label.replace(/\s/g, "");
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
await Promise.all(
|
||||
catColumns.map(async (col) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
optMap[col] = dedup(flatten(res.data.data));
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
})
|
||||
);
|
||||
// 거래처 목록도 로드
|
||||
try {
|
||||
const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
|
||||
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
|
||||
} catch { /* skip */ }
|
||||
// 사용자 목록 로드 (담당자 선택용)
|
||||
try {
|
||||
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
|
||||
page: 1, size: 500, autoFilter: true,
|
||||
});
|
||||
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
|
||||
optMap["manager_id"] = users.map((u: any) => ({
|
||||
code: u.user_id || u.id,
|
||||
label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`,
|
||||
}));
|
||||
} catch { /* skip */ }
|
||||
// item_info 카테고리도 로드 (unit, material 등 코드→라벨 변환용)
|
||||
for (const col of ["unit", "material", "division", "type"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
optMap[`item_${col}`] = flatten(res.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setCategoryOptions(optMap);
|
||||
};
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// 데이터 조회
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters: any[] = searchFilters.map((f) => ({
|
||||
columnName: f.columnName,
|
||||
operator: f.operator,
|
||||
value: f.value,
|
||||
}));
|
||||
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
sort: { columnName: "order_no", order: "desc" },
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
|
||||
// part_code → item_info 조인 (품명/규격이 비어있는 경우 보강)
|
||||
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
|
||||
let itemMap: Record<string, any> = {};
|
||||
if (partCodes.length > 0) {
|
||||
try {
|
||||
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: partCodes.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: partCodes }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
for (const item of items) {
|
||||
itemMap[item.item_number] = item;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
// 조인 적용 + 카테고리 코드→라벨 변환
|
||||
const resolveLabel = (key: string, code: string) => {
|
||||
if (!code) return "";
|
||||
const opts = categoryOptions[key];
|
||||
if (!opts) return code;
|
||||
return opts.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
const data = rows.map((row: any) => {
|
||||
const item = itemMap[row.part_code];
|
||||
const rawUnit = row.unit || item?.unit || "";
|
||||
return {
|
||||
...row,
|
||||
part_name: row.part_name || item?.item_name || "",
|
||||
spec: row.spec || item?.size || "",
|
||||
unit: resolveLabel("item_unit", rawUnit) || rawUnit,
|
||||
};
|
||||
});
|
||||
|
||||
setOrders(data);
|
||||
setTotalCount(res.data?.data?.total || data.length);
|
||||
} catch (err) {
|
||||
console.error("수주 조회 실패:", err);
|
||||
toast.error("수주 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters]);
|
||||
|
||||
useEffect(() => { fetchOrders(); }, [fetchOrders]);
|
||||
|
||||
const getCategoryLabel = (col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
const found = categoryOptions[col]?.find((o) => o.code === code);
|
||||
return found?.label || code;
|
||||
};
|
||||
|
||||
// 등록 모달 열기
|
||||
// 납품처 목록 (거래처 선택 시 조회)
|
||||
const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]);
|
||||
|
||||
const loadDeliveryOptions = async (customerCode: string) => {
|
||||
if (!customerCode) { setDeliveryOptions([]); return; }
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
setDeliveryOptions(rows.map((r: any) => ({
|
||||
code: r.destination_code || r.id,
|
||||
label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`,
|
||||
})));
|
||||
} catch { setDeliveryOptions([]); }
|
||||
};
|
||||
|
||||
const openRegisterModal = () => {
|
||||
// 기본값: 각 카테고리의 첫 번째 옵션
|
||||
const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || "";
|
||||
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
|
||||
const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || "";
|
||||
setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "" });
|
||||
setDetailRows([]);
|
||||
setDeliveryOptions([]);
|
||||
setIsEditMode(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
// 수정 모달 열기 (order_no로 마스터 + 디테일 조회)
|
||||
const openEditModal = async (orderNo: string) => {
|
||||
try {
|
||||
// 마스터 조회
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masterData = (masterRes.data?.data?.data || masterRes.data?.data?.rows || [])[0];
|
||||
|
||||
// 디테일 조회
|
||||
const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
|
||||
|
||||
setMasterForm(masterData || {});
|
||||
setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` })));
|
||||
setIsEditMode(true);
|
||||
setIsModalOpen(true);
|
||||
} catch (err) {
|
||||
console.error("수주 상세 조회 실패:", err);
|
||||
toast.error("수주 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 (다중 선택)
|
||||
const handleDelete = async () => {
|
||||
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
|
||||
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
|
||||
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
|
||||
const ok = await confirm(`${checkedIds.length}건의 수주 데이터를 삭제하시겠습니까?`, {
|
||||
description: "삭제된 데이터는 복구할 수 없습니다.",
|
||||
variant: "destructive",
|
||||
confirmText: "삭제",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
// 선택된 디테일 행 삭제
|
||||
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
||||
data: checkedIds.map((id) => ({ id })),
|
||||
});
|
||||
// 해당 수주번호의 남은 디테일이 없으면 마스터도 삭제
|
||||
for (const orderNo of orderNos) {
|
||||
const remaining = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = remaining.data?.data?.data || remaining.data?.data?.rows || [];
|
||||
if (rows.length === 0) {
|
||||
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
if (masters.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
|
||||
data: masters.map((m: any) => ({ id: m.id })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success("삭제되었습니다.");
|
||||
setCheckedIds([]);
|
||||
fetchOrders();
|
||||
} catch (err) {
|
||||
console.error("삭제 실패:", err);
|
||||
toast.error("삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 저장 (마스터 + 디테일)
|
||||
const handleSave = async () => {
|
||||
if (!masterForm.order_no && !isEditMode) {
|
||||
toast.error("수주번호는 필수입니다.");
|
||||
return;
|
||||
}
|
||||
if (detailRows.length === 0) {
|
||||
toast.error("품목을 1개 이상 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm;
|
||||
|
||||
if (isEditMode && id) {
|
||||
// 마스터 수정
|
||||
await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, {
|
||||
originalData: { id },
|
||||
updatedData: masterFields,
|
||||
});
|
||||
// 기존 디테일 삭제 후 재삽입
|
||||
const existingDetails = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: masterForm.order_no }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const existings = existingDetails.data?.data?.data || existingDetails.data?.data?.rows || [];
|
||||
if (existings.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
||||
data: existings.map((d: any) => ({ id: d.id })),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 마스터 등록
|
||||
await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields);
|
||||
}
|
||||
|
||||
// 디테일 등록
|
||||
for (const row of detailRows) {
|
||||
const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
|
||||
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
|
||||
...detailFields,
|
||||
order_no: masterForm.order_no,
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(isEditMode ? "수정되었습니다." : "등록되었습니다.");
|
||||
setIsModalOpen(false);
|
||||
fetchOrders();
|
||||
} catch (err: any) {
|
||||
console.error("저장 실패:", err);
|
||||
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 품목 검색 (리피터에서 추가)
|
||||
const searchItems = async () => {
|
||||
setItemSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (itemSearchKeyword) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 50,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
|
||||
} catch { /* skip */ } finally {
|
||||
setItemSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addSelectedItemsToDetail = async () => {
|
||||
const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id));
|
||||
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
|
||||
|
||||
// 단가방식에 따라 단가 조회
|
||||
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
|
||||
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
|
||||
const partnerId = masterForm.partner_id;
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
// 거래처별 단가 조회 (선택된 품목들에 대해)
|
||||
let customerPriceMap: Record<string, string> = {};
|
||||
if (isCustomerPrice && partnerId) {
|
||||
try {
|
||||
const itemIds = selected.map((item) => item.item_number || item.id);
|
||||
const res = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
filters: [
|
||||
{ columnName: "customer_id", operator: "equals", value: partnerId },
|
||||
{ columnName: "item_id", operator: "in", value: itemIds },
|
||||
],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
for (const m of mappings) {
|
||||
// calculated_price 우선, 없으면 current_unit_price
|
||||
const price = m.calculated_price || m.current_unit_price || "";
|
||||
if (price) customerPriceMap[m.item_id] = String(price);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("거래처별 단가 조회 실패:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const newRows = selected.map((item) => {
|
||||
const itemCode = item.item_number || item.id;
|
||||
let unitPrice = "";
|
||||
|
||||
if (isStandardPrice) {
|
||||
// 기준단가: item_info의 standard_price 또는 selling_price
|
||||
unitPrice = item.standard_price || item.selling_price || "";
|
||||
} else if (isCustomerPrice && partnerId) {
|
||||
// 거래처별 단가
|
||||
unitPrice = customerPriceMap[itemCode] || "";
|
||||
}
|
||||
|
||||
return {
|
||||
_id: `new_${Date.now()}_${Math.random()}`,
|
||||
part_code: itemCode,
|
||||
part_name: item.item_name,
|
||||
spec: item.size || "",
|
||||
material: getCategoryLabel("item_material", item.material) || item.material || "",
|
||||
unit: getCategoryLabel("item_unit", item.unit) || item.unit || "",
|
||||
qty: "",
|
||||
unit_price: unitPrice,
|
||||
amount: "",
|
||||
due_date: "",
|
||||
};
|
||||
});
|
||||
|
||||
setDetailRows((prev) => [...prev, ...newRows]);
|
||||
toast.success(`${selected.length}개 품목이 추가되었습니다.`);
|
||||
setItemCheckedIds(new Set());
|
||||
setItemSelectOpen(false);
|
||||
};
|
||||
|
||||
const updateDetailRow = (idx: number, field: string, value: string) => {
|
||||
setDetailRows((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
// 수량 × 단가 = 금액 자동 계산
|
||||
if (field === "qty" || field === "unit_price") {
|
||||
const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0;
|
||||
const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0;
|
||||
next[idx].amount = (qty * price).toString();
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeDetailRow = (idx: number) => {
|
||||
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
// input_mode 값으로 레이어 판단
|
||||
// 거래처 우선 (구: 공급업체 우선) - 두 코드 모두 지원
|
||||
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
|
||||
const isItemFirst = masterForm.input_mode === "CAT_MLZWPUQC_PB8Z" || masterForm.input_mode === "CAT_MLKG5FZO_HS1B";
|
||||
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
|
||||
|
||||
const handleExcelDownload = async () => {
|
||||
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||
const data = orders.map((o) => {
|
||||
const row: Record<string, any> = {};
|
||||
for (const col of GRID_COLUMNS) row[col.label] = o[col.key] || "";
|
||||
return row;
|
||||
});
|
||||
await exportToExcel(data, "수주관리.xlsx", "수주목록");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 필터 (사용자 설정 가능) */}
|
||||
<DynamicSearchFilter
|
||||
tableName={DETAIL_TABLE}
|
||||
filterId="sales-order"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={totalCount}
|
||||
externalFilterConfig={filterConfig}
|
||||
/>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<ClipboardList className="w-5 h-5" /> 수주 목록
|
||||
<Badge variant="secondary" className="font-normal">{totalCount}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-4 h-4 mr-1.5" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||
<Download className="w-4 h-4 mr-1.5" /> 엑셀 다운로드
|
||||
</Button>
|
||||
<Button size="sm" onClick={openRegisterModal}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> 수주 등록
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={checkedIds.length !== 1} onClick={() => {
|
||||
const item = orders.find((o) => o.id === checkedIds[0]);
|
||||
if (item) openEditModal(item.order_no);
|
||||
}}>
|
||||
<Pencil className="w-4 h-4 mr-1.5" /> 수정
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" /> 삭제 {checkedIds.length > 0 && `(${checkedIds.length})`}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={checkedIds.length === 0} onClick={() => setShippingPlanOpen(true)}>
|
||||
<Truck className="w-4 h-4 mr-1.5" /> 출하계획 {checkedIds.length > 0 && `(${checkedIds.length})`}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
gridId="sales-order"
|
||||
columns={gridColumns}
|
||||
data={orders}
|
||||
loading={loading}
|
||||
showCheckbox
|
||||
showRowNumber={false}
|
||||
checkedIds={checkedIds}
|
||||
onCheckedChange={setCheckedIds}
|
||||
onRowDoubleClick={(row) => openEditModal(row.order_no)}
|
||||
tableName={DETAIL_TABLE}
|
||||
emptyMessage="등록된 수주가 없습니다"
|
||||
onCellEdit={() => fetchOrders()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수주 등록/수정 모달 */}
|
||||
<FullscreenDialog
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
title={isEditMode ? "수주 수정" : "수주 등록"}
|
||||
description={isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 기본 레이어 (항상 표시) */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">수주번호 <span className="text-destructive">*</span></Label>
|
||||
<Input value={masterForm.order_no || ""} onChange={(e) => setMasterForm((p) => ({ ...p, order_no: e.target.value }))}
|
||||
placeholder="수주번호" className="h-9" disabled={isEditMode} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">수주일</Label>
|
||||
<FormDatePicker value={masterForm.order_date || ""} onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} placeholder="수주일" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">판매 유형</Label>
|
||||
<Select value={masterForm.sell_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, sell_mode: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["sell_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">입력방식</Label>
|
||||
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["input_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">단가방식</Label>
|
||||
<Select value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["price_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 레이어 2: 거래처 우선 (거래처, 담당자, 납품처, 납품장소) */}
|
||||
{isSupplierFirst && (
|
||||
<div className="grid grid-cols-4 gap-4 border-t pt-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">거래처</Label>
|
||||
<Select value={masterForm.partner_id || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">담당자</Label>
|
||||
<Select value={masterForm.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["manager_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">납품처</Label>
|
||||
{deliveryOptions.length > 0 ? (
|
||||
<Select value={masterForm.delivery_partner_id || ""} onValueChange={(v) => {
|
||||
setMasterForm((p) => ({ ...p, delivery_partner_id: v }));
|
||||
// 선택한 납품처의 주소를 자동 입력
|
||||
const found = deliveryOptions.find((o) => o.code === v);
|
||||
if (found) {
|
||||
const addr = found.label.match(/\((.+)\)$/)?.[1] || "";
|
||||
if (addr) setMasterForm((p) => ({ ...p, delivery_partner_id: v, delivery_address: addr }));
|
||||
}
|
||||
}}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="납품처 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{deliveryOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input value={masterForm.delivery_partner_id || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))}
|
||||
placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">납품장소</Label>
|
||||
<Input value={masterForm.delivery_address || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))}
|
||||
placeholder="납품장소" className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 레이어 4: 해외판매 (인코텀즈, 결제조건, 통화, 선적항, 도착항, HS코드) */}
|
||||
{isOverseas && (
|
||||
<div className="grid grid-cols-3 gap-4 border-t pt-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">인코텀즈</Label>
|
||||
<Select value={masterForm.incoterms || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, incoterms: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["incoterms"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">결제조건</Label>
|
||||
<Select value={masterForm.payment_term || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, payment_term: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["payment_term"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">통화</Label>
|
||||
<Input value={masterForm.currency || ""} onChange={(e) => setMasterForm((p) => ({ ...p, currency: e.target.value }))}
|
||||
placeholder="KRW" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">선적항</Label>
|
||||
<Input value={masterForm.port_of_loading || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_loading: e.target.value }))}
|
||||
className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">도착항</Label>
|
||||
<Input value={masterForm.port_of_discharge || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_discharge: e.target.value }))}
|
||||
className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">HS Code</Label>
|
||||
<Input value={masterForm.hs_code || ""} onChange={(e) => setMasterForm((p) => ({ ...p, hs_code: e.target.value }))}
|
||||
className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 리피터 그리드 (품목 목록) — 레이어 2,3 공통 */}
|
||||
<div className="border rounded-lg">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10">
|
||||
<span className="text-sm font-semibold">수주 품목</span>
|
||||
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
|
||||
<Plus className="w-4 h-4 mr-1" /> 품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[300px]">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]"></TableHead>
|
||||
<TableHead className="w-[120px]">품번</TableHead>
|
||||
<TableHead className="min-w-[120px]">품명</TableHead>
|
||||
<TableHead className="w-[80px]">규격</TableHead>
|
||||
<TableHead className="w-[60px]">단위</TableHead>
|
||||
<TableHead className="w-[110px]">수량</TableHead>
|
||||
<TableHead className="w-[120px]">단가</TableHead>
|
||||
<TableHead className="w-[110px]">금액</TableHead>
|
||||
<TableHead className="w-[200px]">납기일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailRows.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8">품목을 추가해주세요</TableCell></TableRow>
|
||||
) : detailRows.map((row, idx) => (
|
||||
<TableRow key={row._id || idx}>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_code}>{row.part_code}</span></TableCell>
|
||||
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
|
||||
<TableCell className="text-xs">{row.spec}</TableCell>
|
||||
<TableCell className="text-xs">{row.unit}</TableCell>
|
||||
<TableCell>
|
||||
<Input value={formatNumber(row.qty || "")} onChange={(e) => updateDetailRow(idx, "qty", parseNumber(e.target.value))}
|
||||
className="h-8 text-sm text-right" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
|
||||
className="h-8 text-sm text-right" />
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-right font-medium">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
|
||||
<TableCell>
|
||||
<FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메모 */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">메모</Label>
|
||||
<Input value={masterForm.memo || ""} onChange={(e) => setMasterForm((p) => ({ ...p, memo: e.target.value }))}
|
||||
placeholder="메모" className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 선택 모달 (등록 모달 내부에 중첩) */}
|
||||
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
|
||||
<DialogContent className="max-w-3xl max-h-[70vh]" onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>품목 선택</DialogTitle>
|
||||
<DialogDescription>수주에 추가할 품목을 선택 후 하단 버튼을 눌러주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Input placeholder="품명/품목코드 검색" value={itemSearchKeyword}
|
||||
onChange={(e) => setItemSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchItems()}
|
||||
className="h-9 flex-1" />
|
||||
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
|
||||
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<input type="checkbox"
|
||||
checked={itemSearchResults.length > 0 && itemCheckedIds.size === itemSearchResults.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id)));
|
||||
else setItemCheckedIds(new Set());
|
||||
}} />
|
||||
</TableHead>
|
||||
<TableHead className="w-[130px]">품목코드</TableHead>
|
||||
<TableHead className="min-w-[150px]">품명</TableHead>
|
||||
<TableHead className="w-[100px]">규격</TableHead>
|
||||
<TableHead className="w-[100px]">재질</TableHead>
|
||||
<TableHead className="w-[60px]">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemSearchResults.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||
) : itemSearchResults.map((item) => (
|
||||
<TableRow key={item.id} className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
|
||||
onClick={() => setItemCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
|
||||
return next;
|
||||
})}>
|
||||
<TableCell className="text-center">
|
||||
<input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs max-w-[130px]"><span className="block truncate" title={item.item_number}>{item.item_number}</span></TableCell>
|
||||
<TableCell className="text-sm max-w-[150px]"><span className="block truncate" title={item.item_name}>{item.item_name}</span></TableCell>
|
||||
<TableCell className="text-xs">{item.size}</TableCell>
|
||||
<TableCell className="text-xs">{item.material}</TableCell>
|
||||
<TableCell className="text-xs">{item.unit}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<span className="text-sm text-muted-foreground">{itemCheckedIds.size}개 선택됨</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(false); }}>취소</Button>
|
||||
<Button onClick={addSelectedItemsToDetail} disabled={itemCheckedIds.size === 0}>
|
||||
<Plus className="w-4 h-4 mr-1" /> {itemCheckedIds.size}개 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</FullscreenDialog>
|
||||
|
||||
{/* 출하계획 동시 등록 모달 */}
|
||||
<ShippingPlanBatchModal
|
||||
open={shippingPlanOpen}
|
||||
onOpenChange={setShippingPlanOpen}
|
||||
selectedDetailIds={checkedIds}
|
||||
onSuccess={fetchOrders}
|
||||
/>
|
||||
|
||||
{/* 엑셀 업로드 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
tableName={DETAIL_TABLE}
|
||||
userId={user?.userId}
|
||||
onSuccess={() => fetchOrders()}
|
||||
/>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={DETAIL_TABLE}
|
||||
settingsId="sales-order"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
|
||||
{/* 공통 확인 다이얼로그 */}
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,917 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 판매품목정보 — 하드코딩 페이지
|
||||
*
|
||||
* 좌측: 판매품목 목록 (item_info, 판매 관련 필터)
|
||||
* 우측: 선택한 품목의 거래처 정보 (customer_item_mapping → customer_mng 조인)
|
||||
*
|
||||
* 거래처관리와 양방향 연동 (같은 customer_item_mapping 테이블)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X, Settings2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
|
||||
const ITEM_TABLE = "item_info";
|
||||
const MAPPING_TABLE = "customer_item_mapping";
|
||||
const CUSTOMER_TABLE = "customer_mng";
|
||||
|
||||
// 좌측: 판매품목 컬럼
|
||||
const LEFT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "item_number", label: "품번", width: "w-[110px]" },
|
||||
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
|
||||
{ key: "size", label: "규격", width: "w-[90px]" },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
|
||||
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||
{ key: "status", label: "상태", width: "w-[60px]" },
|
||||
];
|
||||
|
||||
// 우측: 거래처 정보 컬럼
|
||||
const RIGHT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "customer_code", label: "거래처코드", width: "w-[110px]" },
|
||||
{ key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" },
|
||||
{ key: "customer_item_code", label: "거래처품번", width: "w-[100px]" },
|
||||
{ key: "customer_item_name", label: "거래처품명", width: "w-[100px]" },
|
||||
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
|
||||
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||
];
|
||||
|
||||
export default function SalesItemPage() {
|
||||
const { user } = useAuth();
|
||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
|
||||
// 좌측: 품목
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [itemLoading, setItemLoading] = useState(false);
|
||||
const [itemCount, setItemCount] = useState(0);
|
||||
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
|
||||
|
||||
// 테이블 설정
|
||||
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
|
||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
|
||||
|
||||
// 우측: 거래처
|
||||
const [customerItems, setCustomerItems] = useState<any[]>([]);
|
||||
const [customerLoading, setCustomerLoading] = useState(false);
|
||||
|
||||
// 카테고리
|
||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// 거래처 추가 모달
|
||||
const [custSelectOpen, setCustSelectOpen] = useState(false);
|
||||
const [custSearchKeyword, setCustSearchKeyword] = useState("");
|
||||
const [custSearchResults, setCustSearchResults] = useState<any[]>([]);
|
||||
const [custSearchLoading, setCustSearchLoading] = useState(false);
|
||||
const [custCheckedIds, setCustCheckedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 품목 수정 모달
|
||||
const [editItemOpen, setEditItemOpen] = useState(false);
|
||||
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 엑셀
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
// 거래처 상세 입력 모달 (거래처 품번/품명 + 단가)
|
||||
const [custDetailOpen, setCustDetailOpen] = useState(false);
|
||||
const [selectedCustsForDetail, setSelectedCustsForDetail] = useState<any[]>([]);
|
||||
const [custMappings, setCustMappings] = useState<Record<string, Array<{ _id: string; customer_item_code: string; customer_item_name: string }>>>({});
|
||||
const [custPrices, setCustPrices] = useState<Record<string, Array<{
|
||||
_id: string; start_date: string; end_date: string; currency_code: string;
|
||||
base_price_type: string; base_price: string; discount_type: string;
|
||||
discount_value: string; rounding_type: string; rounding_unit_value: string;
|
||||
calculated_price: string;
|
||||
}>>>({});
|
||||
const [priceCategoryOptions, setPriceCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
const [editCustData, setEditCustData] = useState<any>(null);
|
||||
|
||||
// 테이블 설정 적용 (필터)
|
||||
const applyTableSettings = useCallback((settings: TableSettings) => {
|
||||
setFilterConfig(settings.filters);
|
||||
}, []);
|
||||
|
||||
// 마운트 시 저장된 설정 복원
|
||||
useEffect(() => {
|
||||
const saved = loadTableSettings("sales-item");
|
||||
if (saved) applyTableSettings(saved);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const result: { code: string; label: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ code: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
|
||||
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setCategoryOptions(optMap);
|
||||
|
||||
// 단가 카테고리
|
||||
const priceOpts: Record<string, { code: string; label: string }[]> = {};
|
||||
for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/customer_item_prices/${col}/values`);
|
||||
if (res.data?.success) priceOpts[col] = flatten(res.data.data || []);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
setPriceCategoryOptions(priceOpts);
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const resolve = (col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// 좌측: 품목 조회
|
||||
const fetchItems = useCallback(async () => {
|
||||
setItemLoading(true);
|
||||
try {
|
||||
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
||||
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
|
||||
const data = raw.map((r: any) => {
|
||||
const converted = { ...r };
|
||||
for (const col of CATS) {
|
||||
if (converted[col]) converted[col] = resolve(col, converted[col]);
|
||||
}
|
||||
return converted;
|
||||
});
|
||||
setItems(data);
|
||||
setItemCount(res.data?.data?.total || raw.length);
|
||||
} catch (err) {
|
||||
console.error("품목 조회 실패:", err);
|
||||
toast.error("품목 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setItemLoading(false);
|
||||
}
|
||||
}, [searchFilters, categoryOptions]);
|
||||
|
||||
useEffect(() => { fetchItems(); }, [fetchItems]);
|
||||
|
||||
// 선택된 품목
|
||||
const selectedItem = items.find((i) => i.id === selectedItemId);
|
||||
|
||||
// 우측: 거래처 목록 조회
|
||||
useEffect(() => {
|
||||
if (!selectedItem?.item_number) { setCustomerItems([]); return; }
|
||||
const itemKey = selectedItem.item_number;
|
||||
const fetchCustomerItems = async () => {
|
||||
setCustomerLoading(true);
|
||||
try {
|
||||
// customer_item_mapping에서 해당 품목의 매핑 조회
|
||||
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
|
||||
page: 1, size: 500,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
|
||||
|
||||
// customer_id → customer_mng 조인 (거래처명)
|
||||
const custIds = [...new Set(mappings.map((m: any) => m.customer_id).filter(Boolean))];
|
||||
let custMap: Record<string, any> = {};
|
||||
if (custIds.length > 0) {
|
||||
try {
|
||||
const custRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
|
||||
page: 1, size: custIds.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "in", value: custIds }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
for (const c of (custRes.data?.data?.data || custRes.data?.data?.rows || [])) {
|
||||
custMap[c.customer_code] = c;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
setCustomerItems(mappings.map((m: any) => ({
|
||||
...m,
|
||||
customer_code: m.customer_id,
|
||||
customer_name: custMap[m.customer_id]?.customer_name || "",
|
||||
})));
|
||||
} catch (err) {
|
||||
console.error("거래처 조회 실패:", err);
|
||||
} finally {
|
||||
setCustomerLoading(false);
|
||||
}
|
||||
};
|
||||
fetchCustomerItems();
|
||||
}, [selectedItem?.item_number]);
|
||||
|
||||
// 거래처 검색
|
||||
const searchCustomers = async () => {
|
||||
setCustSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (custSearchKeyword) filters.push({ columnName: "customer_name", operator: "contains", value: custSearchKeyword });
|
||||
const res = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
|
||||
page: 1, size: 50,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
const all = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// 이미 등록된 거래처 제외
|
||||
const existing = new Set(customerItems.map((c: any) => c.customer_id || c.customer_code));
|
||||
setCustSearchResults(all.filter((c: any) => !existing.has(c.customer_code)));
|
||||
} catch { /* skip */ } finally { setCustSearchLoading(false); }
|
||||
};
|
||||
|
||||
// 거래처 선택 → 상세 모달로 이동
|
||||
const goToCustDetail = () => {
|
||||
const selected = custSearchResults.filter((c) => custCheckedIds.has(c.id));
|
||||
if (selected.length === 0) { toast.error("거래처를 선택해주세요."); return; }
|
||||
setSelectedCustsForDetail(selected);
|
||||
const mappings: typeof custMappings = {};
|
||||
const prices: typeof custPrices = {};
|
||||
for (const cust of selected) {
|
||||
const key = cust.customer_code || cust.id;
|
||||
mappings[key] = [];
|
||||
prices[key] = [{
|
||||
_id: `p_${Date.now()}_${Math.random()}`,
|
||||
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
|
||||
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
|
||||
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
|
||||
calculated_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
|
||||
}];
|
||||
}
|
||||
setCustMappings(mappings);
|
||||
setCustPrices(prices);
|
||||
setCustSelectOpen(false);
|
||||
setCustDetailOpen(true);
|
||||
};
|
||||
|
||||
const addMappingRow = (custKey: string) => {
|
||||
setCustMappings((prev) => ({
|
||||
...prev,
|
||||
[custKey]: [...(prev[custKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, customer_item_code: "", customer_item_name: "" }],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeMappingRow = (custKey: string, rowId: string) => {
|
||||
setCustMappings((prev) => ({
|
||||
...prev,
|
||||
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateMappingRow = (custKey: string, rowId: string, field: string, value: string) => {
|
||||
setCustMappings((prev) => ({
|
||||
...prev,
|
||||
[custKey]: (prev[custKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r),
|
||||
}));
|
||||
};
|
||||
|
||||
const addPriceRow = (custKey: string) => {
|
||||
setCustPrices((prev) => ({
|
||||
...prev,
|
||||
[custKey]: [...(prev[custKey] || []), {
|
||||
_id: `p_${Date.now()}_${Math.random()}`,
|
||||
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
|
||||
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "",
|
||||
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
|
||||
calculated_price: "",
|
||||
}],
|
||||
}));
|
||||
};
|
||||
|
||||
const removePriceRow = (custKey: string, rowId: string) => {
|
||||
setCustPrices((prev) => ({
|
||||
...prev,
|
||||
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
|
||||
}));
|
||||
};
|
||||
|
||||
const updatePriceRow = (custKey: string, rowId: string, field: string, value: string) => {
|
||||
setCustPrices((prev) => ({
|
||||
...prev,
|
||||
[custKey]: (prev[custKey] || []).map((r) => {
|
||||
if (r._id !== rowId) return r;
|
||||
const updated = { ...r, [field]: value };
|
||||
if (["base_price", "discount_type", "discount_value"].includes(field)) {
|
||||
const bp = Number(updated.base_price) || 0;
|
||||
const dv = Number(updated.discount_value) || 0;
|
||||
const dt = updated.discount_type;
|
||||
let calc = bp;
|
||||
if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100);
|
||||
else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv;
|
||||
updated.calculated_price = String(Math.round(calc));
|
||||
}
|
||||
return updated;
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
const openEditCust = async (row: any) => {
|
||||
const custKey = row.customer_code || row.customer_id;
|
||||
|
||||
// customer_mng에서 거래처 정보 조회
|
||||
let custInfo: any = { customer_code: custKey, customer_name: row.customer_name || "" };
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, {
|
||||
page: 1, size: 1,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: custKey }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
const found = (res.data?.data?.data || res.data?.data?.rows || [])[0];
|
||||
if (found) custInfo = found;
|
||||
} catch { /* skip */ }
|
||||
|
||||
const mappingRows = [{
|
||||
_id: `m_existing_${row.id}`,
|
||||
customer_item_code: row.customer_item_code || "",
|
||||
customer_item_name: row.customer_item_name || "",
|
||||
}].filter((m) => m.customer_item_code || m.customer_item_name);
|
||||
|
||||
const priceRows = [{
|
||||
_id: `p_existing_${row.id}`,
|
||||
start_date: row.start_date || "",
|
||||
end_date: row.end_date || "",
|
||||
currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI",
|
||||
base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW",
|
||||
base_price: row.base_price ? String(row.base_price) : "",
|
||||
discount_type: row.discount_type || "",
|
||||
discount_value: row.discount_value ? String(row.discount_value) : "",
|
||||
rounding_type: row.rounding_type || "",
|
||||
rounding_unit_value: row.rounding_unit_value || "",
|
||||
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
|
||||
}].filter((p) => p.base_price || p.start_date);
|
||||
|
||||
if (priceRows.length === 0) {
|
||||
priceRows.push({
|
||||
_id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
|
||||
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "",
|
||||
rounding_type: "", rounding_unit_value: "", calculated_price: "",
|
||||
});
|
||||
}
|
||||
|
||||
setSelectedCustsForDetail([custInfo]);
|
||||
setCustMappings({ [custKey]: mappingRows });
|
||||
setCustPrices({ [custKey]: priceRows });
|
||||
setEditCustData(row);
|
||||
setCustDetailOpen(true);
|
||||
};
|
||||
|
||||
const handleCustDetailSave = async () => {
|
||||
if (!selectedItem) return;
|
||||
const isEditingExisting = !!editCustData;
|
||||
setSaving(true);
|
||||
try {
|
||||
for (const cust of selectedCustsForDetail) {
|
||||
const custKey = cust.customer_code || cust.id;
|
||||
const mappingRows = custMappings[custKey] || [];
|
||||
|
||||
if (isEditingExisting && editCustData?.id) {
|
||||
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
|
||||
originalData: { id: editCustData.id },
|
||||
updatedData: {
|
||||
customer_item_code: mappingRows[0]?.customer_item_code || "",
|
||||
customer_item_name: mappingRows[0]?.customer_item_name || "",
|
||||
},
|
||||
});
|
||||
|
||||
// 기존 prices 삭제 후 재등록
|
||||
try {
|
||||
const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
|
||||
page: 1, size: 100,
|
||||
dataFilter: { enabled: true, filters: [
|
||||
{ columnName: "mapping_id", operator: "equals", value: editCustData.id },
|
||||
]}, autoFilter: true,
|
||||
});
|
||||
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
|
||||
if (existing.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
|
||||
data: existing.map((p: any) => ({ id: p.id })),
|
||||
});
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
const priceRows = (custPrices[custKey] || []).filter((p) =>
|
||||
(p.base_price && Number(p.base_price) > 0) || p.start_date
|
||||
);
|
||||
for (const price of priceRows) {
|
||||
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
|
||||
mapping_id: editCustData.id,
|
||||
customer_id: custKey,
|
||||
item_id: selectedItem.item_number,
|
||||
start_date: price.start_date || null, end_date: price.end_date || null,
|
||||
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
|
||||
base_price: price.base_price ? Number(price.base_price) : null,
|
||||
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
|
||||
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
|
||||
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 신규 등록
|
||||
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
||||
customer_id: custKey, item_id: selectedItem.item_number,
|
||||
customer_item_code: mappingRows[0]?.customer_item_code || "",
|
||||
customer_item_name: mappingRows[0]?.customer_item_name || "",
|
||||
});
|
||||
const mappingId = mappingRes.data?.data?.id || null;
|
||||
|
||||
for (let mi = 1; mi < mappingRows.length; mi++) {
|
||||
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
|
||||
customer_id: custKey, item_id: selectedItem.item_number,
|
||||
customer_item_code: mappingRows[mi].customer_item_code || "",
|
||||
customer_item_name: mappingRows[mi].customer_item_name || "",
|
||||
});
|
||||
}
|
||||
|
||||
const priceRows = (custPrices[custKey] || []).filter((p) =>
|
||||
(p.base_price && Number(p.base_price) > 0) || p.start_date
|
||||
);
|
||||
for (const price of priceRows) {
|
||||
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
|
||||
mapping_id: mappingId || "", customer_id: custKey, item_id: selectedItem.item_number,
|
||||
start_date: price.start_date || null, end_date: price.end_date || null,
|
||||
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
|
||||
base_price: price.base_price ? Number(price.base_price) : null,
|
||||
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
|
||||
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
|
||||
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success(isEditingExisting ? "수정되었습니다." : `${selectedCustsForDetail.length}개 거래처가 추가되었습니다.`);
|
||||
setCustDetailOpen(false);
|
||||
setEditCustData(null);
|
||||
setCustCheckedIds(new Set());
|
||||
// 우측 새로고침
|
||||
const sid = selectedItemId;
|
||||
setSelectedItemId(null);
|
||||
setTimeout(() => setSelectedItemId(sid), 50);
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 품목 수정
|
||||
const openEditItem = () => {
|
||||
if (!selectedItem) return;
|
||||
setEditItemForm({ ...selectedItem });
|
||||
setEditItemOpen(true);
|
||||
};
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!editItemForm.id) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
|
||||
originalData: { id: editItemForm.id },
|
||||
updatedData: {
|
||||
selling_price: editItemForm.selling_price || null,
|
||||
standard_price: editItemForm.standard_price || null,
|
||||
currency_code: editItemForm.currency_code || null,
|
||||
},
|
||||
});
|
||||
toast.success("수정되었습니다.");
|
||||
setEditItemOpen(false);
|
||||
fetchItems();
|
||||
} catch (err: any) {
|
||||
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 엑셀 다운로드
|
||||
const handleExcelDownload = async () => {
|
||||
if (items.length === 0) return;
|
||||
const data = items.map((i) => ({
|
||||
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
|
||||
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
|
||||
}));
|
||||
await exportToExcel(data, "판매품목정보.xlsx", "판매품목");
|
||||
toast.success("다운로드 완료");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 p-3">
|
||||
{/* 검색 */}
|
||||
<DynamicSearchFilter
|
||||
tableName={ITEM_TABLE}
|
||||
filterId="sales-item"
|
||||
onFilterChange={setSearchFilters}
|
||||
dataCount={itemCount}
|
||||
externalFilterConfig={filterConfig}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
|
||||
<Settings2 className="w-4 h-4 mr-1.5" /> 테이블 설정
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
|
||||
<Download className="w-3.5 h-3.5 mr-1" /> 엑셀 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 분할 패널 */}
|
||||
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
{/* 좌측: 판매품목 */}
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Package className="w-4 h-4" /> 판매품목 목록
|
||||
<Badge variant="secondary" className="font-normal">{itemCount}건</Badge>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" /> 수정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataGrid
|
||||
gridId="sales-item-left"
|
||||
columns={LEFT_COLUMNS}
|
||||
data={items}
|
||||
loading={itemLoading}
|
||||
selectedId={selectedItemId}
|
||||
onSelect={setSelectedItemId}
|
||||
onRowDoubleClick={() => openEditItem()}
|
||||
emptyMessage="등록된 판매품목이 없습니다"
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle />
|
||||
|
||||
{/* 우측: 거래처 정보 */}
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
<Users className="w-4 h-4" /> 거래처 정보
|
||||
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled={!selectedItemId}
|
||||
onClick={() => { setCustCheckedIds(new Set()); setCustSelectOpen(true); searchCustomers(); }}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" /> 거래처 추가
|
||||
</Button>
|
||||
</div>
|
||||
{!selectedItemId ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
|
||||
좌측에서 품목을 선택하세요
|
||||
</div>
|
||||
) : (
|
||||
<DataGrid
|
||||
gridId="sales-item-right"
|
||||
columns={RIGHT_COLUMNS}
|
||||
data={customerItems}
|
||||
loading={customerLoading}
|
||||
showRowNumber={false}
|
||||
emptyMessage="등록된 거래처가 없습니다"
|
||||
onRowDoubleClick={(row) => openEditCust(row)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* 품목 수정 모달 */}
|
||||
<FullscreenDialog
|
||||
open={editItemOpen}
|
||||
onOpenChange={setEditItemOpen}
|
||||
title="판매품목 수정"
|
||||
description={`${editItemForm.item_number || ""} — ${editItemForm.item_name || ""}`}
|
||||
defaultMaxWidth="max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setEditItemOpen(false)}>취소</Button>
|
||||
<Button onClick={handleEditSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 py-4">
|
||||
{/* 품목 기본정보 (읽기 전용) */}
|
||||
{[
|
||||
{ key: "item_number", label: "품목코드" },
|
||||
{ key: "item_name", label: "품명" },
|
||||
{ key: "size", label: "규격" },
|
||||
{ key: "unit", label: "단위" },
|
||||
{ key: "material", label: "재질" },
|
||||
{ key: "status", label: "상태" },
|
||||
].map((f) => (
|
||||
<div key={f.key} className="space-y-1.5">
|
||||
<Label className="text-sm text-muted-foreground">{f.label}</Label>
|
||||
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="col-span-2 border-t my-2" />
|
||||
|
||||
{/* 판매 설정 (수정 가능) */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">판매가격</Label>
|
||||
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
|
||||
placeholder="판매가격" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">기준단가</Label>
|
||||
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
|
||||
placeholder="기준단가" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">통화</Label>
|
||||
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
|
||||
{/* 거래처 추가 모달 */}
|
||||
<Dialog open={custSelectOpen} onOpenChange={setCustSelectOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>거래처 선택</DialogTitle>
|
||||
<DialogDescription>품목에 추가할 거래처를 선택하세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Input placeholder="거래처명 검색" value={custSearchKeyword}
|
||||
onChange={(e) => setCustSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchCustomers()}
|
||||
className="h-9 flex-1" />
|
||||
<Button size="sm" onClick={searchCustomers} disabled={custSearchLoading} className="h-9">
|
||||
{custSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> 조회</>}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto max-h-[350px] border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<input type="checkbox"
|
||||
checked={custSearchResults.length > 0 && custCheckedIds.size === custSearchResults.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) setCustCheckedIds(new Set(custSearchResults.map((c) => c.id)));
|
||||
else setCustCheckedIds(new Set());
|
||||
}} />
|
||||
</TableHead>
|
||||
<TableHead className="w-[110px]">거래처코드</TableHead>
|
||||
<TableHead className="min-w-[130px]">거래처명</TableHead>
|
||||
<TableHead className="w-[80px]">거래유형</TableHead>
|
||||
<TableHead className="w-[80px]">담당자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{custSearchResults.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">검색 결과가 없습니다</TableCell></TableRow>
|
||||
) : custSearchResults.map((c) => (
|
||||
<TableRow key={c.id} className={cn("cursor-pointer", custCheckedIds.has(c.id) && "bg-primary/5")}
|
||||
onClick={() => setCustCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(c.id)) next.delete(c.id); else next.add(c.id);
|
||||
return next;
|
||||
})}>
|
||||
<TableCell className="text-center"><input type="checkbox" checked={custCheckedIds.has(c.id)} readOnly /></TableCell>
|
||||
<TableCell className="text-xs">{c.customer_code}</TableCell>
|
||||
<TableCell className="text-sm">{c.customer_name}</TableCell>
|
||||
<TableCell className="text-xs">{c.division}</TableCell>
|
||||
<TableCell className="text-xs">{c.contact_person}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<span className="text-sm text-muted-foreground">{custCheckedIds.size}개 선택됨</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setCustSelectOpen(false)}>취소</Button>
|
||||
<Button onClick={goToCustDetail} disabled={custCheckedIds.size === 0}>
|
||||
<Plus className="w-4 h-4 mr-1" /> {custCheckedIds.size}개 다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 거래처 상세 입력/수정 모달 */}
|
||||
<FullscreenDialog
|
||||
open={custDetailOpen}
|
||||
onOpenChange={setCustDetailOpen}
|
||||
title={`📋 거래처 상세정보 ${editCustData ? "수정" : "입력"} — ${selectedItem?.item_name || ""}`}
|
||||
description={editCustData ? "거래처 품번/품명과 기간별 단가를 수정합니다." : "선택한 거래처의 품번/품명과 기간별 단가를 설정합니다."}
|
||||
defaultMaxWidth="max-w-[1100px]"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => {
|
||||
setCustDetailOpen(false);
|
||||
if (!editCustData) setCustSelectOpen(true);
|
||||
setEditCustData(null);
|
||||
}}>{editCustData ? "취소" : "← 이전"}</Button>
|
||||
<Button onClick={handleCustDetailSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6 py-2">
|
||||
{selectedCustsForDetail.map((cust, idx) => {
|
||||
const custKey = cust.customer_code || cust.id;
|
||||
const mappingRows = custMappings[custKey] || [];
|
||||
const prices = custPrices[custKey] || [];
|
||||
|
||||
return (
|
||||
<div key={custKey} className="border rounded-xl overflow-hidden bg-card">
|
||||
<div className="px-5 py-3 bg-muted/30 border-b">
|
||||
<div className="font-bold">{idx + 1}. {cust.customer_name || custKey}</div>
|
||||
<div className="text-xs text-muted-foreground">{custKey}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 p-4">
|
||||
{/* 좌: 거래처 품번/품명 */}
|
||||
<div className="flex-1 border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-semibold">거래처 품번/품명 관리</span>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(custKey)}>
|
||||
<Plus className="h-3 w-3 mr-1" /> 품번 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{mappingRows.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground py-2">입력된 거래처 품번이 없습니다</div>
|
||||
) : mappingRows.map((mRow, mIdx) => (
|
||||
<div key={mRow._id} className="flex gap-2 items-center">
|
||||
<span className="text-xs text-muted-foreground w-4 shrink-0">{mIdx + 1}</span>
|
||||
<Input value={mRow.customer_item_code}
|
||||
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_code", e.target.value)}
|
||||
placeholder="거래처 품번" className="h-8 text-sm flex-1" />
|
||||
<Input value={mRow.customer_item_name}
|
||||
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_name", e.target.value)}
|
||||
placeholder="거래처 품명" className="h-8 text-sm flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive shrink-0"
|
||||
onClick={() => removeMappingRow(custKey, mRow._id)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우: 기간별 단가 */}
|
||||
<div className="flex-1 border rounded-lg p-4 bg-amber-50/30 dark:bg-amber-950/10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-semibold">기간별 단가 설정</span>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(custKey)}>
|
||||
<Plus className="h-3 w-3 mr-1" /> 단가 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{prices.map((price, pIdx) => (
|
||||
<div key={price._id} className="border rounded-lg p-3 bg-background space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">단가 {pIdx + 1}</span>
|
||||
{prices.length > 1 && (
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive"
|
||||
onClick={() => removePriceRow(custKey, price._id)}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-1">
|
||||
<FormDatePicker value={price.start_date}
|
||||
onChange={(v) => updatePriceRow(custKey, price._id, "start_date", v)} placeholder="시작일" />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">~</span>
|
||||
<div className="flex-1">
|
||||
<FormDatePicker value={price.end_date}
|
||||
onChange={(v) => updatePriceRow(custKey, price._id, "end_date", v)} placeholder="종료일" />
|
||||
</div>
|
||||
<div className="w-[80px]">
|
||||
<Select value={price.currency_code} onValueChange={(v) => updatePriceRow(custKey, price._id, "currency_code", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="통화" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(priceCategoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="w-[90px]">
|
||||
<Select value={price.base_price_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "base_price_type", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="기준" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(priceCategoryOptions["base_price_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input value={price.base_price}
|
||||
onChange={(e) => updatePriceRow(custKey, price._id, "base_price", e.target.value)}
|
||||
className="h-8 text-xs text-right flex-1" placeholder="기준가" />
|
||||
<div className="w-[90px]">
|
||||
<Select value={price.discount_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "discount_type", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="할인" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">할인없음</SelectItem>
|
||||
{(priceCategoryOptions["discount_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Input value={price.discount_value}
|
||||
onChange={(e) => updatePriceRow(custKey, price._id, "discount_value", e.target.value)}
|
||||
className="h-8 text-xs text-right w-[60px]" placeholder="0" />
|
||||
<div className="w-[90px]">
|
||||
<Select value={price.rounding_unit_value} onValueChange={(v) => updatePriceRow(custKey, price._id, "rounding_unit_value", v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="반올림" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(priceCategoryOptions["rounding_unit_value"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-1 border-t">
|
||||
<span className="text-xs text-muted-foreground">계산 단가:</span>
|
||||
<span className="font-bold text-sm">{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</FullscreenDialog>
|
||||
|
||||
{/* 엑셀 업로드 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
tableName={ITEM_TABLE}
|
||||
userId={user?.userId}
|
||||
onSuccess={() => fetchItems()}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<TableSettingsModal
|
||||
open={tableSettingsOpen}
|
||||
onOpenChange={setTableSettingsOpen}
|
||||
tableName={ITEM_TABLE}
|
||||
settingsId="sales-item"
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2 } from "lucide-react";
|
||||
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import {
|
||||
|
|
@ -24,6 +24,8 @@ import {
|
|||
getSalesOrderSource,
|
||||
getItemSource,
|
||||
} from "@/lib/api/shipping";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
|
||||
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
|
||||
|
||||
|
|
@ -84,6 +86,9 @@ export default function ShippingOrderPage() {
|
|||
const [searchDateFrom, setSearchDateFrom] = useState("");
|
||||
const [searchDateTo, setSearchDateTo] = useState("");
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
// 모달
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
|
@ -467,6 +472,9 @@ export default function ShippingOrderPage() {
|
|||
<Badge variant="secondary" className="font-normal">{orders.length}건</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 size="sm" onClick={() => openModal()}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> 출하지시 등록
|
||||
</Button>
|
||||
|
|
@ -577,14 +585,22 @@ export default function ShippingOrderPage() {
|
|||
</div>
|
||||
|
||||
{/* 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="max-w-[90vw] max-h-[90vh] w-[1400px] p-0 flex flex-col overflow-hidden">
|
||||
<DialogHeader className="p-5 pb-4 border-b bg-primary text-primary-foreground shrink-0">
|
||||
<DialogTitle className="text-lg">{isEditMode ? "출하지시 수정" : "출하지시 등록"}</DialogTitle>
|
||||
<DialogDescription className="text-primary-foreground/70">
|
||||
{isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FullscreenDialog
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
title={isEditMode ? "출하지시 수정" : "출하지시 등록"}
|
||||
description={isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
|
||||
defaultMaxWidth="max-w-[90vw]"
|
||||
defaultWidth="w-[1400px]"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
|
|
@ -813,14 +829,17 @@ export default function ShippingOrderPage() {
|
|||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="p-4 border-t bg-muted/30 shrink-0">
|
||||
<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>
|
||||
</FullscreenDialog>
|
||||
|
||||
{/* 엑셀 업로드 모달 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
tableName="shipment_instruction"
|
||||
onSuccess={() => {
|
||||
fetchOrders();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -315,15 +315,15 @@ export default function ShippingPlanPage() {
|
|||
onCheckedChange={handleCheckAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[160px]">수주번호</TableHead>
|
||||
<TableHead className="w-[100px] text-center">납기일</TableHead>
|
||||
<TableHead className="w-[120px]">거래처</TableHead>
|
||||
<TableHead className="w-[100px]">품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[80px] text-right">수주수량</TableHead>
|
||||
<TableHead className="w-[80px] text-right">계획수량</TableHead>
|
||||
<TableHead className="w-[100px] text-center">출하계획일</TableHead>
|
||||
<TableHead className="w-[80px] text-center">상태</TableHead>
|
||||
<TableHead className="w-[10%]">수주번호</TableHead>
|
||||
<TableHead className="w-[8%] text-center">납기일</TableHead>
|
||||
<TableHead className="w-[12%]">거래처</TableHead>
|
||||
<TableHead className="w-[20%]">품목코드</TableHead>
|
||||
<TableHead className="w-[20%]">품목명</TableHead>
|
||||
<TableHead className="w-[7%] text-right">수주수량</TableHead>
|
||||
<TableHead className="w-[7%] text-right">계획수량</TableHead>
|
||||
<TableHead className="w-[8%] text-center">출하계획일</TableHead>
|
||||
<TableHead className="w-[6%] text-center">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/servi
|
|||
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
|
||||
import { useTabId } from "@/contexts/TabIdContext";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
import { FileSpreadsheet, Loader2 as ExcelLoader } from "lucide-react";
|
||||
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
|
||||
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
|
||||
|
||||
export interface ScreenViewPageProps {
|
||||
screenIdProp?: number;
|
||||
|
|
@ -96,6 +99,11 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
|||
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
|
||||
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
|
||||
|
||||
// 엑셀 업로드 모달 상태
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
|
||||
const [excelDetecting, setExcelDetecting] = useState(false);
|
||||
|
||||
// 편집 모달 상태
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editModalConfig, setEditModalConfig] = useState<{
|
||||
|
|
@ -650,8 +658,46 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
|||
<TableOptionsProvider>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`bg-background h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}
|
||||
className={`bg-background relative h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}
|
||||
>
|
||||
{/* 엑셀 업로드 버튼 (테이블이 있는 화면에서만 표시) */}
|
||||
{!isPreviewMode && screen?.tableName && (
|
||||
<div className="absolute top-2 right-3 z-10">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 text-xs"
|
||||
disabled={excelDetecting}
|
||||
onClick={async () => {
|
||||
if (!screen?.tableName) return;
|
||||
setExcelDetecting(true);
|
||||
try {
|
||||
const result = await autoDetectMultiTableConfig(screen.tableName, screenId);
|
||||
if (result.success && result.data) {
|
||||
setExcelChainConfig(result.data);
|
||||
setExcelUploadOpen(true);
|
||||
} else {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error(result.message || "테이블 구조를 분석할 수 없습니다.");
|
||||
}
|
||||
} catch {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error("테이블 구조 분석 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setExcelDetecting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{excelDetecting ? (
|
||||
<ExcelLoader className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileSpreadsheet className="h-3.5 w-3.5" />
|
||||
)}
|
||||
엑셀 업로드
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 레이아웃 준비 중 로딩 표시 */}
|
||||
{!layoutReady && (
|
||||
<div className="bg-muted/30 flex h-full w-full items-center justify-center">
|
||||
|
|
@ -801,6 +847,22 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
|||
}}
|
||||
/>
|
||||
|
||||
{/* 엑셀 업로드 모달 (멀티테이블 자동감지) */}
|
||||
{excelChainConfig && (
|
||||
<MultiTableExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={(open) => {
|
||||
setExcelUploadOpen(open);
|
||||
if (!open) setExcelChainConfig(null);
|
||||
}}
|
||||
config={excelChainConfig}
|
||||
onSuccess={() => {
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 스케줄 생성 확인 다이얼로그 */}
|
||||
<ScheduleConfirmDialog
|
||||
open={showConfirmDialog}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ConfirmDialog — 공통 확인/취소 다이얼로그
|
||||
*
|
||||
* 사용법:
|
||||
* const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||
*
|
||||
* // JSX에 넣기
|
||||
* <ConfirmDialogComponent />
|
||||
*
|
||||
* // 호출
|
||||
* const ok = await confirm("삭제하시겠습니까?", { description: "이 작업은 되돌릴 수 없습니다." });
|
||||
* if (ok) { ... }
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { AlertTriangle, Info, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConfirmVariant = "default" | "destructive" | "info";
|
||||
|
||||
interface ConfirmOptions {
|
||||
description?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: ConfirmVariant;
|
||||
}
|
||||
|
||||
const VARIANT_CONFIG = {
|
||||
default: {
|
||||
icon: Info,
|
||||
iconClass: "text-primary",
|
||||
buttonClass: "",
|
||||
},
|
||||
destructive: {
|
||||
icon: Trash2,
|
||||
iconClass: "text-destructive",
|
||||
buttonClass: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
iconClass: "text-blue-500",
|
||||
buttonClass: "",
|
||||
},
|
||||
};
|
||||
|
||||
export function useConfirmDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [options, setOptions] = useState<ConfirmOptions>({});
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null);
|
||||
|
||||
const confirm = useCallback((msg: string, opts?: ConfirmOptions): Promise<boolean> => {
|
||||
setTitle(msg);
|
||||
setOptions(opts || {});
|
||||
setOpen(true);
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
setOpen(false);
|
||||
resolveRef.current?.(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false);
|
||||
resolveRef.current?.(false);
|
||||
};
|
||||
|
||||
const variant = options.variant || "default";
|
||||
const config = VARIANT_CONFIG[variant];
|
||||
const Icon = config.icon;
|
||||
|
||||
const ConfirmDialogComponent = (
|
||||
<AlertDialog open={open} onOpenChange={(v) => { if (!v) handleCancel(); }}>
|
||||
<AlertDialogContent className="max-w-[420px]">
|
||||
<AlertDialogHeader>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn("mt-0.5 shrink-0", config.iconClass)}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<AlertDialogTitle className="text-base">{title}</AlertDialogTitle>
|
||||
{options.description && (
|
||||
<AlertDialogDescription className="mt-1.5 text-sm">
|
||||
{options.description}
|
||||
</AlertDialogDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancel}>
|
||||
{options.cancelText || "취소"}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm} className={cn(config.buttonClass)}>
|
||||
{options.confirmText || "확인"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
|
||||
return { confirm, ConfirmDialogComponent };
|
||||
}
|
||||
|
|
@ -0,0 +1,560 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DataGrid — 하드코딩 페이지용 공통 데이터 테이블 컴포넌트
|
||||
*
|
||||
* 기능:
|
||||
* - 드래그로 컬럼 순서 변경 (@dnd-kit)
|
||||
* - 컬럼 헤더 클릭 정렬 (asc/desc)
|
||||
* - 컬럼 헤더 필터: 필터 아이콘 → Popover → 고유값 체크박스 다중 선택
|
||||
* - 셀 더블클릭 인라인 편집 (text/number/date/select)
|
||||
* - 천단위 구분자 (number 타입)
|
||||
* - 셀 값 truncate + tooltip
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import {
|
||||
DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, horizontalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Filter, Check, Search, ImageIcon, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
// --- 타입 ---
|
||||
|
||||
export interface DataGridColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
width?: string;
|
||||
minWidth?: string;
|
||||
editable?: boolean;
|
||||
inputType?: "text" | "number" | "date" | "select";
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
truncate?: boolean;
|
||||
align?: "left" | "center" | "right";
|
||||
formatNumber?: boolean;
|
||||
/** 이미지 타입 — 값을 /api/files/preview/{objid} 이미지로 렌더링 */
|
||||
renderType?: "image";
|
||||
selectOptions?: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
export interface DataGridProps {
|
||||
columns: DataGridColumn[];
|
||||
data: any[];
|
||||
onRowClick?: (row: any, index: number) => void;
|
||||
onRowDoubleClick?: (row: any, index: number) => void;
|
||||
selectedId?: string | null;
|
||||
onSelect?: (id: string | null) => void;
|
||||
onCellEdit?: (rowId: string, columnKey: string, newValue: any, row: any) => void;
|
||||
tableName?: string;
|
||||
showRowNumber?: boolean;
|
||||
/** 체크박스 다중선택 모드 */
|
||||
showCheckbox?: boolean;
|
||||
/** 체크된 행 ID 배열 */
|
||||
checkedIds?: string[];
|
||||
/** 체크 변경 콜백 */
|
||||
onCheckedChange?: (ids: string[]) => void;
|
||||
emptyMessage?: string;
|
||||
loading?: boolean;
|
||||
onColumnOrderChange?: (columns: DataGridColumn[]) => void;
|
||||
gridId?: string;
|
||||
}
|
||||
|
||||
const fmtNum = (val: any) => {
|
||||
if (val == null || val === "") return "";
|
||||
const n = Number(String(val).replace(/,/g, ""));
|
||||
if (isNaN(n)) return String(val);
|
||||
return n.toLocaleString();
|
||||
};
|
||||
|
||||
// --- Sortable Header Cell ---
|
||||
function SortableHeaderCell({
|
||||
col, sortKey, sortDir, onSort,
|
||||
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
|
||||
}: {
|
||||
col: DataGridColumn;
|
||||
sortKey: string | null;
|
||||
sortDir: "asc" | "desc";
|
||||
onSort: (key: string) => void;
|
||||
headerFilterValues: Set<string>;
|
||||
uniqueValues: string[];
|
||||
onToggleFilter: (colKey: string, value: string) => void;
|
||||
onClearFilter: (colKey: string) => void;
|
||||
}) {
|
||||
const [filterSearch, setFilterSearch] = useState("");
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
cursor: "grab",
|
||||
};
|
||||
|
||||
const isSorted = sortKey === col.key;
|
||||
const hasFilter = headerFilterValues.size > 0;
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(col.width, col.minWidth, "select-none relative")}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex items-center gap-0.5 cursor-pointer flex-1 min-w-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (col.sortable !== false) onSort(col.key);
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-medium truncate">{col.label}</span>
|
||||
{isSorted && (
|
||||
<span className="text-primary text-xs shrink-0">{sortDir === "asc" ? "↑" : "↓"}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 컬럼 필터 아이콘 → Popover */}
|
||||
{col.filterable !== false && uniqueValues.length > 0 && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
|
||||
hasFilter && "text-primary bg-primary/10",
|
||||
)}
|
||||
title="필터"
|
||||
>
|
||||
<Filter className="h-3 w-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-52 p-2" align="start" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between border-b pb-2">
|
||||
<span className="text-xs font-medium">필터: {col.label}</span>
|
||||
{hasFilter && (
|
||||
<button onClick={() => onClearFilter(col.key)} className="text-destructive text-xs hover:underline">
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
value={filterSearch}
|
||||
onChange={(e) => setFilterSearch(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="h-7 text-xs pl-7"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{uniqueValues
|
||||
.filter((v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()))
|
||||
.slice(0, 50).map((val) => {
|
||||
const isSelected = headerFilterValues.has(val);
|
||||
return (
|
||||
<div
|
||||
key={val}
|
||||
className={cn(
|
||||
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
|
||||
isSelected && "bg-primary/10",
|
||||
)}
|
||||
onClick={() => onToggleFilter(col.key, val)}
|
||||
>
|
||||
<div className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
|
||||
isSelected ? "bg-primary border-primary" : "border-input",
|
||||
)}>
|
||||
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
|
||||
</div>
|
||||
<span className="truncate">{val || "(빈 값)"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{uniqueValues.filter((v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())).length > 50 && (
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs">
|
||||
...외 {uniqueValues.filter((v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())).length - 50}개
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
// --- DataGrid ---
|
||||
|
||||
export function DataGrid({
|
||||
columns: initialColumns,
|
||||
data,
|
||||
onRowClick,
|
||||
onRowDoubleClick,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onCellEdit,
|
||||
tableName,
|
||||
showRowNumber = true,
|
||||
showCheckbox = false,
|
||||
checkedIds = [],
|
||||
onCheckedChange,
|
||||
emptyMessage = "데이터가 없습니다",
|
||||
loading = false,
|
||||
onColumnOrderChange,
|
||||
gridId,
|
||||
}: DataGridProps) {
|
||||
const [columns, setColumns] = useState(initialColumns);
|
||||
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
|
||||
|
||||
// 정렬
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// 헤더 필터 (컬럼별 선택된 값 Set)
|
||||
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
||||
|
||||
// 인라인 편집
|
||||
const [editingCell, setEditingCell] = useState<{ rowIdx: number; colKey: string } | null>(null);
|
||||
// 이미지 확대 모달
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const editRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
|
||||
);
|
||||
|
||||
// localStorage에서 컬럼 순서 복원
|
||||
useEffect(() => {
|
||||
if (!gridId) return;
|
||||
const saved = localStorage.getItem(`datagrid_col_order_${gridId}`);
|
||||
if (saved) {
|
||||
try {
|
||||
const order = JSON.parse(saved) as string[];
|
||||
const reordered = order
|
||||
.map((key) => initialColumns.find((c) => c.key === key))
|
||||
.filter(Boolean) as DataGridColumn[];
|
||||
const remaining = initialColumns.filter((c) => !order.includes(c.key));
|
||||
setColumns([...reordered, ...remaining]);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}, [gridId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 컬럼별 고유값 계산 (필터 팝오버용)
|
||||
const columnUniqueValues = useMemo(() => {
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const col of columns) {
|
||||
const values = new Set<string>();
|
||||
data.forEach((row) => {
|
||||
const val = row[col.key];
|
||||
if (val !== null && val !== undefined && val !== "") {
|
||||
values.add(String(val));
|
||||
}
|
||||
});
|
||||
result[col.key] = Array.from(values).sort();
|
||||
}
|
||||
return result;
|
||||
}, [data, columns]);
|
||||
|
||||
// 드래그 완료
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
setColumns((prev) => {
|
||||
const oldIndex = prev.findIndex((c) => c.key === active.id);
|
||||
const newIndex = prev.findIndex((c) => c.key === over.id);
|
||||
const next = arrayMove(prev, oldIndex, newIndex);
|
||||
if (gridId) localStorage.setItem(`datagrid_col_order_${gridId}`, JSON.stringify(next.map((c) => c.key)));
|
||||
onColumnOrderChange?.(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 정렬
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
else { setSortKey(key); setSortDir("asc"); }
|
||||
};
|
||||
|
||||
// 헤더 필터 토글
|
||||
const toggleHeaderFilter = (colKey: string, value: string) => {
|
||||
setHeaderFilters((prev) => {
|
||||
const next = { ...prev };
|
||||
const set = new Set(next[colKey] || []);
|
||||
if (set.has(value)) set.delete(value); else set.add(value);
|
||||
if (set.size === 0) delete next[colKey]; else next[colKey] = set;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const clearHeaderFilter = (colKey: string) => {
|
||||
setHeaderFilters((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[colKey];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// 필터 + 정렬 적용
|
||||
const processedData = useMemo(() => {
|
||||
let result = [...data];
|
||||
|
||||
// 헤더 필터 적용
|
||||
if (Object.keys(headerFilters).length > 0) {
|
||||
result = result.filter((row) => {
|
||||
return Object.entries(headerFilters).every(([colKey, values]) => {
|
||||
if (values.size === 0) return true;
|
||||
const cellVal = row[colKey] != null ? String(row[colKey]) : "";
|
||||
return values.has(cellVal);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (sortKey) {
|
||||
result.sort((a, b) => {
|
||||
const av = a[sortKey] ?? "";
|
||||
const bv = b[sortKey] ?? "";
|
||||
const na = Number(av);
|
||||
const nb = Number(bv);
|
||||
if (!isNaN(na) && !isNaN(nb)) return sortDir === "asc" ? na - nb : nb - na;
|
||||
return sortDir === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, headerFilters, sortKey, sortDir]);
|
||||
|
||||
// 인라인 편집
|
||||
const startEdit = (rowIdx: number, colKey: string, currentVal: any) => {
|
||||
const col = columns.find((c) => c.key === colKey);
|
||||
if (!col?.editable) return;
|
||||
setEditingCell({ rowIdx, colKey });
|
||||
setEditValue(currentVal != null ? String(currentVal) : "");
|
||||
};
|
||||
|
||||
const saveEdit = useCallback(async () => {
|
||||
if (!editingCell) return;
|
||||
const { rowIdx, colKey } = editingCell;
|
||||
const row = processedData[rowIdx];
|
||||
if (!row) { setEditingCell(null); return; }
|
||||
|
||||
const originalVal = String(row[colKey] ?? "");
|
||||
if (originalVal === editValue) { setEditingCell(null); return; }
|
||||
|
||||
if (tableName && row.id) {
|
||||
try {
|
||||
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
|
||||
originalData: { id: row.id },
|
||||
updatedData: { [colKey]: editValue || null },
|
||||
});
|
||||
row[colKey] = editValue;
|
||||
toast.success("저장됨");
|
||||
} catch (err) {
|
||||
console.error("셀 저장 실패:", err);
|
||||
toast.error("저장에 실패했습니다.");
|
||||
setEditingCell(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onCellEdit?.(row.id, colKey, editValue, row);
|
||||
setEditingCell(null);
|
||||
}, [editingCell, editValue, processedData, tableName, onCellEdit]);
|
||||
|
||||
const cancelEdit = () => setEditingCell(null);
|
||||
|
||||
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") { e.preventDefault(); saveEdit(); }
|
||||
else if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }
|
||||
else if (e.key === "Tab") { e.preventDefault(); saveEdit(); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCell && editRef.current) {
|
||||
editRef.current.focus();
|
||||
editRef.current.select();
|
||||
}
|
||||
}, [editingCell]);
|
||||
|
||||
// 셀 렌더링
|
||||
const renderCell = (row: any, col: DataGridColumn, rowIdx: number) => {
|
||||
const isEditing = editingCell?.rowIdx === rowIdx && editingCell?.colKey === col.key;
|
||||
const val = row[col.key];
|
||||
|
||||
if (isEditing) {
|
||||
if (col.inputType === "select" && col.selectOptions) {
|
||||
return (
|
||||
<select ref={editRef as any} value={editValue} onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown} onBlur={saveEdit}
|
||||
className="h-7 w-full rounded border border-primary bg-background px-1.5 text-xs focus:ring-1">
|
||||
<option value="">선택</option>
|
||||
{col.selectOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input ref={editRef} type={col.inputType === "date" ? "date" : "text"}
|
||||
value={editValue} onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleEditKeyDown} onBlur={saveEdit}
|
||||
className={cn("h-7 w-full rounded border border-primary bg-background px-1.5 text-xs focus:ring-1",
|
||||
col.align === "right" && "text-right")} />
|
||||
);
|
||||
}
|
||||
|
||||
// 이미지 타입
|
||||
if (col.renderType === "image" && val) {
|
||||
const src = (val.startsWith("http") || val.startsWith("/")) ? val : `/api/files/preview/${val}`;
|
||||
return (
|
||||
<img src={src} alt="" className="h-10 w-10 rounded object-cover cursor-pointer hover:ring-2 hover:ring-primary transition-all"
|
||||
onClick={(e) => { e.stopPropagation(); setPreviewImage(src); }} />
|
||||
);
|
||||
}
|
||||
if (col.renderType === "image" && !val) {
|
||||
return <div className="h-10 w-10 rounded bg-muted flex items-center justify-center"><ImageIcon className="h-4 w-4 text-muted-foreground/30" /></div>;
|
||||
}
|
||||
|
||||
let display = val ?? "";
|
||||
if (col.formatNumber || col.inputType === "number") display = fmtNum(val);
|
||||
const truncateClass = col.truncate !== false ? "block truncate" : "";
|
||||
|
||||
return (
|
||||
<span className={cn("text-xs", truncateClass, col.align === "right" && "text-right w-full inline-block")}
|
||||
title={String(val ?? "")}>
|
||||
{display}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow>
|
||||
{showCheckbox && (
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<Checkbox
|
||||
checked={processedData.length > 0 && checkedIds.length === processedData.length}
|
||||
onCheckedChange={(checked) => {
|
||||
onCheckedChange?.(checked ? processedData.map((r) => r.id) : []);
|
||||
}}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{showRowNumber && !showCheckbox && <TableHead className="w-[40px] text-center text-xs">No</TableHead>}
|
||||
{columns.map((col) => (
|
||||
<SortableHeaderCell
|
||||
key={col.key}
|
||||
col={col}
|
||||
sortKey={sortKey}
|
||||
sortDir={sortDir}
|
||||
onSort={handleSort}
|
||||
headerFilterValues={headerFilters[col.key] || new Set()}
|
||||
uniqueValues={columnUniqueValues[col.key] || []}
|
||||
onToggleFilter={toggleHeaderFilter}
|
||||
onClearFilter={clearHeaderFilter}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
</SortableContext>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : processedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : processedData.map((row, rowIdx) => (
|
||||
<TableRow
|
||||
key={row.id || rowIdx}
|
||||
className={cn("cursor-pointer",
|
||||
selectedId === row.id && "bg-primary/5",
|
||||
showCheckbox && checkedIds.includes(row.id) && "bg-primary/5",
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect?.(row.id);
|
||||
onRowClick?.(row, rowIdx);
|
||||
// 체크박스 모드에서는 행 클릭으로 체크 토글
|
||||
if (showCheckbox && onCheckedChange) {
|
||||
const next = checkedIds.includes(row.id)
|
||||
? checkedIds.filter((id) => id !== row.id)
|
||||
: [...checkedIds, row.id];
|
||||
onCheckedChange(next);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => onRowDoubleClick?.(row, rowIdx)}
|
||||
>
|
||||
{showCheckbox && (
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={checkedIds.includes(row.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = checked
|
||||
? [...checkedIds, row.id]
|
||||
: checkedIds.filter((id) => id !== row.id);
|
||||
onCheckedChange?.(next);
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{showRowNumber && !showCheckbox && <TableCell className="text-center text-[10px] text-muted-foreground">{rowIdx + 1}</TableCell>}
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(col.width, col.minWidth, "py-2.5", col.editable && "cursor-text")}
|
||||
onDoubleClick={(e) => {
|
||||
if (col.editable) {
|
||||
e.stopPropagation();
|
||||
startEdit(rowIdx, col.key, row[col.key]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{renderCell(row, col, rowIdx)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
|
||||
{/* 이미지 확대 모달 */}
|
||||
{previewImage && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70"
|
||||
onClick={() => setPreviewImage(null)}>
|
||||
<div className="relative max-w-[90vw] max-h-[90vh]">
|
||||
<img src={previewImage} alt="" className="max-w-full max-h-[85vh] rounded-lg object-contain" />
|
||||
<button className="absolute -top-3 -right-3 bg-background rounded-full p-1 shadow-lg hover:bg-muted"
|
||||
onClick={() => setPreviewImage(null)}>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,530 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DynamicSearchFilter — 하드코딩 페이지용 공통 검색 필터 컴포넌트
|
||||
*
|
||||
* 기능:
|
||||
* - 사용자가 필터 컬럼을 자유롭게 추가/제거 (설정 모달)
|
||||
* - 필터 타입을 사용자가 텍스트/선택/날짜로 변경 가능
|
||||
* - 필터별 너비(%) 설정 가능
|
||||
* - 라벨은 placeholder로만 표시 (외부에 라벨 노출 없음)
|
||||
* - select 타입은 다중 선택 (체크박스)
|
||||
* - 날짜는 범위 선택 (FormDatePicker)
|
||||
* - 필터 설정 + 값을 localStorage에 저장 (페이지 새로고침 시 복원)
|
||||
* - 실시간 필터링 (값 변경 시 즉시 콜백 호출)
|
||||
* - 컬럼 라벨은 회사별 table_type_columns의 column_label 사용
|
||||
*/
|
||||
|
||||
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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Settings, ChevronsUpDown, RotateCcw, Search, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
|
||||
// --- 타입 ---
|
||||
|
||||
export type FilterType = "text" | "select" | "date";
|
||||
|
||||
export interface FilterColumn {
|
||||
columnName: string;
|
||||
columnLabel: string;
|
||||
/** 원본 inputType (DB에서 가져온 값, 타입 변경 시 기본값 복원용) */
|
||||
originalType: FilterType;
|
||||
/** 사용자가 선택한 필터 타입 */
|
||||
filterType: FilterType;
|
||||
enabled: boolean;
|
||||
/** 필터 너비 (%, 10~100, 기본 25) */
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface FilterValue {
|
||||
columnName: string;
|
||||
operator: "contains" | "equals" | "in" | "between";
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ExternalFilterConfig {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
enabled: boolean;
|
||||
filterType: FilterType;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface DynamicSearchFilterProps {
|
||||
/** 테이블명 (컬럼 목록 + 카테고리 옵션 로드에 사용) */
|
||||
tableName: string;
|
||||
/** 고유 ID (localStorage 키 분리용, 예: "item-info", "sales-order") */
|
||||
filterId: string;
|
||||
/** 필터 변경 시 콜백 — API 필터 배열 형태로 전달 */
|
||||
onFilterChange: (filters: FilterValue[]) => void;
|
||||
/** 데이터 건수 표시 (optional) */
|
||||
dataCount?: number;
|
||||
/** 추가 액션 버튼 영역 */
|
||||
extraActions?: React.ReactNode;
|
||||
/** TableSettingsModal에서 전달된 외부 필터 설정 (제공 시 자체 설정 모달 숨김) */
|
||||
externalFilterConfig?: ExternalFilterConfig[];
|
||||
}
|
||||
|
||||
const FILTER_TYPE_OPTIONS: { value: FilterType; label: string }[] = [
|
||||
{ value: "text", label: "텍스트" },
|
||||
{ value: "select", label: "선택" },
|
||||
{ value: "date", label: "날짜" },
|
||||
];
|
||||
|
||||
const WIDTH_OPTIONS = [
|
||||
{ value: 15, label: "15%" },
|
||||
{ value: 20, label: "20%" },
|
||||
{ value: 25, label: "25%" },
|
||||
{ value: 30, label: "30%" },
|
||||
{ value: 40, label: "40%" },
|
||||
{ value: 50, label: "50%" },
|
||||
];
|
||||
|
||||
// --- 컴포넌트 ---
|
||||
|
||||
export function DynamicSearchFilter({
|
||||
tableName,
|
||||
filterId,
|
||||
onFilterChange,
|
||||
dataCount,
|
||||
extraActions,
|
||||
externalFilterConfig,
|
||||
}: DynamicSearchFilterProps) {
|
||||
const [allColumns, setAllColumns] = useState<FilterColumn[]>([]);
|
||||
const [activeFilters, setActiveFilters] = useState<FilterColumn[]>([]);
|
||||
const [filterValues, setFilterValues] = useState<Record<string, any>>({});
|
||||
const [selectOptions, setSelectOptions] = useState<Record<string, { label: string; value: string }[]>>({});
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [selectSearchTerms, setSelectSearchTerms] = useState<Record<string, string>>({});
|
||||
const [tempColumns, setTempColumns] = useState<FilterColumn[]>([]);
|
||||
|
||||
const STORAGE_KEY_FILTERS = `dynamic_filter_config_${filterId}`;
|
||||
const STORAGE_KEY_VALUES = `dynamic_filter_values_${filterId}`;
|
||||
|
||||
// 컬럼 정보 로드 (회사별 table_type_columns의 column_label 사용)
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-management/tables/${tableName}/web-types`);
|
||||
const types = res.data?.data || [];
|
||||
|
||||
const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"];
|
||||
const cols: FilterColumn[] = types
|
||||
.filter((t: any) => !AUTO_COLS.includes(t.columnName))
|
||||
.map((t: any) => {
|
||||
let filterType: FilterType = "text";
|
||||
if (t.inputType === "category" || t.inputType === "select") filterType = "select";
|
||||
else if (t.inputType === "date" || t.inputType === "datetime") filterType = "date";
|
||||
return {
|
||||
columnName: t.columnName,
|
||||
columnLabel: t.displayName || t.columnLabel || t.columnName,
|
||||
originalType: filterType,
|
||||
filterType,
|
||||
enabled: false,
|
||||
width: 25,
|
||||
};
|
||||
});
|
||||
|
||||
// localStorage에서 저장된 설정 복원 (enabled, filterType, width)
|
||||
const saved = localStorage.getItem(STORAGE_KEY_FILTERS);
|
||||
let merged = cols;
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved) as FilterColumn[];
|
||||
merged = cols.map((col) => {
|
||||
const s = parsed.find((p) => p.columnName === col.columnName);
|
||||
return s ? { ...col, enabled: s.enabled, filterType: s.filterType, width: s.width || 25 } : col;
|
||||
});
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
setAllColumns(merged);
|
||||
setActiveFilters(merged.filter((c) => c.enabled));
|
||||
|
||||
// 저장된 필터 값 복원
|
||||
const savedValues = localStorage.getItem(STORAGE_KEY_VALUES);
|
||||
if (savedValues) {
|
||||
try { setFilterValues(JSON.parse(savedValues)); } catch { /* skip */ }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("필터 컬럼 로드 실패:", err);
|
||||
}
|
||||
};
|
||||
loadColumns();
|
||||
}, [tableName, STORAGE_KEY_FILTERS, STORAGE_KEY_VALUES]);
|
||||
|
||||
// 외부 필터 설정 적용 (TableSettingsModal에서 전달)
|
||||
useEffect(() => {
|
||||
if (!externalFilterConfig) return;
|
||||
const active: FilterColumn[] = externalFilterConfig
|
||||
.filter((f) => f.enabled)
|
||||
.map((f) => ({
|
||||
columnName: f.columnName,
|
||||
columnLabel: f.displayName,
|
||||
originalType: f.filterType,
|
||||
filterType: f.filterType,
|
||||
enabled: true,
|
||||
width: f.width,
|
||||
}));
|
||||
setActiveFilters(active);
|
||||
}, [externalFilterConfig]);
|
||||
|
||||
// select 타입 필터의 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadOptions = async () => {
|
||||
const selectCols = activeFilters.filter((f) => f.filterType === "select");
|
||||
if (selectCols.length === 0) return;
|
||||
|
||||
const opts: Record<string, { label: string; value: string }[]> = {};
|
||||
const flatten = (vals: any[]): { label: string; value: string }[] => {
|
||||
const result: { label: string; value: string }[] = [];
|
||||
for (const v of vals) {
|
||||
result.push({ value: v.valueCode, label: v.valueLabel });
|
||||
if (v.children?.length) result.push(...flatten(v.children));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
selectCols.map(async (col) => {
|
||||
if (selectOptions[col.columnName]?.length) return; // 이미 로드됨
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${tableName}/${col.columnName}/values`);
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
opts[col.columnName] = flatten(res.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
})
|
||||
);
|
||||
if (Object.keys(opts).length > 0) {
|
||||
setSelectOptions((prev) => ({ ...prev, ...opts }));
|
||||
}
|
||||
};
|
||||
loadOptions();
|
||||
}, [activeFilters, tableName]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 필터 값 → API 필터 형태로 변환
|
||||
const emitFilters = useCallback((values: Record<string, any>) => {
|
||||
const filters: FilterValue[] = [];
|
||||
for (const f of activeFilters) {
|
||||
const val = values[f.columnName];
|
||||
if (!val || (typeof val === "string" && val === "")) continue;
|
||||
|
||||
if (f.filterType === "date" && typeof val === "object" && (val.from || val.to)) {
|
||||
const from = val.from || "";
|
||||
const to = val.to || "";
|
||||
if (from || to) filters.push({ columnName: f.columnName, operator: "between", value: `${from}|${to}` });
|
||||
} else if (Array.isArray(val)) {
|
||||
if (val.length > 0) filters.push({ columnName: f.columnName, operator: "in", value: val.join("|") });
|
||||
} else {
|
||||
filters.push({
|
||||
columnName: f.columnName,
|
||||
operator: f.filterType === "select" ? "equals" : "contains",
|
||||
value: String(val),
|
||||
});
|
||||
}
|
||||
}
|
||||
onFilterChange(filters);
|
||||
}, [activeFilters, onFilterChange]);
|
||||
|
||||
const handleValueChange = (columnName: string, value: any) => {
|
||||
const newValues = { ...filterValues, [columnName]: value };
|
||||
setFilterValues(newValues);
|
||||
localStorage.setItem(STORAGE_KEY_VALUES, JSON.stringify(newValues));
|
||||
emitFilters(newValues);
|
||||
};
|
||||
|
||||
// 초기 로드 시 필터 적용
|
||||
useEffect(() => {
|
||||
if (activeFilters.length > 0 && Object.keys(filterValues).length > 0) {
|
||||
emitFilters(filterValues);
|
||||
}
|
||||
}, [activeFilters.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleReset = () => {
|
||||
setFilterValues({});
|
||||
localStorage.removeItem(STORAGE_KEY_VALUES);
|
||||
onFilterChange([]);
|
||||
};
|
||||
|
||||
// 설정 모달
|
||||
const openSettings = () => {
|
||||
setTempColumns(allColumns.map((c) => ({ ...c })));
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
const saveSettings = () => {
|
||||
setAllColumns(tempColumns);
|
||||
const active = tempColumns.filter((c) => c.enabled);
|
||||
setActiveFilters(active);
|
||||
localStorage.setItem(STORAGE_KEY_FILTERS, JSON.stringify(tempColumns));
|
||||
|
||||
// 비활성화된 필터 값 + 타입 변경된 필터 값 제거
|
||||
const activeNames = new Set(active.map((a) => a.columnName));
|
||||
const cleaned = { ...filterValues };
|
||||
for (const key of Object.keys(cleaned)) {
|
||||
if (!activeNames.has(key)) delete cleaned[key];
|
||||
}
|
||||
// 타입이 변경된 필터의 값도 초기화
|
||||
for (const col of active) {
|
||||
const prev = allColumns.find((c) => c.columnName === col.columnName);
|
||||
if (prev && prev.filterType !== col.filterType) {
|
||||
delete cleaned[col.columnName];
|
||||
}
|
||||
}
|
||||
setFilterValues(cleaned);
|
||||
localStorage.setItem(STORAGE_KEY_VALUES, JSON.stringify(cleaned));
|
||||
|
||||
setSettingsOpen(false);
|
||||
emitFilters(cleaned);
|
||||
};
|
||||
|
||||
// --- 필터 렌더링 (라벨은 placeholder로만) ---
|
||||
|
||||
const renderFilterInput = (filter: FilterColumn) => {
|
||||
const value = filterValues[filter.columnName] || "";
|
||||
const widthStyle = { flex: `0 0 ${filter.width}%`, minWidth: "120px" };
|
||||
|
||||
switch (filter.filterType) {
|
||||
case "date":
|
||||
return (
|
||||
<div style={widthStyle} className="flex items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<FormDatePicker
|
||||
value={typeof value === "object" ? value.from || "" : ""}
|
||||
onChange={(v) => handleValueChange(filter.columnName, { ...((typeof value === "object" && value) || {}), from: v })}
|
||||
placeholder={`${filter.columnLabel} 시작`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs shrink-0">~</span>
|
||||
<div className="flex-1">
|
||||
<FormDatePicker
|
||||
value={typeof value === "object" ? value.to || "" : ""}
|
||||
onChange={(v) => handleValueChange(filter.columnName, { ...((typeof value === "object" && value) || {}), to: v })}
|
||||
placeholder={`${filter.columnLabel} 종료`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "select": {
|
||||
const options = selectOptions[filter.columnName] || [];
|
||||
const selectedValues: string[] = Array.isArray(value) ? value : value ? [value] : [];
|
||||
|
||||
const getDisplayText = () => {
|
||||
if (selectedValues.length === 0) return filter.columnLabel;
|
||||
if (selectedValues.length === 1) {
|
||||
const opt = options.find((o) => o.value === selectedValues[0]);
|
||||
return opt?.label || selectedValues[0];
|
||||
}
|
||||
return `${filter.columnLabel} (${selectedValues.length})`;
|
||||
};
|
||||
|
||||
const toggleOption = (optValue: string, checked: boolean) => {
|
||||
const next = checked ? [...selectedValues, optValue] : selectedValues.filter((v) => v !== optValue);
|
||||
handleValueChange(filter.columnName, next.length > 0 ? next : "");
|
||||
};
|
||||
|
||||
const searchTerm = (selectSearchTerms[filter.columnName] || "").toLowerCase();
|
||||
const filteredOptions = searchTerm
|
||||
? options.filter((opt) => opt.label.toLowerCase().includes(searchTerm))
|
||||
: options;
|
||||
|
||||
return (
|
||||
<div style={widthStyle}>
|
||||
<Popover onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectSearchTerms((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[filter.columnName];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" role="combobox"
|
||||
className={cn("h-9 w-full justify-between text-sm font-normal", selectedValues.length === 0 && "text-muted-foreground")}>
|
||||
<span className="truncate">{getDisplayText()}</span>
|
||||
<ChevronsUpDown className="ml-1 h-3.5 w-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
{options.length > 5 && (
|
||||
<div className="border-b p-1">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2" />
|
||||
<Input
|
||||
value={selectSearchTerms[filter.columnName] || ""}
|
||||
onChange={(e) =>
|
||||
setSelectSearchTerms((prev) => ({ ...prev, [filter.columnName]: e.target.value }))
|
||||
}
|
||||
placeholder="검색..."
|
||||
className="h-7 pl-7 pr-7 text-xs"
|
||||
/>
|
||||
{selectSearchTerms[filter.columnName] && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setSelectSearchTerms((prev) => ({ ...prev, [filter.columnName]: "" }))
|
||||
}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="text-muted-foreground hover:text-foreground h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-60 overflow-auto p-1">
|
||||
{options.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">옵션 없음</div>
|
||||
) : filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">검색 결과 없음</div>
|
||||
) : filteredOptions.map((opt, i) => (
|
||||
<div key={`${opt.value}-${i}`}
|
||||
className="hover:bg-accent flex cursor-pointer items-center space-x-2 rounded-sm px-2 py-1.5"
|
||||
onClick={() => toggleOption(opt.value, !selectedValues.includes(opt.value))}>
|
||||
<Checkbox checked={selectedValues.includes(opt.value)} />
|
||||
<span className="truncate text-sm">{opt.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selectedValues.length > 0 && (
|
||||
<div className="border-t p-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-full text-xs"
|
||||
onClick={() => handleValueChange(filter.columnName, "")}>선택 초기화</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default: // text
|
||||
return (
|
||||
<div style={widthStyle}>
|
||||
<Input type="text" value={value}
|
||||
onChange={(e) => handleValueChange(filter.columnName, e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") emitFilters(filterValues); }}
|
||||
className="h-9 w-full text-sm" placeholder={filter.columnLabel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card flex w-full flex-wrap items-center gap-2 rounded-lg border p-3 shadow-sm">
|
||||
{/* 활성 필터들 — 라벨 없이 placeholder만 */}
|
||||
{activeFilters.length > 0 ? (
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
{activeFilters.map((filter) => renderFilterInput(filter))}
|
||||
<Button variant="outline" size="sm" onClick={handleReset} className="h-9 shrink-0">
|
||||
<RotateCcw className="mr-1 h-3.5 w-3.5" /> 초기화
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
검색 필터를 설정해주세요
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우측 */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{dataCount !== undefined && (
|
||||
<div className="bg-muted text-muted-foreground rounded-md px-3 py-1.5 text-sm font-medium">
|
||||
{dataCount.toLocaleString()}건
|
||||
</div>
|
||||
)}
|
||||
{extraActions}
|
||||
{!externalFilterConfig && (
|
||||
<Button variant="outline" size="sm" onClick={openSettings} className="h-9">
|
||||
<Settings className="mr-1 h-3.5 w-3.5" /> 필터 설정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 설정 모달 */}
|
||||
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[70vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>검색 필터 설정</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-3 pb-2 border-b mb-1 text-xs font-medium text-muted-foreground">
|
||||
<div className="w-8 text-center">활성</div>
|
||||
<div className="flex-1">컬럼명</div>
|
||||
<div className="w-[120px]">필터 타입</div>
|
||||
<div className="w-[100px]">너비</div>
|
||||
</div>
|
||||
{tempColumns.map((col, idx) => (
|
||||
<div key={col.columnName} className="flex items-center gap-3 py-1.5 px-1 hover:bg-muted/50 rounded">
|
||||
<div className="w-8 text-center">
|
||||
<Checkbox
|
||||
checked={col.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
const next = [...tempColumns];
|
||||
next[idx] = { ...next[idx], enabled: !!checked };
|
||||
setTempColumns(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 text-sm">{col.columnLabel}</div>
|
||||
<div className="w-[120px]">
|
||||
<Select
|
||||
value={col.filterType}
|
||||
onValueChange={(v) => {
|
||||
const next = [...tempColumns];
|
||||
next[idx] = { ...next[idx], filterType: v as FilterType };
|
||||
setTempColumns(next);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-[100px]">
|
||||
<Select
|
||||
value={String(col.width)}
|
||||
onValueChange={(v) => {
|
||||
const next = [...tempColumns];
|
||||
next[idx] = { ...next[idx], width: Number(v) };
|
||||
setTempColumns(next);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WIDTH_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={String(opt.value)}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSettingsOpen(false)}>취소</Button>
|
||||
<Button onClick={saveSettings}>적용</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -822,6 +822,37 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
return true;
|
||||
};
|
||||
|
||||
// 템플릿 다운로드: 테이블 스키마 기반으로 빈 엑셀 파일 생성
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const { exportToExcel } = await import("@/lib/utils/excelExport");
|
||||
const response = await getTableSchema(tableName);
|
||||
if (!response.success || !response.data) {
|
||||
toast.error("테이블 정보를 가져올 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"];
|
||||
const columns = response.data.columns.filter(
|
||||
(col) => !AUTO_COLS.includes(col.name.toLowerCase())
|
||||
);
|
||||
|
||||
// 필수 컬럼에 * 표시
|
||||
const headerRow: Record<string, any> = {};
|
||||
for (const col of columns) {
|
||||
const label = col.label || col.name;
|
||||
const isRequired = !col.nullable;
|
||||
headerRow[isRequired ? `${label} *` : label] = "";
|
||||
}
|
||||
|
||||
await exportToExcel([headerRow], `${tableName}_템플릿.xlsx`, "Sheet1");
|
||||
toast.success("템플릿 파일이 다운로드되었습니다.");
|
||||
} catch (error) {
|
||||
console.error("템플릿 다운로드 실패:", error);
|
||||
toast.error("템플릿 다운로드에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 다음 단계
|
||||
const handleNext = async () => {
|
||||
if (currentStep === 1 && !file) {
|
||||
|
|
@ -1607,11 +1638,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 선택 영역 */}
|
||||
{/* 템플릿 다운로드 + 파일 선택 영역 */}
|
||||
<div>
|
||||
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
||||
파일 선택 *
|
||||
</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
|
||||
파일 선택 *
|
||||
</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
<ArrowRight className="h-3 w-3 rotate-90" />
|
||||
업로드 템플릿 다운로드
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* FullscreenDialog — 전체화면 토글이 포함된 공통 Dialog
|
||||
*
|
||||
* 사용법:
|
||||
* <FullscreenDialog open={open} onOpenChange={setOpen} title="제목" description="설명">
|
||||
* {children}
|
||||
* </FullscreenDialog>
|
||||
*
|
||||
* footer prop으로 하단 버튼 영역 커스텀 가능
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Maximize2, Minimize2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FullscreenDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
/** 기본 모달 최대 너비 (기본: "max-w-5xl") */
|
||||
defaultMaxWidth?: string;
|
||||
/** 기본 모달 너비 (기본: "w-[95vw]") */
|
||||
defaultWidth?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FullscreenDialog({
|
||||
open, onOpenChange, title, description, children, footer,
|
||||
defaultMaxWidth = "max-w-5xl",
|
||||
defaultWidth = "w-[95vw]",
|
||||
className,
|
||||
}: FullscreenDialogProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const handleOpenChange = (v: boolean) => {
|
||||
if (!v) setIsFullscreen(false);
|
||||
onOpenChange(v);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className={cn(
|
||||
"overflow-auto flex flex-col transition-all duration-200",
|
||||
isFullscreen
|
||||
? "max-w-screen max-h-screen w-screen h-screen rounded-none"
|
||||
: `${defaultMaxWidth} ${defaultWidth} max-h-[90vh]`,
|
||||
className,
|
||||
)}>
|
||||
<DialogHeader className="shrink-0">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<div>
|
||||
{typeof title === "string" ? <DialogTitle>{title}</DialogTitle> : title}
|
||||
{description && (
|
||||
typeof description === "string"
|
||||
? <DialogDescription>{description}</DialogDescription>
|
||||
: description
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
|
||||
onClick={() => setIsFullscreen((p) => !p)}
|
||||
title={isFullscreen ? "기본 크기" : "전체 화면"}>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<DialogFooter className="shrink-0">
|
||||
{footer}
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ImageUpload — 공통 이미지 업로드 컴포넌트
|
||||
*
|
||||
* 기능:
|
||||
* - 이미지 파일 선택 (클릭 or 드래그)
|
||||
* - 미리보기 표시
|
||||
* - /api/files/upload API로 업로드
|
||||
* - 업로드 후 파일 objid 반환
|
||||
* - 기존 이미지 표시 (objid 또는 URL)
|
||||
*
|
||||
* 사용법:
|
||||
* <ImageUpload
|
||||
* value={form.image_path} // 기존 파일 objid 또는 URL
|
||||
* onChange={(objid) => setForm(...)} // 업로드 완료 시 objid 반환
|
||||
* tableName="equipment_mng" // 연결 테이블
|
||||
* recordId="xxx" // 연결 레코드 ID
|
||||
* columnName="image_path" // 연결 컬럼명
|
||||
* />
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Upload, X, Loader2, ImageIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ImageUploadProps {
|
||||
/** 현재 이미지 값 (파일 objid, URL, 또는 빈 문자열) */
|
||||
value?: string;
|
||||
/** 업로드 완료 시 콜백 (파일 objid) */
|
||||
onChange?: (value: string) => void;
|
||||
/** 연결할 테이블명 */
|
||||
tableName?: string;
|
||||
/** 연결할 레코드 ID */
|
||||
recordId?: string;
|
||||
/** 연결할 컬럼명 */
|
||||
columnName?: string;
|
||||
/** 높이 (기본 160px) */
|
||||
height?: string;
|
||||
/** 비활성화 */
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ImageUpload({
|
||||
value, onChange, tableName, recordId, columnName,
|
||||
height = "h-40", disabled = false, className,
|
||||
}: ImageUploadProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 이미지 URL 결정
|
||||
const imageUrl = value
|
||||
? (value.startsWith("http") || value.startsWith("/"))
|
||||
? value
|
||||
: `/api/files/preview/${value}`
|
||||
: null;
|
||||
|
||||
const handleUpload = useCallback(async (file: File) => {
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error("이미지 파일만 업로드 가능합니다.");
|
||||
return;
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error("파일 크기는 10MB 이하만 가능합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("files", file);
|
||||
formData.append("docType", "IMAGE");
|
||||
formData.append("docTypeName", "이미지");
|
||||
if (tableName) formData.append("linkedTable", tableName);
|
||||
if (recordId) formData.append("recordId", recordId);
|
||||
if (columnName) formData.append("columnName", columnName);
|
||||
if (tableName && recordId) {
|
||||
formData.append("autoLink", "true");
|
||||
if (columnName) formData.append("isVirtualFileColumn", "true");
|
||||
}
|
||||
|
||||
const res = await apiClient.post("/files/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
|
||||
if (res.data?.success && (res.data.files?.length > 0 || res.data.data?.length > 0)) {
|
||||
const file = res.data.files?.[0] || res.data.data?.[0];
|
||||
const objid = file.objid;
|
||||
onChange?.(objid);
|
||||
toast.success("이미지가 업로드되었습니다.");
|
||||
} else {
|
||||
toast.error(res.data?.message || "업로드에 실패했습니다.");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("이미지 업로드 실패:", err);
|
||||
toast.error(err.response?.data?.message || "업로드에 실패했습니다.");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [tableName, recordId, columnName, onChange]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(file);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleUpload(file);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onChange?.("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-lg flex flex-col items-center justify-center cursor-pointer transition-colors overflow-hidden",
|
||||
height,
|
||||
dragOver ? "border-primary bg-primary/5" : imageUrl ? "border-transparent" : "border-muted-foreground/25 hover:border-primary hover:bg-muted/50",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
onClick={() => !disabled && !uploading && fileRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragOver(true); }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={!disabled ? handleDrop : undefined}
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">업로드 중...</span>
|
||||
</div>
|
||||
) : imageUrl ? (
|
||||
<img src={imageUrl} alt="이미지" className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
|
||||
<span className="text-xs text-muted-foreground">클릭 또는 드래그하여 업로드</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
{imageUrl && !disabled && (
|
||||
<Button variant="destructive" size="sm" className="absolute top-1 right-1 h-6 w-6 p-0 rounded-full"
|
||||
onClick={(e) => { e.stopPropagation(); handleRemove(); }}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<input ref={fileRef} type="file" accept="image/*" onChange={handleFileChange} className="hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ShippingPlanBatchModal — 출하계획 동시 등록 모달
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Plus, X, Loader2, Package, Truck, Clock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
|
||||
import {
|
||||
getShippingPlanAggregate,
|
||||
batchSaveShippingPlans,
|
||||
type AggregateResponse,
|
||||
type BatchSavePlan,
|
||||
} from "@/lib/api/shipping";
|
||||
|
||||
// --- 시간 선택 컴포넌트 (오전/오후 + 시 + 분) ---
|
||||
function TimePicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// value: "HH:MM" or ""
|
||||
const parsed = value ? value.split(":") : ["", ""];
|
||||
const hour24 = parsed[0] ? parseInt(parsed[0]) : -1;
|
||||
const minute = parsed[1] ? parseInt(parsed[1]) : -1;
|
||||
const isAM = hour24 >= 0 && hour24 < 12;
|
||||
const [period, setPeriod] = useState<"오전" | "오후">(isAM ? "오전" : "오후");
|
||||
|
||||
const hours = Array.from({ length: 12 }, (_, i) => i); // 0-11
|
||||
const minutes = Array.from({ length: 12 }, (_, i) => i * 5); // 0,5,10...55
|
||||
|
||||
const displayHour = hour24 >= 0 ? (hour24 % 12 || 12) : null;
|
||||
const displayMinute = minute >= 0 ? minute : null;
|
||||
|
||||
const select = (p: "오전" | "오후", h: number, m: number) => {
|
||||
const h24 = p === "오전" ? (h === 12 ? 0 : h) : (h === 12 ? 12 : h + 12);
|
||||
onChange(`${String(h24).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="h-7 w-full justify-start text-xs font-normal gap-1 px-2">
|
||||
<Clock className="h-3 w-3 shrink-0 opacity-50" />
|
||||
{value || "--:--"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="flex">
|
||||
{/* 오전/오후 */}
|
||||
<div className="border-r p-1 flex flex-col gap-0.5">
|
||||
{(["오전", "오후"] as const).map((p) => (
|
||||
<button key={p} onClick={() => setPeriod(p)}
|
||||
className={cn("px-3 py-1.5 text-xs rounded", period === p ? "bg-primary text-primary-foreground" : "hover:bg-muted")}>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 시 */}
|
||||
<div className="border-r p-1 max-h-48 overflow-auto flex flex-col gap-0.5">
|
||||
{[12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((h) => (
|
||||
<button key={h} onClick={() => select(period, h, displayMinute ?? 0)}
|
||||
className={cn("px-3 py-1 text-xs rounded min-w-[32px]",
|
||||
displayHour === h ? "bg-primary text-primary-foreground" : "hover:bg-muted")}>
|
||||
{String(h).padStart(2, "0")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 분 */}
|
||||
<div className="p-1 max-h-48 overflow-auto flex flex-col gap-0.5">
|
||||
{minutes.map((m) => (
|
||||
<button key={m} onClick={() => select(period, displayHour ?? 12, m)}
|
||||
className={cn("px-3 py-1 text-xs rounded min-w-[32px]",
|
||||
displayMinute === m ? "bg-primary text-primary-foreground" : "hover:bg-muted")}>
|
||||
{String(m).padStart(2, "0")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 타입 ---
|
||||
interface NewPlanRow {
|
||||
_id: string;
|
||||
sourceId: string;
|
||||
partCode: string;
|
||||
planQty: string;
|
||||
planDate: string;
|
||||
planTime: string;
|
||||
shipInfo: string;
|
||||
}
|
||||
|
||||
interface ShippingPlanBatchModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
selectedDetailIds: string[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className={cn("rounded-lg px-3 py-2.5 flex-1 min-w-0", color)}>
|
||||
<div className="text-[10px] opacity-80 whitespace-nowrap">{label}</div>
|
||||
<div className="text-lg font-bold">{value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- 메인 ---
|
||||
export function ShippingPlanBatchModal({
|
||||
open, onOpenChange, selectedDetailIds, onSuccess,
|
||||
}: ShippingPlanBatchModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [aggregate, setAggregate] = useState<AggregateResponse>({});
|
||||
const [newPlans, setNewPlans] = useState<Record<string, NewPlanRow[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || selectedDetailIds.length === 0) return;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getShippingPlanAggregate(selectedDetailIds);
|
||||
if (result.success && result.data) {
|
||||
setAggregate(result.data);
|
||||
const plans: Record<string, NewPlanRow[]> = {};
|
||||
for (const partCode of Object.keys(result.data)) {
|
||||
plans[partCode] = [makeNewRow(partCode, result.data[partCode].orders[0]?.sourceId || "")];
|
||||
}
|
||||
setNewPlans(plans);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하계획 집계 조회 실패:", err);
|
||||
toast.error("출하계획 정보를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [open, selectedDetailIds]);
|
||||
|
||||
const makeNewRow = (partCode: string, sourceId: string): NewPlanRow => ({
|
||||
_id: `new_${Date.now()}_${Math.random()}`,
|
||||
sourceId, partCode, planQty: "", planDate: "", planTime: "", shipInfo: "",
|
||||
});
|
||||
|
||||
const addRow = (partCode: string) => {
|
||||
const sourceId = aggregate[partCode]?.orders[0]?.sourceId || "";
|
||||
setNewPlans((prev) => ({ ...prev, [partCode]: [...(prev[partCode] || []), makeNewRow(partCode, sourceId)] }));
|
||||
};
|
||||
|
||||
const removeRow = (partCode: string, rowId: string) => {
|
||||
setNewPlans((prev) => ({ ...prev, [partCode]: (prev[partCode] || []).filter((r) => r._id !== rowId) }));
|
||||
};
|
||||
|
||||
const updateRow = (partCode: string, rowId: string, field: keyof NewPlanRow, value: string) => {
|
||||
// planQty 변경 시 총수주잔량 초과 검증
|
||||
if (field === "planQty" && value) {
|
||||
const agg = aggregate[partCode];
|
||||
if (agg) {
|
||||
const maxQty = agg.totalBalance - agg.totalPlanQty; // 기존 계획 제외한 잔여 가능량
|
||||
const otherSum = (newPlans[partCode] || [])
|
||||
.filter((r) => r._id !== rowId)
|
||||
.reduce((sum, r) => sum + (Number(r.planQty) || 0), 0);
|
||||
const remaining = maxQty - otherSum;
|
||||
if (Number(value) > remaining) {
|
||||
toast.error(`출하계획량이 잔여 가능량(${remaining.toLocaleString()})을 초과할 수 없습니다.`);
|
||||
value = String(Math.max(0, remaining));
|
||||
}
|
||||
}
|
||||
}
|
||||
setNewPlans((prev) => ({
|
||||
...prev,
|
||||
[partCode]: (prev[partCode] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r),
|
||||
}));
|
||||
};
|
||||
|
||||
const totalNewPlans = Object.values(newPlans).flat().filter((r) => r.planQty && Number(r.planQty) > 0).length;
|
||||
|
||||
const handleSave = async () => {
|
||||
const plans: BatchSavePlan[] = [];
|
||||
for (const rows of Object.values(newPlans)) {
|
||||
for (const row of rows) {
|
||||
const qty = Number(row.planQty);
|
||||
if (qty <= 0) continue;
|
||||
plans.push({ sourceId: row.sourceId, planQty: qty, planDate: row.planDate || undefined });
|
||||
}
|
||||
}
|
||||
if (plans.length === 0) { toast.error("출하계획 수량을 입력해주세요."); return; }
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await batchSaveShippingPlans(plans, "detail");
|
||||
if (result.success) {
|
||||
toast.success(`출하계획 ${plans.length}건이 등록되었습니다.`);
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(result.message || "등록 실패");
|
||||
}
|
||||
} catch { toast.error("등록 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const partCodes = Object.keys(aggregate);
|
||||
|
||||
return (
|
||||
<FullscreenDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={<><Truck className="h-5 w-5 inline mr-2" />출하계획 동시 등록</>}
|
||||
description={<>출하계획 설정: <strong>{totalNewPlans}개</strong></>}
|
||||
defaultMaxWidth="max-w-[1200px]"
|
||||
footer={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="text-sm text-muted-foreground">💡 수주 등 시 출하계획도 함께 저장됩니다</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button>
|
||||
<Button onClick={handleSave} disabled={saving || totalNewPlans === 0}>
|
||||
{saving && <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />} 적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
<div className="flex-1 overflow-auto space-y-6 py-2 px-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-40"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
|
||||
) : partCodes.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-10">선택된 품목이 없습니다.</div>
|
||||
) : partCodes.map((partCode) => {
|
||||
const agg = aggregate[partCode];
|
||||
const orders = agg.orders || [];
|
||||
const existingPlans = agg.existingPlans || [];
|
||||
const rows = newPlans[partCode] || [];
|
||||
const firstOrder = orders[0];
|
||||
|
||||
return (
|
||||
<div key={partCode} className="border rounded-xl overflow-hidden bg-card">
|
||||
{/* 품목 헤더 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 bg-muted/30 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-7 w-7 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground">품목코드</div>
|
||||
<div className="font-bold">{partCode}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-muted-foreground">품명</div>
|
||||
<div className="font-bold">{firstOrder?.partName || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 (신규 입력량 반영) */}
|
||||
{(() => {
|
||||
const newQtySum = (newPlans[partCode] || []).reduce((sum, r) => sum + (Number(r.planQty) || 0), 0);
|
||||
const totalPlanQty = agg.totalPlanQty + newQtySum;
|
||||
const availableStock = agg.currentStock - totalPlanQty;
|
||||
return (
|
||||
<div className="flex gap-2 px-4 py-2.5">
|
||||
<StatCard label="총수주잔량" value={agg.totalBalance} color="bg-violet-100 text-violet-900 dark:bg-violet-900/30 dark:text-violet-200" />
|
||||
<StatCard label="총출하계획량" value={totalPlanQty} color="bg-blue-100 text-blue-900 dark:bg-blue-900/30 dark:text-blue-200" />
|
||||
<StatCard label="현재고" value={agg.currentStock} color="bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200" />
|
||||
<StatCard label="가용재고" value={availableStock} color="bg-emerald-100 text-emerald-900 dark:bg-emerald-900/30 dark:text-emerald-200" />
|
||||
<StatCard label="생산중수량" value={agg.inProductionQty} color="bg-cyan-100 text-cyan-900 dark:bg-cyan-900/30 dark:text-cyan-200" />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 테이블 — overflow-x-auto로 겹침 방지 */}
|
||||
<div className="px-4 pb-3">
|
||||
<table className="w-full text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col style={{ width: 50 }} />
|
||||
<col style={{ width: "16%" }} />
|
||||
<col style={{ width: "9%" }} />
|
||||
<col style={{ width: "7%" }} />
|
||||
<col style={{ width: "6%" }} />
|
||||
<col style={{ width: "7%" }} />
|
||||
<col style={{ width: "17%" }} />
|
||||
<col style={{ width: "10%" }} />
|
||||
<col style={{ width: "14%" }} />
|
||||
<col style={{ width: 42 }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-muted-foreground">
|
||||
<th className="py-2 text-left">구분</th>
|
||||
<th className="py-2 text-left">수주번호</th>
|
||||
<th className="py-2 text-left">거래처</th>
|
||||
<th className="py-2 text-left">납기일</th>
|
||||
<th className="py-2 text-right">미출하</th>
|
||||
<th className="py-2 text-center">출하계획량</th>
|
||||
<th className="py-2 text-center">출하계획일</th>
|
||||
<th className="py-2 text-center">시간</th>
|
||||
<th className="py-2 text-left">출하정보</th>
|
||||
<th className="py-2 text-center">분할</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* 기존 계획 */}
|
||||
{existingPlans.map((plan) => {
|
||||
const order = orders.find((o) => o.sourceId === plan.sourceId);
|
||||
return (
|
||||
<tr key={`ex-${plan.id}`} className="border-b border-dashed">
|
||||
<td className="py-2"><Badge variant="secondary" className="text-[10px]">기존</Badge></td>
|
||||
<td className="py-2 text-xs truncate max-w-[150px]">{order?.orderNo || "-"}</td>
|
||||
<td className="py-2 text-xs">{order?.partnerName || "-"}</td>
|
||||
<td className="py-2 text-xs">{order?.dueDate?.split("T")[0] || "-"}</td>
|
||||
<td className="py-2 text-right text-xs">{order?.balanceQty?.toLocaleString() || "-"}</td>
|
||||
<td className="py-2 text-center text-xs">{plan.planQty.toLocaleString()}</td>
|
||||
<td className="py-2 text-center text-xs">{plan.planDate?.split("T")[0] || "-"}</td>
|
||||
<td className="py-2 text-center text-xs">-</td>
|
||||
<td className="py-2 text-xs">{plan.shipmentPlanNo || "-"}</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 신규 입력 행 */}
|
||||
{rows.map((row, rowIdx) => {
|
||||
const order = orders.find((o) => o.sourceId === row.sourceId) || orders[0];
|
||||
return (
|
||||
<tr key={row._id} className="border-b">
|
||||
<td className="py-2.5"><Badge className="text-[10px] bg-primary">신규</Badge></td>
|
||||
<td className="py-2.5 text-xs truncate">{order?.orderNo || "-"}</td>
|
||||
<td className="py-2.5 text-xs">{order?.partnerName || "-"}</td>
|
||||
<td className="py-2.5 text-xs">{order?.dueDate?.split("T")[0] || "-"}</td>
|
||||
<td className="py-2.5 text-right text-xs">{order?.balanceQty?.toLocaleString() || "-"}</td>
|
||||
<td className="py-2 px-1">
|
||||
<Input value={row.planQty}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.replace(/[^0-9]/g, "");
|
||||
updateRow(partCode, row._id, "planQty", raw ? String(Number(raw)) : "");
|
||||
}}
|
||||
className="h-8 text-xs text-center" placeholder="0" />
|
||||
</td>
|
||||
<td className="py-2 px-1">
|
||||
<FormDatePicker value={row.planDate}
|
||||
onChange={(v) => updateRow(partCode, row._id, "planDate", v)} placeholder="계획일" />
|
||||
</td>
|
||||
<td className="py-2 px-1">
|
||||
<TimePicker value={row.planTime}
|
||||
onChange={(v) => updateRow(partCode, row._id, "planTime", v)} />
|
||||
</td>
|
||||
<td className="py-2 px-1">
|
||||
<Input value={row.shipInfo}
|
||||
onChange={(e) => updateRow(partCode, row._id, "shipInfo", e.target.value)}
|
||||
className="h-8 text-xs" placeholder="출하정보 입력" />
|
||||
</td>
|
||||
<td className="py-2 text-center">
|
||||
{rowIdx === rows.length - 1 ? (
|
||||
<Button variant="outline" size="sm" className="h-7 w-7 p-0 rounded-full"
|
||||
onClick={() => addRow(partCode)}><Plus className="h-3.5 w-3.5" /></Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive"
|
||||
onClick={() => removeRow(partCode, row._id)}><X className="h-3.5 w-3.5" /></Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</FullscreenDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,575 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* ShippingPlanModal — 출하계획 모달 컴포넌트
|
||||
*
|
||||
* 기존 shipping-plan 페이지의 기능을 Dialog 안에 재구현.
|
||||
* orderNo prop이 있으면 해당 수주의 출하계획만 필터링하여 표시.
|
||||
*/
|
||||
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Search, X, Save, RotateCcw, Loader2, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getShipmentPlanList,
|
||||
updateShipmentPlan,
|
||||
type ShipmentPlanListItem,
|
||||
} from "@/lib/api/shipping";
|
||||
|
||||
interface ShippingPlanModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
orderNo?: string;
|
||||
onUpdated?: () => void;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "all", label: "전체" },
|
||||
{ value: "READY", label: "준비" },
|
||||
{ value: "CONFIRMED", label: "확정" },
|
||||
{ value: "SHIPPING", label: "출하중" },
|
||||
{ value: "COMPLETED", label: "완료" },
|
||||
{ value: "CANCEL_REQUEST", label: "취소요청" },
|
||||
{ value: "CANCELLED", label: "취소완료" },
|
||||
];
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const found = STATUS_OPTIONS.find((o) => o.value === status);
|
||||
return found?.label || status;
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "READY": return "bg-blue-100 text-blue-800 border-blue-200";
|
||||
case "CONFIRMED": return "bg-indigo-100 text-indigo-800 border-indigo-200";
|
||||
case "SHIPPING": return "bg-amber-100 text-amber-800 border-amber-200";
|
||||
case "COMPLETED": return "bg-emerald-100 text-emerald-800 border-emerald-200";
|
||||
case "CANCEL_REQUEST": return "bg-rose-100 text-rose-800 border-rose-200";
|
||||
case "CANCELLED": return "bg-slate-100 text-slate-800 border-slate-200";
|
||||
default: return "bg-gray-100 text-gray-800 border-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return "-";
|
||||
return dateStr.split("T")[0];
|
||||
};
|
||||
|
||||
const formatNumber = (val: string | number) => {
|
||||
const num = Number(val);
|
||||
return isNaN(num) ? "0" : num.toLocaleString();
|
||||
};
|
||||
|
||||
export function ShippingPlanModal({ open, onOpenChange, orderNo, onUpdated }: ShippingPlanModalProps) {
|
||||
const [data, setData] = useState<ShipmentPlanListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [checkedIds, setCheckedIds] = useState<number[]>([]);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// 검색
|
||||
const [searchDateFrom, setSearchDateFrom] = useState("");
|
||||
const [searchDateTo, setSearchDateTo] = useState("");
|
||||
const [searchStatus, setSearchStatus] = useState("all");
|
||||
const [searchCustomer, setSearchCustomer] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
|
||||
// 상세 패널 편집
|
||||
const [editPlanQty, setEditPlanQty] = useState("");
|
||||
const [editPlanDate, setEditPlanDate] = useState("");
|
||||
const [editMemo, setEditMemo] = useState("");
|
||||
const [isDetailChanged, setIsDetailChanged] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 모달 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const today = new Date();
|
||||
const threeMonthsAgo = new Date(today);
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
const oneMonthLater = new Date(today);
|
||||
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
|
||||
|
||||
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
|
||||
setSearchDateTo(oneMonthLater.toISOString().split("T")[0]);
|
||||
setSearchStatus("all");
|
||||
setSearchCustomer("");
|
||||
setSearchKeyword(orderNo || "");
|
||||
setSelectedId(null);
|
||||
setCheckedIds([]);
|
||||
setIsDetailChanged(false);
|
||||
setIsFullscreen(false);
|
||||
}, [open, orderNo]);
|
||||
|
||||
// 데이터 조회
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {};
|
||||
if (searchDateFrom) params.dateFrom = searchDateFrom;
|
||||
if (searchDateTo) params.dateTo = searchDateTo;
|
||||
if (searchStatus !== "all") params.status = searchStatus;
|
||||
if (searchCustomer.trim()) params.customer = searchCustomer.trim();
|
||||
if (searchKeyword.trim()) params.keyword = searchKeyword.trim();
|
||||
|
||||
const result = await getShipmentPlanList(params);
|
||||
if (result.success) {
|
||||
setData(result.data || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("출하계획 조회 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchDateFrom, searchDateTo, searchStatus, searchCustomer, searchKeyword]);
|
||||
|
||||
// 모달 열리고 날짜 세팅 완료 후 자동 조회
|
||||
useEffect(() => {
|
||||
if (open && searchDateFrom && searchDateTo) {
|
||||
fetchData();
|
||||
}
|
||||
}, [open, searchDateFrom, searchDateTo]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSearch = () => fetchData();
|
||||
|
||||
const handleResetSearch = () => {
|
||||
setSearchStatus("all");
|
||||
setSearchCustomer("");
|
||||
setSearchKeyword(orderNo || "");
|
||||
const today = new Date();
|
||||
const threeMonthsAgo = new Date(today);
|
||||
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
|
||||
const oneMonthLater = new Date(today);
|
||||
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
|
||||
setSearchDateFrom(threeMonthsAgo.toISOString().split("T")[0]);
|
||||
setSearchDateTo(oneMonthLater.toISOString().split("T")[0]);
|
||||
};
|
||||
|
||||
const selectedPlan = useMemo(() => data.find((p) => p.id === selectedId), [data, selectedId]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
const orderMap = new Map<string, ShipmentPlanListItem[]>();
|
||||
const orderKeys: string[] = [];
|
||||
data.forEach((plan) => {
|
||||
const key = plan.order_no || `_no_order_${plan.id}`;
|
||||
if (!orderMap.has(key)) {
|
||||
orderMap.set(key, []);
|
||||
orderKeys.push(key);
|
||||
}
|
||||
orderMap.get(key)!.push(plan);
|
||||
});
|
||||
return orderKeys.map((key) => ({
|
||||
orderNo: key,
|
||||
plans: orderMap.get(key)!,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
const handleRowClick = (plan: ShipmentPlanListItem) => {
|
||||
if (isDetailChanged && selectedId !== plan.id) {
|
||||
if (!confirm("변경사항이 있습니다. 저장하지 않고 이동하시겠습니까?")) return;
|
||||
}
|
||||
setSelectedId(plan.id);
|
||||
setEditPlanQty(String(Number(plan.plan_qty)));
|
||||
setEditPlanDate(plan.plan_date ? plan.plan_date.split("T")[0] : "");
|
||||
setEditMemo(plan.memo || "");
|
||||
setIsDetailChanged(false);
|
||||
};
|
||||
|
||||
const handleCheckAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setCheckedIds(data.filter((p) => p.status !== "CANCELLED").map((p) => p.id));
|
||||
} else {
|
||||
setCheckedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheck = (id: number, checked: boolean) => {
|
||||
if (checked) {
|
||||
setCheckedIds((prev) => [...prev, id]);
|
||||
} else {
|
||||
setCheckedIds((prev) => prev.filter((i) => i !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDetail = async () => {
|
||||
if (!selectedId || !selectedPlan) return;
|
||||
|
||||
const qty = Number(editPlanQty);
|
||||
if (qty <= 0) {
|
||||
alert("계획수량은 0보다 커야 합니다.");
|
||||
return;
|
||||
}
|
||||
if (!editPlanDate) {
|
||||
alert("출하계획일을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await updateShipmentPlan(selectedId, {
|
||||
planQty: qty,
|
||||
planDate: editPlanDate,
|
||||
memo: editMemo,
|
||||
});
|
||||
if (result.success) {
|
||||
setIsDetailChanged(false);
|
||||
alert("저장되었습니다.");
|
||||
fetchData();
|
||||
onUpdated?.();
|
||||
} else {
|
||||
alert(result.message || "저장 실패");
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.message || "저장 중 오류 발생");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"overflow-hidden flex flex-col transition-all duration-200 p-0",
|
||||
isFullscreen
|
||||
? "max-w-[100vw] max-h-[100vh] w-[100vw] h-[100vh] rounded-none"
|
||||
: "max-w-6xl h-[85vh]"
|
||||
)}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="px-4 pt-4 pb-2 shrink-0">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<div>
|
||||
<DialogTitle>출하계획 관리</DialogTitle>
|
||||
<DialogDescription>
|
||||
{orderNo ? `수주번호 ${orderNo}의 출하계획` : "전체 출하계획을 관리합니다."}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost" size="sm" className="h-8 w-8 p-0"
|
||||
onClick={() => setIsFullscreen((prev) => !prev)}
|
||||
title={isFullscreen ? "기본 크기" : "전체 화면"}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 검색 영역 */}
|
||||
<div className="px-4 pb-2 shrink-0 flex flex-wrap items-end gap-3 border-b">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">출하계획일</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input type="date" className="w-[130px] h-8 text-xs" value={searchDateFrom}
|
||||
onChange={(e) => setSearchDateFrom(e.target.value)} />
|
||||
<span className="text-muted-foreground text-xs">~</span>
|
||||
<Input type="date" className="w-[130px] h-8 text-xs" value={searchDateTo}
|
||||
onChange={(e) => setSearchDateTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">상태</Label>
|
||||
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
||||
<SelectTrigger className="w-[100px] h-8 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">거래처</Label>
|
||||
<Input placeholder="거래처" className="w-[120px] h-8 text-xs" value={searchCustomer}
|
||||
onChange={(e) => setSearchCustomer(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">수주번호/품목</Label>
|
||||
<Input placeholder="수주번호/품목" className="w-[160px] h-8 text-xs" value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 pb-1">
|
||||
<Button size="sm" className="h-8 text-xs" onClick={handleSearch} disabled={loading}>
|
||||
{loading ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Search className="w-3.5 h-3.5 mr-1" />}
|
||||
조회
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={handleResetSearch}>
|
||||
<RotateCcw className="w-3.5 h-3.5 mr-1" /> 초기화
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 목록 + 상세 패널 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel defaultSize={selectedId ? 60 : 100} minSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/10 shrink-0">
|
||||
<div className="font-semibold flex items-center gap-2 text-sm">
|
||||
출하계획 목록
|
||||
<Badge variant="secondary" className="font-normal">{data.length}건</Badge>
|
||||
</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>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[36px] text-center">
|
||||
<Checkbox
|
||||
checked={data.length > 0 && checkedIds.length === data.filter((p) => p.status !== "CANCELLED").length}
|
||||
onCheckedChange={handleCheckAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[130px]">수주번호</TableHead>
|
||||
<TableHead className="w-[90px] text-center">납기일</TableHead>
|
||||
<TableHead className="w-[100px]">거래처</TableHead>
|
||||
<TableHead className="w-[90px]">품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead className="w-[70px] text-right">수주수량</TableHead>
|
||||
<TableHead className="w-[70px] text-right">계획수량</TableHead>
|
||||
<TableHead className="w-[90px] text-center">출하계획일</TableHead>
|
||||
<TableHead className="w-[70px] text-center">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="h-32 text-center text-muted-foreground">
|
||||
출하계획이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupedData.map((group) =>
|
||||
group.plans.map((plan, planIdx) => (
|
||||
<TableRow
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50 transition-colors",
|
||||
selectedId === plan.id && "bg-primary/5",
|
||||
plan.status === "CANCELLED" && "opacity-60 bg-slate-50",
|
||||
planIdx === 0 && "border-t-2 border-t-border"
|
||||
)}
|
||||
onClick={() => handleRowClick(plan)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{planIdx === 0 && (
|
||||
<Checkbox
|
||||
checked={group.plans.every((p) => checkedIds.includes(p.id))}
|
||||
onCheckedChange={(c) => {
|
||||
if (c) {
|
||||
setCheckedIds((prev) => [...new Set([...prev, ...group.plans.filter((p) => p.status !== "CANCELLED").map((p) => p.id)])]);
|
||||
} else {
|
||||
setCheckedIds((prev) => prev.filter((id) => !group.plans.some((p) => p.id === id)));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-xs">
|
||||
{planIdx === 0 ? (plan.order_no || "-") : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs">
|
||||
{planIdx === 0 ? formatDate(plan.due_date) : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{planIdx === 0 ? (plan.customer_name || "-") : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{plan.part_code || "-"}</TableCell>
|
||||
<TableCell className="text-xs font-medium">{plan.part_name || "-"}</TableCell>
|
||||
<TableCell className="text-right text-xs">{formatNumber(plan.order_qty)}</TableCell>
|
||||
<TableCell className="text-right text-xs font-semibold text-primary">{formatNumber(plan.plan_qty)}</TableCell>
|
||||
<TableCell className="text-center text-xs">{formatDate(plan.plan_date)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={cn("px-1.5 py-0.5 rounded-full text-[10px] font-medium border", getStatusColor(plan.status))}>
|
||||
{getStatusLabel(plan.status)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* 상세 패널 */}
|
||||
{selectedId && selectedPlan && (
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={40} minSize={25}>
|
||||
<div className="flex flex-col h-full bg-card">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0">
|
||||
<span className="font-semibold text-sm">
|
||||
{selectedPlan.shipment_plan_no || `#${selectedPlan.id}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveDetail}
|
||||
disabled={!isDetailChanged || saving}
|
||||
className={cn("h-8", isDetailChanged ? "bg-primary" : "bg-muted text-muted-foreground")}
|
||||
>
|
||||
{saving ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <Save className="w-3.5 h-3.5 mr-1" />}
|
||||
저장
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setSelectedId(null)}>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4 space-y-5">
|
||||
{/* 기본 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-2">기본 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-0.5">상태</span>
|
||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border inline-block", getStatusColor(selectedPlan.status))}>
|
||||
{getStatusLabel(selectedPlan.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-0.5">수주번호</span>
|
||||
<span>{selectedPlan.order_no || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-0.5">거래처</span>
|
||||
<span>{selectedPlan.customer_name || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-0.5">납기일</span>
|
||||
<span>{formatDate(selectedPlan.due_date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 품목 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-2">품목 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5 text-sm bg-muted/30 p-3 rounded-md border border-border/50">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-0.5">품목코드</span>
|
||||
<span>{selectedPlan.part_code || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-0.5">품목명</span>
|
||||
<span className="font-medium">{selectedPlan.part_name || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-0.5">규격</span>
|
||||
<span>{selectedPlan.spec || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-0.5">재질</span>
|
||||
<span>{selectedPlan.material || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 수량 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-2">수량 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-0.5">수주수량</span>
|
||||
<span>{formatNumber(selectedPlan.order_qty)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs block mb-0.5">계획수량</Label>
|
||||
<Input
|
||||
type="number" className="h-8 text-sm"
|
||||
value={editPlanQty}
|
||||
onChange={(e) => { setEditPlanQty(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-0.5">출하수량</span>
|
||||
<span>{formatNumber(selectedPlan.shipped_qty)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs block mb-0.5">잔여수량</span>
|
||||
<span className={cn("font-semibold",
|
||||
(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty)) > 0
|
||||
? "text-destructive"
|
||||
: "text-emerald-600"
|
||||
)}>
|
||||
{formatNumber(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 출하 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-2">출하 정보</h3>
|
||||
<div className="grid grid-cols-1 gap-y-3 text-sm">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs block mb-0.5">출하계획일</Label>
|
||||
<Input
|
||||
type="date" className="h-8 text-sm"
|
||||
value={editPlanDate}
|
||||
onChange={(e) => { setEditPlanDate(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs block mb-0.5">비고</Label>
|
||||
<Textarea
|
||||
className="min-h-[70px] resize-y text-sm"
|
||||
value={editMemo}
|
||||
onChange={(e) => { setEditMemo(e.target.value); setIsDetailChanged(true); }}
|
||||
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
|
||||
placeholder="비고 입력"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 등록 정보 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold mb-2">등록 정보</h3>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2.5 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<span className="text-xs block mb-0.5">등록자</span>
|
||||
<span className="text-foreground">{selectedPlan.created_by || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs block mb-0.5">등록일시</span>
|
||||
<span className="text-foreground">{selectedPlan.created_date ? new Date(selectedPlan.created_date).toLocaleString("ko-KR") : "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,569 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* TableSettingsModal -- 하드코딩 페이지용 테이블 설정 모달 (3탭)
|
||||
*
|
||||
* 탭 1: 컬럼 설정 -- 컬럼 표시/숨김, 드래그 순서 변경, 너비(px) 설정, 틀고정
|
||||
* 탭 2: 필터 설정 -- 필터 활성/비활성, 필터 타입(텍스트/선택/날짜), 너비(%) 설정, 그룹별 합산
|
||||
* 탭 3: 그룹 설정 -- 그룹핑 컬럼 선택
|
||||
*
|
||||
* 설정값은 localStorage에 저장되며, onSave 콜백으로 부모 컴포넌트에 전달
|
||||
* DynamicSearchFilter, DataGrid와 함께 사용
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { GripVertical, Settings2, SlidersHorizontal, Layers, RotateCcw, Lock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import {
|
||||
DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext, verticalListSortingStrategy, useSortable, arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
// ===== 타입 =====
|
||||
|
||||
export interface ColumnSetting {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
visible: boolean;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface FilterSetting {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
enabled: boolean;
|
||||
filterType: "text" | "select" | "date";
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface GroupSetting {
|
||||
columnName: string;
|
||||
displayName: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TableSettings {
|
||||
columns: ColumnSetting[];
|
||||
filters: FilterSetting[];
|
||||
groups: GroupSetting[];
|
||||
frozenCount: number;
|
||||
groupSumEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface TableSettingsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 테이블명 (web-types API 호출용) */
|
||||
tableName: string;
|
||||
/** localStorage 키 분리용 고유 ID */
|
||||
settingsId: string;
|
||||
/** 저장 시 콜백 */
|
||||
onSave?: (settings: TableSettings) => void;
|
||||
/** 초기 탭 */
|
||||
initialTab?: "columns" | "filters" | "groups";
|
||||
}
|
||||
|
||||
// ===== 상수 =====
|
||||
|
||||
const FILTER_TYPE_OPTIONS = [
|
||||
{ value: "text", label: "텍스트" },
|
||||
{ value: "select", label: "선택" },
|
||||
{ value: "date", label: "날짜" },
|
||||
];
|
||||
|
||||
const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"];
|
||||
|
||||
// ===== 유틸 =====
|
||||
|
||||
function getStorageKey(settingsId: string) {
|
||||
return `table_settings_${settingsId}`;
|
||||
}
|
||||
|
||||
/** localStorage에서 저장된 설정 로드 (외부에서도 사용 가능) */
|
||||
export function loadTableSettings(settingsId: string): TableSettings | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(getStorageKey(settingsId));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 저장된 컬럼 순서/설정을 API 컬럼과 병합 */
|
||||
function mergeColumns(fresh: ColumnSetting[], saved: ColumnSetting[]): ColumnSetting[] {
|
||||
const savedMap = new Map(saved.map((s) => [s.columnName, s]));
|
||||
const ordered: ColumnSetting[] = [];
|
||||
// 저장된 순서대로
|
||||
for (const s of saved) {
|
||||
const f = fresh.find((c) => c.columnName === s.columnName);
|
||||
if (f) ordered.push({ ...f, visible: s.visible, width: s.width });
|
||||
}
|
||||
// 새로 추가된 컬럼은 맨 뒤에
|
||||
for (const f of fresh) {
|
||||
if (!savedMap.has(f.columnName)) ordered.push(f);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// ===== Sortable Column Row (탭 1) =====
|
||||
|
||||
function SortableColumnRow({
|
||||
col,
|
||||
onToggleVisible,
|
||||
onWidthChange,
|
||||
}: {
|
||||
col: ColumnSetting & { _idx: number };
|
||||
onToggleVisible: (idx: number) => void;
|
||||
onWidthChange: (idx: number, width: number) => void;
|
||||
}) {
|
||||
const {
|
||||
attributes, listeners, setNodeRef, transform, transition, isDragging,
|
||||
} = useSortable({ id: col.columnName });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2 px-2 rounded hover:bg-muted/50",
|
||||
isDragging && "bg-muted/50 shadow-md",
|
||||
)}
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 표시 체크박스 */}
|
||||
<Checkbox
|
||||
checked={col.visible}
|
||||
onCheckedChange={() => onToggleVisible(col._idx)}
|
||||
/>
|
||||
|
||||
{/* 표시 토글 (Switch) */}
|
||||
<Switch
|
||||
checked={col.visible}
|
||||
onCheckedChange={() => onToggleVisible(col._idx)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
{/* 컬럼명 + 기술명 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{col.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{col.columnName}</div>
|
||||
</div>
|
||||
|
||||
{/* 너비 입력 */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-xs text-muted-foreground">너비:</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={col.width}
|
||||
onChange={(e) => onWidthChange(col._idx, Number(e.target.value) || 100)}
|
||||
className="h-8 w-[70px] text-xs text-center"
|
||||
min={50}
|
||||
max={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== TableSettingsModal =====
|
||||
|
||||
export function TableSettingsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
tableName,
|
||||
settingsId,
|
||||
onSave,
|
||||
initialTab = "columns",
|
||||
}: TableSettingsModalProps) {
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 임시 설정 (모달 내에서만 수정, 저장 시 반영)
|
||||
const [tempColumns, setTempColumns] = useState<ColumnSetting[]>([]);
|
||||
const [tempFilters, setTempFilters] = useState<FilterSetting[]>([]);
|
||||
const [tempGroups, setTempGroups] = useState<GroupSetting[]>([]);
|
||||
const [tempFrozenCount, setTempFrozenCount] = useState(0);
|
||||
const [tempGroupSum, setTempGroupSum] = useState(false);
|
||||
|
||||
// 원본 컬럼 (초기화용)
|
||||
const [defaultColumns, setDefaultColumns] = useState<ColumnSetting[]>([]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||
);
|
||||
|
||||
// 모달 열릴 때 데이터 로드
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setActiveTab(initialTab);
|
||||
loadData();
|
||||
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get(`/table-management/tables/${tableName}/web-types`);
|
||||
const types: any[] = res.data?.data || [];
|
||||
|
||||
// 기본 컬럼 설정 생성
|
||||
const freshColumns: ColumnSetting[] = types
|
||||
.filter((t) => !AUTO_COLS.includes(t.columnName))
|
||||
.map((t) => ({
|
||||
columnName: t.columnName,
|
||||
displayName: t.displayName || t.columnLabel || t.columnName,
|
||||
visible: true,
|
||||
width: 120,
|
||||
}));
|
||||
|
||||
// 기본 필터 설정 생성
|
||||
const freshFilters: FilterSetting[] = freshColumns.map((c) => {
|
||||
const wt = types.find((t) => t.columnName === c.columnName);
|
||||
let filterType: "text" | "select" | "date" = "text";
|
||||
if (wt?.inputType === "category" || wt?.inputType === "select") filterType = "select";
|
||||
else if (wt?.inputType === "date" || wt?.inputType === "datetime") filterType = "date";
|
||||
return {
|
||||
columnName: c.columnName,
|
||||
displayName: c.displayName,
|
||||
enabled: false,
|
||||
filterType,
|
||||
width: 25,
|
||||
};
|
||||
});
|
||||
|
||||
// 기본 그룹 설정 생성
|
||||
const freshGroups: GroupSetting[] = freshColumns.map((c) => ({
|
||||
columnName: c.columnName,
|
||||
displayName: c.displayName,
|
||||
enabled: false,
|
||||
}));
|
||||
|
||||
setDefaultColumns(freshColumns);
|
||||
|
||||
// localStorage에서 저장된 설정 복원
|
||||
const saved = loadTableSettings(settingsId);
|
||||
if (saved) {
|
||||
setTempColumns(mergeColumns(freshColumns, saved.columns));
|
||||
setTempFilters(freshFilters.map((f) => {
|
||||
const s = saved.filters?.find((sf) => sf.columnName === f.columnName);
|
||||
return s ? { ...f, enabled: s.enabled, filterType: s.filterType, width: s.width } : f;
|
||||
}));
|
||||
setTempGroups(freshGroups.map((g) => {
|
||||
const s = saved.groups?.find((sg) => sg.columnName === g.columnName);
|
||||
return s ? { ...g, enabled: s.enabled } : g;
|
||||
}));
|
||||
setTempFrozenCount(saved.frozenCount || 0);
|
||||
setTempGroupSum(saved.groupSumEnabled || false);
|
||||
} else {
|
||||
setTempColumns(freshColumns);
|
||||
setTempFilters(freshFilters);
|
||||
setTempGroups(freshGroups);
|
||||
setTempFrozenCount(0);
|
||||
setTempGroupSum(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("테이블 설정 로드 실패:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = () => {
|
||||
const settings: TableSettings = {
|
||||
columns: tempColumns,
|
||||
filters: tempFilters,
|
||||
groups: tempGroups,
|
||||
frozenCount: tempFrozenCount,
|
||||
groupSumEnabled: tempGroupSum,
|
||||
};
|
||||
localStorage.setItem(getStorageKey(settingsId), JSON.stringify(settings));
|
||||
onSave?.(settings);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 컬럼 설정 초기화
|
||||
const handleResetColumns = () => {
|
||||
setTempColumns(defaultColumns.map((c) => ({ ...c })));
|
||||
setTempFrozenCount(0);
|
||||
};
|
||||
|
||||
// ===== 컬럼 설정 핸들러 =====
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
setTempColumns((prev) => {
|
||||
const oldIdx = prev.findIndex((c) => c.columnName === active.id);
|
||||
const newIdx = prev.findIndex((c) => c.columnName === over.id);
|
||||
return arrayMove(prev, oldIdx, newIdx);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleColumnVisible = (idx: number) => {
|
||||
setTempColumns((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], visible: !next[idx].visible };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const changeColumnWidth = (idx: number, width: number) => {
|
||||
setTempColumns((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], width };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ===== 필터 설정 핸들러 =====
|
||||
|
||||
const allFiltersEnabled = tempFilters.length > 0 && tempFilters.every((f) => f.enabled);
|
||||
|
||||
const toggleFilterAll = (checked: boolean) => {
|
||||
setTempFilters((prev) => prev.map((f) => ({ ...f, enabled: checked })));
|
||||
};
|
||||
|
||||
const toggleFilter = (idx: number) => {
|
||||
setTempFilters((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], enabled: !next[idx].enabled };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const changeFilterType = (idx: number, filterType: "text" | "select" | "date") => {
|
||||
setTempFilters((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], filterType };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const changeFilterWidth = (idx: number, width: number) => {
|
||||
setTempFilters((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], width };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// ===== 그룹 설정 핸들러 =====
|
||||
|
||||
const toggleGroup = (idx: number) => {
|
||||
setTempGroups((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], enabled: !next[idx].enabled };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const visibleCount = tempColumns.filter((c) => c.visible).length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>테이블 설정</DialogTitle>
|
||||
<DialogDescription>테이블의 컬럼, 필터, 그룹화를 설정합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
||||
로딩 중...
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as typeof activeTab)} className="flex-1 flex flex-col min-h-0">
|
||||
<TabsList className="grid w-full grid-cols-3 shrink-0">
|
||||
<TabsTrigger value="columns" className="flex items-center gap-1.5">
|
||||
<Settings2 className="h-3.5 w-3.5" /> 컬럼 설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="filters" className="flex items-center gap-1.5">
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" /> 필터 설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="groups" className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" /> 그룹 설정
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ===== 탭 1: 컬럼 설정 ===== */}
|
||||
<TabsContent value="columns" className="flex-1 overflow-auto mt-0 pt-3">
|
||||
{/* 헤더: 표시 수 / 틀고정 / 초기화 */}
|
||||
<div className="flex items-center justify-between px-2 pb-3 border-b mb-2">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span>
|
||||
{visibleCount}/{tempColumns.length}개 컬럼 표시 중
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Lock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">틀고정:</span>
|
||||
<Input
|
||||
type="number"
|
||||
value={tempFrozenCount}
|
||||
onChange={(e) =>
|
||||
setTempFrozenCount(
|
||||
Math.min(Math.max(0, Number(e.target.value) || 0), tempColumns.length)
|
||||
)
|
||||
}
|
||||
className="h-7 w-[50px] text-xs text-center"
|
||||
min={0}
|
||||
max={tempColumns.length}
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">개 컬럼</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleResetColumns} className="text-xs">
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 목록 (드래그 순서 변경 가능) */}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={tempColumns.map((c) => c.columnName)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
{tempColumns.map((col, idx) => (
|
||||
<SortableColumnRow
|
||||
key={col.columnName}
|
||||
col={{ ...col, _idx: idx }}
|
||||
onToggleVisible={toggleColumnVisible}
|
||||
onWidthChange={changeColumnWidth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 탭 2: 필터 설정 ===== */}
|
||||
<TabsContent value="filters" className="flex-1 overflow-auto mt-0 pt-3">
|
||||
{/* 전체 선택 */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-2 pb-3 border-b mb-2 cursor-pointer"
|
||||
onClick={() => toggleFilterAll(!allFiltersEnabled)}
|
||||
>
|
||||
<Checkbox checked={allFiltersEnabled} />
|
||||
<span className="text-sm">전체 선택</span>
|
||||
</div>
|
||||
|
||||
{/* 필터 목록 */}
|
||||
<div className="space-y-1">
|
||||
{tempFilters.map((filter, idx) => (
|
||||
<div
|
||||
key={filter.columnName}
|
||||
className="flex items-center gap-3 py-1.5 px-2 hover:bg-muted/50 rounded"
|
||||
>
|
||||
<Checkbox
|
||||
checked={filter.enabled}
|
||||
onCheckedChange={() => toggleFilter(idx)}
|
||||
/>
|
||||
<div className="flex-1 text-sm min-w-0 truncate">{filter.displayName}</div>
|
||||
<Select
|
||||
value={filter.filterType}
|
||||
onValueChange={(v) => changeFilterType(idx, v as any)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[90px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
value={filter.width}
|
||||
onChange={(e) => changeFilterWidth(idx, Number(e.target.value) || 25)}
|
||||
className="h-8 w-[55px] text-xs text-center"
|
||||
min={10}
|
||||
max={100}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 그룹별 합산 토글 */}
|
||||
<div className="mt-4 flex items-center justify-between rounded-lg border p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">그룹별 합산</div>
|
||||
<div className="text-xs text-muted-foreground">같은 값끼리 그룹핑하여 합산</div>
|
||||
</div>
|
||||
<Switch checked={tempGroupSum} onCheckedChange={setTempGroupSum} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ===== 탭 3: 그룹 설정 ===== */}
|
||||
<TabsContent value="groups" className="flex-1 overflow-auto mt-0 pt-3">
|
||||
<div className="px-2 pb-3 border-b mb-2">
|
||||
<span className="text-sm font-medium">사용 가능한 컬럼</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{tempGroups.map((group, idx) => (
|
||||
<div
|
||||
key={group.columnName}
|
||||
className={cn(
|
||||
"flex items-center gap-3 py-2.5 px-3 rounded cursor-pointer hover:bg-muted/50",
|
||||
group.enabled && "bg-primary/5",
|
||||
)}
|
||||
onClick={() => toggleGroup(idx)}
|
||||
>
|
||||
<Checkbox checked={group.enabled} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{group.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{group.columnName}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave}>저장</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,825 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* TimelineScheduler — 하드코딩 페이지용 공통 타임라인 스케줄러 컴포넌트
|
||||
*
|
||||
* 기능:
|
||||
* - 리소스(설비/품목) 기준 Y축, 날짜 기준 X축
|
||||
* - 줌 레벨 전환 (일/주/월)
|
||||
* - 날짜 네비게이션 (이전/다음/오늘)
|
||||
* - 이벤트 바 드래그 이동
|
||||
* - 이벤트 바 리사이즈 (좌/우 핸들)
|
||||
* - 오늘 날짜 빨간 세로선
|
||||
* - 진행률 바 시각화
|
||||
* - 마일스톤 (다이아몬드) 표시
|
||||
* - 상태별 색상 + 범례
|
||||
* - 충돌 감지 (같은 리소스에서 겹침 시 빨간 테두리)
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useMemo, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CalendarDays,
|
||||
Loader2,
|
||||
Diamond,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ─── 타입 정의 ───
|
||||
|
||||
export interface TimelineResource {
|
||||
id: string;
|
||||
label: string;
|
||||
subLabel?: string;
|
||||
}
|
||||
|
||||
export interface TimelineEvent {
|
||||
id: string | number;
|
||||
resourceId: string;
|
||||
startDate: string; // YYYY-MM-DD
|
||||
endDate: string; // YYYY-MM-DD
|
||||
label?: string;
|
||||
status?: string;
|
||||
progress?: number; // 0~100
|
||||
isMilestone?: boolean;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export type ZoomLevel = "day" | "week" | "month";
|
||||
|
||||
export interface StatusColor {
|
||||
key: string;
|
||||
label: string;
|
||||
bgClass: string; // tailwind gradient class e.g. "from-blue-500 to-blue-600"
|
||||
}
|
||||
|
||||
export interface TimelineSchedulerProps {
|
||||
resources: TimelineResource[];
|
||||
events: TimelineEvent[];
|
||||
/** 타임라인 시작 기준일 (기본: 오늘) */
|
||||
startDate?: Date;
|
||||
/** 줌 레벨 (기본: week) */
|
||||
zoomLevel?: ZoomLevel;
|
||||
onZoomChange?: (zoom: ZoomLevel) => void;
|
||||
/** 이벤트 바 클릭 */
|
||||
onEventClick?: (event: TimelineEvent) => void;
|
||||
/** 드래그 이동 완료 */
|
||||
onEventMove?: (eventId: string | number, newStartDate: string, newEndDate: string) => void;
|
||||
/** 리사이즈 완료 */
|
||||
onEventResize?: (eventId: string | number, newStartDate: string, newEndDate: string) => void;
|
||||
/** 상태별 색상 배열 */
|
||||
statusColors?: StatusColor[];
|
||||
/** 진행률 바 표시 여부 */
|
||||
showProgress?: boolean;
|
||||
/** 마일스톤 표시 여부 */
|
||||
showMilestones?: boolean;
|
||||
/** 오늘 세로선 표시 */
|
||||
showTodayLine?: boolean;
|
||||
/** 범례 표시 */
|
||||
showLegend?: boolean;
|
||||
/** 충돌 감지 */
|
||||
conflictDetection?: boolean;
|
||||
/** 로딩 상태 */
|
||||
loading?: boolean;
|
||||
/** 데이터 없을 때 메시지 */
|
||||
emptyMessage?: string;
|
||||
/** 데이터 없을 때 아이콘 */
|
||||
emptyIcon?: React.ReactNode;
|
||||
/** 리소스 열 너비 (px) */
|
||||
resourceWidth?: number;
|
||||
/** 행 높이 (px) */
|
||||
rowHeight?: number;
|
||||
}
|
||||
|
||||
// ─── 기본값 ───
|
||||
|
||||
const DEFAULT_STATUS_COLORS: StatusColor[] = [
|
||||
{ key: "planned", label: "계획", bgClass: "from-blue-500 to-blue-600" },
|
||||
{ key: "work-order", label: "지시", bgClass: "from-amber-500 to-amber-600" },
|
||||
{ key: "in-progress", label: "진행", bgClass: "from-emerald-500 to-emerald-600" },
|
||||
{ key: "completed", label: "완료", bgClass: "from-gray-400 to-gray-500" },
|
||||
];
|
||||
|
||||
const ZOOM_CONFIG: Record<ZoomLevel, { cellWidth: number; spanDays: number; navStep: number }> = {
|
||||
day: { cellWidth: 60, spanDays: 28, navStep: 7 },
|
||||
week: { cellWidth: 36, spanDays: 56, navStep: 14 },
|
||||
month: { cellWidth: 16, spanDays: 90, navStep: 30 },
|
||||
};
|
||||
|
||||
// ─── 유틸리티 함수 ───
|
||||
|
||||
/** YYYY-MM-DD 문자열로 변환 */
|
||||
function toDateStr(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** 날짜 문자열을 Date로 (시간 0시) */
|
||||
function parseDate(s: string): Date {
|
||||
const [y, m, d] = s.split("T")[0].split("-").map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
/** 두 날짜 사이의 일 수 차이 */
|
||||
function diffDays(a: Date, b: Date): number {
|
||||
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
/** 날짜에 일 수 더하기 */
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setDate(r.getDate() + n);
|
||||
return r;
|
||||
}
|
||||
|
||||
function isWeekend(d: Date): boolean {
|
||||
return d.getDay() === 0 || d.getDay() === 6;
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
||||
}
|
||||
|
||||
const DAY_NAMES = ["일", "월", "화", "수", "목", "금", "토"];
|
||||
const MONTH_NAMES = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"];
|
||||
|
||||
// ─── 충돌 감지 ───
|
||||
|
||||
function detectConflicts(events: TimelineEvent[]): Set<string | number> {
|
||||
const conflictIds = new Set<string | number>();
|
||||
const byResource = new Map<string, TimelineEvent[]>();
|
||||
|
||||
for (const ev of events) {
|
||||
if (ev.isMilestone) continue;
|
||||
if (!byResource.has(ev.resourceId)) byResource.set(ev.resourceId, []);
|
||||
byResource.get(ev.resourceId)!.push(ev);
|
||||
}
|
||||
|
||||
for (const [, resEvents] of byResource) {
|
||||
for (let i = 0; i < resEvents.length; i++) {
|
||||
for (let j = i + 1; j < resEvents.length; j++) {
|
||||
const a = resEvents[i];
|
||||
const b = resEvents[j];
|
||||
const aStart = parseDate(a.startDate).getTime();
|
||||
const aEnd = parseDate(a.endDate).getTime();
|
||||
const bStart = parseDate(b.startDate).getTime();
|
||||
const bEnd = parseDate(b.endDate).getTime();
|
||||
if (aStart <= bEnd && bStart <= aEnd) {
|
||||
conflictIds.add(a.id);
|
||||
conflictIds.add(b.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflictIds;
|
||||
}
|
||||
|
||||
// ─── 메인 컴포넌트 ───
|
||||
|
||||
export default function TimelineScheduler({
|
||||
resources,
|
||||
events,
|
||||
startDate: propStartDate,
|
||||
zoomLevel: propZoom,
|
||||
onZoomChange,
|
||||
onEventClick,
|
||||
onEventMove,
|
||||
onEventResize,
|
||||
statusColors = DEFAULT_STATUS_COLORS,
|
||||
showProgress = true,
|
||||
showMilestones = true,
|
||||
showTodayLine = true,
|
||||
showLegend = true,
|
||||
conflictDetection = true,
|
||||
loading = false,
|
||||
emptyMessage = "데이터가 없습니다",
|
||||
emptyIcon,
|
||||
resourceWidth = 160,
|
||||
rowHeight = 48,
|
||||
}: TimelineSchedulerProps) {
|
||||
// ── 상태 ──
|
||||
const [zoom, setZoom] = useState<ZoomLevel>(propZoom || "week");
|
||||
const [baseDate, setBaseDate] = useState<Date>(() => {
|
||||
const d = propStartDate || new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
});
|
||||
|
||||
// 드래그/리사이즈 상태
|
||||
const [dragState, setDragState] = useState<{
|
||||
eventId: string | number;
|
||||
mode: "move" | "resize-left" | "resize-right";
|
||||
origStartDate: string;
|
||||
origEndDate: string;
|
||||
startX: number;
|
||||
currentOffsetDays: number;
|
||||
} | null>(null);
|
||||
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 줌 레벨 동기화
|
||||
useEffect(() => {
|
||||
if (propZoom && propZoom !== zoom) setZoom(propZoom);
|
||||
}, [propZoom]);
|
||||
|
||||
const config = ZOOM_CONFIG[zoom];
|
||||
const today = useMemo(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}, []);
|
||||
|
||||
// 날짜 배열 생성
|
||||
const dates = useMemo(() => {
|
||||
const arr: Date[] = [];
|
||||
for (let i = 0; i < config.spanDays; i++) {
|
||||
arr.push(addDays(baseDate, i));
|
||||
}
|
||||
return arr;
|
||||
}, [baseDate, config.spanDays]);
|
||||
|
||||
const totalWidth = config.cellWidth * config.spanDays;
|
||||
|
||||
// 충돌 ID 집합
|
||||
const conflictIds = useMemo(() => {
|
||||
return conflictDetection ? detectConflicts(events) : new Set<string | number>();
|
||||
}, [events, conflictDetection]);
|
||||
|
||||
// 리소스별 이벤트 그룹
|
||||
const eventsByResource = useMemo(() => {
|
||||
const map = new Map<string, TimelineEvent[]>();
|
||||
for (const r of resources) map.set(r.id, []);
|
||||
for (const ev of events) {
|
||||
if (!map.has(ev.resourceId)) map.set(ev.resourceId, []);
|
||||
map.get(ev.resourceId)!.push(ev);
|
||||
}
|
||||
return map;
|
||||
}, [resources, events]);
|
||||
|
||||
// 같은 리소스 내 겹치는 이벤트들의 행(lane) 계산
|
||||
const eventLanes = useMemo(() => {
|
||||
const laneMap = new Map<string | number, number>();
|
||||
for (const [, resEvents] of eventsByResource) {
|
||||
// 시작일 기준 정렬
|
||||
const sorted = [...resEvents].sort(
|
||||
(a, b) => parseDate(a.startDate).getTime() - parseDate(b.startDate).getTime()
|
||||
);
|
||||
const lanes: { endTime: number }[] = [];
|
||||
for (const ev of sorted) {
|
||||
if (ev.isMilestone) {
|
||||
laneMap.set(ev.id, 0);
|
||||
continue;
|
||||
}
|
||||
const evStart = parseDate(ev.startDate).getTime();
|
||||
const evEnd = parseDate(ev.endDate).getTime();
|
||||
let placed = false;
|
||||
for (let l = 0; l < lanes.length; l++) {
|
||||
if (evStart > lanes[l].endTime) {
|
||||
lanes[l].endTime = evEnd;
|
||||
laneMap.set(ev.id, l);
|
||||
placed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!placed) {
|
||||
laneMap.set(ev.id, lanes.length);
|
||||
lanes.push({ endTime: evEnd });
|
||||
}
|
||||
}
|
||||
}
|
||||
return laneMap;
|
||||
}, [eventsByResource]);
|
||||
|
||||
// 리소스별 최대 lane 수 -> 행 높이 결정
|
||||
const resourceLaneCounts = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const [resId, resEvents] of eventsByResource) {
|
||||
let maxLane = 0;
|
||||
for (const ev of resEvents) {
|
||||
const lane = eventLanes.get(ev.id) || 0;
|
||||
maxLane = Math.max(maxLane, lane);
|
||||
}
|
||||
map.set(resId, resEvents.length > 0 ? maxLane + 1 : 1);
|
||||
}
|
||||
return map;
|
||||
}, [eventsByResource, eventLanes]);
|
||||
|
||||
// ── 줌/네비게이션 핸들러 ──
|
||||
|
||||
const handleZoom = useCallback(
|
||||
(z: ZoomLevel) => {
|
||||
setZoom(z);
|
||||
onZoomChange?.(z);
|
||||
},
|
||||
[onZoomChange]
|
||||
);
|
||||
|
||||
const handleNavPrev = useCallback(() => {
|
||||
setBaseDate((prev) => addDays(prev, -config.navStep));
|
||||
}, [config.navStep]);
|
||||
|
||||
const handleNavNext = useCallback(() => {
|
||||
setBaseDate((prev) => addDays(prev, config.navStep));
|
||||
}, [config.navStep]);
|
||||
|
||||
const handleNavToday = useCallback(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
setBaseDate(d);
|
||||
}, []);
|
||||
|
||||
// ── 이벤트 바 위치 계산 ──
|
||||
|
||||
const getBarStyle = useCallback(
|
||||
(startDateStr: string, endDateStr: string) => {
|
||||
const evStart = parseDate(startDateStr);
|
||||
const evEnd = parseDate(endDateStr);
|
||||
const firstDate = dates[0];
|
||||
const lastDate = dates[dates.length - 1];
|
||||
|
||||
// 완전히 범위 밖이면 표시하지 않음
|
||||
if (evEnd < firstDate || evStart > lastDate) return null;
|
||||
|
||||
const startIdx = Math.max(0, diffDays(firstDate, evStart));
|
||||
const endIdx = Math.min(config.spanDays - 1, diffDays(firstDate, evEnd));
|
||||
const left = startIdx * config.cellWidth;
|
||||
const width = (endIdx - startIdx + 1) * config.cellWidth;
|
||||
|
||||
return { left, width };
|
||||
},
|
||||
[dates, config.cellWidth, config.spanDays]
|
||||
);
|
||||
|
||||
// ── 드래그/리사이즈 핸들러 ──
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(
|
||||
e: React.MouseEvent,
|
||||
eventId: string | number,
|
||||
mode: "move" | "resize-left" | "resize-right",
|
||||
startDate: string,
|
||||
endDate: string
|
||||
) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragState({
|
||||
eventId,
|
||||
mode,
|
||||
origStartDate: startDate,
|
||||
origEndDate: endDate,
|
||||
startX: e.clientX,
|
||||
currentOffsetDays: 0,
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// mousemove / mouseup (document-level)
|
||||
useEffect(() => {
|
||||
if (!dragState) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const dx = e.clientX - dragState.startX;
|
||||
const dayOffset = Math.round(dx / config.cellWidth);
|
||||
setDragState((prev) => (prev ? { ...prev, currentOffsetDays: dayOffset } : null));
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: MouseEvent) => {
|
||||
if (!dragState) return;
|
||||
const dx = e.clientX - dragState.startX;
|
||||
const dayOffset = Math.round(dx / config.cellWidth);
|
||||
|
||||
if (dayOffset !== 0) {
|
||||
const origStart = parseDate(dragState.origStartDate);
|
||||
const origEnd = parseDate(dragState.origEndDate);
|
||||
|
||||
if (dragState.mode === "move") {
|
||||
const newStart = toDateStr(addDays(origStart, dayOffset));
|
||||
const newEnd = toDateStr(addDays(origEnd, dayOffset));
|
||||
onEventMove?.(dragState.eventId, newStart, newEnd);
|
||||
} else if (dragState.mode === "resize-left") {
|
||||
const newStart = toDateStr(addDays(origStart, dayOffset));
|
||||
const newEnd = dragState.origEndDate.split("T")[0];
|
||||
// 시작이 종료를 넘지 않도록
|
||||
if (parseDate(newStart) <= parseDate(newEnd)) {
|
||||
onEventResize?.(dragState.eventId, newStart, newEnd);
|
||||
}
|
||||
} else if (dragState.mode === "resize-right") {
|
||||
const newStart = dragState.origStartDate.split("T")[0];
|
||||
const newEnd = toDateStr(addDays(origEnd, dayOffset));
|
||||
if (parseDate(newStart) <= parseDate(newEnd)) {
|
||||
onEventResize?.(dragState.eventId, newStart, newEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setDragState(null);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [dragState, config.cellWidth, onEventMove, onEventResize]);
|
||||
|
||||
// 드래그 중인 이벤트의 현재 표시 위치 계산
|
||||
const getDraggedBarStyle = useCallback(
|
||||
(event: TimelineEvent) => {
|
||||
if (!dragState || dragState.eventId !== event.id) return null;
|
||||
|
||||
const origStart = parseDate(dragState.origStartDate);
|
||||
const origEnd = parseDate(dragState.origEndDate);
|
||||
const offset = dragState.currentOffsetDays;
|
||||
|
||||
let newStart: Date, newEnd: Date;
|
||||
if (dragState.mode === "move") {
|
||||
newStart = addDays(origStart, offset);
|
||||
newEnd = addDays(origEnd, offset);
|
||||
} else if (dragState.mode === "resize-left") {
|
||||
newStart = addDays(origStart, offset);
|
||||
newEnd = origEnd;
|
||||
if (newStart > newEnd) newStart = newEnd;
|
||||
} else {
|
||||
newStart = origStart;
|
||||
newEnd = addDays(origEnd, offset);
|
||||
if (newEnd < newStart) newEnd = newStart;
|
||||
}
|
||||
|
||||
return getBarStyle(toDateStr(newStart), toDateStr(newEnd));
|
||||
},
|
||||
[dragState, getBarStyle]
|
||||
);
|
||||
|
||||
// ── 오늘 라인 위치 ──
|
||||
|
||||
const todayLineLeft = useMemo(() => {
|
||||
if (!showTodayLine || dates.length === 0) return null;
|
||||
const firstDate = dates[0];
|
||||
const lastDate = dates[dates.length - 1];
|
||||
if (today < firstDate || today > lastDate) return null;
|
||||
const idx = diffDays(firstDate, today);
|
||||
return idx * config.cellWidth + config.cellWidth / 2;
|
||||
}, [dates, today, config.cellWidth, showTodayLine]);
|
||||
|
||||
// ── 상태 색상 매핑 ──
|
||||
|
||||
const getStatusColor = useCallback(
|
||||
(status?: string) => {
|
||||
if (!status) return statusColors[0]?.bgClass || "from-blue-500 to-blue-600";
|
||||
const found = statusColors.find((c) => c.key === status);
|
||||
return found?.bgClass || statusColors[0]?.bgClass || "from-blue-500 to-blue-600";
|
||||
},
|
||||
[statusColors]
|
||||
);
|
||||
|
||||
// ── 날짜 헤더 그룹 ──
|
||||
|
||||
const dateGroups = useMemo(() => {
|
||||
if (zoom === "day") {
|
||||
return null; // day 뷰에서는 상위 그룹 없이 바로 날짜 표시
|
||||
}
|
||||
|
||||
// week / month 뷰: 월 단위로 그룹
|
||||
const groups: { label: string; span: number; startIdx: number }[] = [];
|
||||
let currentMonth = -1;
|
||||
let currentYear = -1;
|
||||
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const d = dates[i];
|
||||
if (d.getMonth() !== currentMonth || d.getFullYear() !== currentYear) {
|
||||
groups.push({
|
||||
label: `${d.getFullYear()}년 ${MONTH_NAMES[d.getMonth()]}`,
|
||||
span: 1,
|
||||
startIdx: i,
|
||||
});
|
||||
currentMonth = d.getMonth();
|
||||
currentYear = d.getFullYear();
|
||||
} else {
|
||||
groups[groups.length - 1].span++;
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [dates, zoom]);
|
||||
|
||||
// ── 렌더링 ──
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resources.length === 0 || events.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
{emptyIcon}
|
||||
<p className="text-base font-medium mb-2 mt-3">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const barHeight = 24;
|
||||
const barGap = 2;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 컨트롤 바 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleNavPrev}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleNavToday}>
|
||||
<CalendarDays className="mr-1 h-4 w-4" />
|
||||
오늘
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleNavNext}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{toDateStr(dates[0])} ~ {toDateStr(dates[dates.length - 1])}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(["day", "week", "month"] as ZoomLevel[]).map((z) => (
|
||||
<Button
|
||||
key={z}
|
||||
size="sm"
|
||||
variant={zoom === z ? "default" : "outline"}
|
||||
className="h-7 text-xs px-3"
|
||||
onClick={() => handleZoom(z)}
|
||||
>
|
||||
{z === "day" ? "일" : z === "week" ? "주" : "월"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
{showLegend && (
|
||||
<div className="flex items-center gap-4 flex-wrap text-xs">
|
||||
<span className="font-semibold text-muted-foreground">상태:</span>
|
||||
{statusColors.map((sc) => (
|
||||
<div key={sc.key} className="flex items-center gap-1.5">
|
||||
<div className={cn("h-3.5 w-5 rounded bg-gradient-to-br", sc.bgClass)} />
|
||||
<span>{sc.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{showMilestones && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Diamond className="h-3.5 w-3.5 text-purple-500 fill-purple-500" />
|
||||
<span>마일스톤</span>
|
||||
</div>
|
||||
)}
|
||||
{conflictDetection && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3.5 w-5 rounded border-2 border-red-500 bg-red-500/20" />
|
||||
<span>충돌</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 타임라인 본체 */}
|
||||
<div className="rounded-lg border bg-background overflow-hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-x-auto overflow-y-auto"
|
||||
style={{ maxHeight: "calc(100vh - 350px)" }}
|
||||
>
|
||||
<div className="flex" style={{ minWidth: resourceWidth + totalWidth }}>
|
||||
{/* 좌측: 리소스 라벨 */}
|
||||
<div
|
||||
className="shrink-0 border-r bg-muted/30 z-20 sticky left-0"
|
||||
style={{ width: resourceWidth }}
|
||||
>
|
||||
{/* 헤더 공간 */}
|
||||
<div
|
||||
className="border-b bg-muted/50 flex items-center justify-center text-xs font-semibold text-muted-foreground"
|
||||
style={{ height: dateGroups ? 60 : 36 }}
|
||||
>
|
||||
리소스
|
||||
</div>
|
||||
{/* 리소스 행 */}
|
||||
{resources.map((res) => {
|
||||
const laneCount = resourceLaneCounts.get(res.id) || 1;
|
||||
const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
|
||||
return (
|
||||
<div
|
||||
key={res.id}
|
||||
className="border-b px-3 flex flex-col justify-center"
|
||||
style={{ height: h }}
|
||||
>
|
||||
<span className="text-xs font-semibold text-foreground truncate">
|
||||
{res.label}
|
||||
</span>
|
||||
{res.subLabel && (
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{res.subLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 우측: 타임라인 그리드 */}
|
||||
<div className="flex-1 relative" ref={gridRef} style={{ width: totalWidth }}>
|
||||
{/* 날짜 헤더 */}
|
||||
<div className="sticky top-0 z-10 bg-background border-b">
|
||||
{/* 상위 그룹 (월) */}
|
||||
{dateGroups && (
|
||||
<div className="flex border-b">
|
||||
{dateGroups.map((g, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-center text-[11px] font-semibold text-muted-foreground border-r py-1"
|
||||
style={{ width: g.span * config.cellWidth }}
|
||||
>
|
||||
{g.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 하위 날짜 셀 */}
|
||||
<div className="flex">
|
||||
{dates.map((date, idx) => {
|
||||
const isT = isSameDay(date, today);
|
||||
const isW = isWeekend(date);
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"text-center border-r select-none",
|
||||
isW && "text-red-400",
|
||||
isT && "bg-primary/10 font-bold text-primary"
|
||||
)}
|
||||
style={{
|
||||
width: config.cellWidth,
|
||||
minWidth: config.cellWidth,
|
||||
fontSize: zoom === "month" ? 9 : 11,
|
||||
padding: zoom === "month" ? "2px 0" : "3px 0",
|
||||
}}
|
||||
>
|
||||
{zoom === "month" ? (
|
||||
<div>{date.getDate()}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-semibold">{date.getDate()}</div>
|
||||
<div>{DAY_NAMES[date.getDay()]}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리소스별 이벤트 행 */}
|
||||
{resources.map((res) => {
|
||||
const resEvents = eventsByResource.get(res.id) || [];
|
||||
const laneCount = resourceLaneCounts.get(res.id) || 1;
|
||||
const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
|
||||
|
||||
return (
|
||||
<div key={res.id} className="relative border-b" style={{ height: h }}>
|
||||
{/* 배경 그리드 */}
|
||||
<div className="absolute inset-0 flex pointer-events-none">
|
||||
{dates.map((date, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"border-r border-border/20",
|
||||
isWeekend(date) && "bg-red-500/[0.03]"
|
||||
)}
|
||||
style={{ width: config.cellWidth, minWidth: config.cellWidth }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 오늘 라인 */}
|
||||
{todayLineLeft != null && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-[2px] bg-red-500 z-[5] pointer-events-none"
|
||||
style={{ left: todayLineLeft }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 이벤트 바 */}
|
||||
{resEvents.map((ev) => {
|
||||
if (ev.isMilestone && showMilestones) {
|
||||
// 마일스톤: 다이아몬드 아이콘
|
||||
const pos = getBarStyle(ev.startDate, ev.startDate);
|
||||
if (!pos) return null;
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="absolute z-10 flex items-center justify-center cursor-pointer"
|
||||
style={{
|
||||
left: pos.left + pos.width / 2 - 8,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
title={ev.label || "마일스톤"}
|
||||
onClick={() => onEventClick?.(ev)}
|
||||
>
|
||||
<Diamond className="h-4 w-4 text-purple-500 fill-purple-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 이벤트 바
|
||||
const isDragging = dragState?.eventId === ev.id;
|
||||
const barStyle = isDragging
|
||||
? getDraggedBarStyle(ev)
|
||||
: getBarStyle(ev.startDate, ev.endDate);
|
||||
if (!barStyle) return null;
|
||||
|
||||
const lane = eventLanes.get(ev.id) || 0;
|
||||
const colorClass = getStatusColor(ev.status);
|
||||
const isConflict = conflictIds.has(ev.id);
|
||||
const progress = ev.progress ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className={cn(
|
||||
"absolute rounded shadow-sm z-10 group select-none",
|
||||
`bg-gradient-to-br ${colorClass}`,
|
||||
isDragging && "opacity-80 shadow-lg z-20",
|
||||
isConflict && "ring-2 ring-red-500 ring-offset-1",
|
||||
"cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
style={{
|
||||
left: barStyle.left,
|
||||
width: Math.max(barStyle.width, config.cellWidth * 0.5),
|
||||
height: barHeight,
|
||||
top: 6 + lane * (barHeight + barGap),
|
||||
}}
|
||||
title={`${ev.label || ""} | ${ev.startDate.split("T")[0]} ~ ${ev.endDate.split("T")[0]}${progress > 0 ? ` | ${progress}%` : ""}`}
|
||||
onClick={(e) => {
|
||||
if (!isDragging) {
|
||||
e.stopPropagation();
|
||||
onEventClick?.(ev);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => handleMouseDown(e, ev.id, "move", ev.startDate, ev.endDate)}
|
||||
>
|
||||
{/* 진행률 바 */}
|
||||
{showProgress && progress > 0 && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-l bg-white/25"
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 라벨 */}
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-white drop-shadow-sm truncate px-1">
|
||||
{ev.label || ""}
|
||||
{showProgress && progress > 0 && (
|
||||
<span className="ml-1 opacity-75">({progress}%)</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 좌측 리사이즈 핸들 */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-[5px] cursor-col-resize opacity-0 group-hover:opacity-100 bg-white/30 rounded-l"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, ev.id, "resize-left", ev.startDate, ev.endDate);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 우측 리사이즈 핸들 */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-[5px] cursor-col-resize opacity-0 group-hover:opacity-100 bg-white/30 rounded-r"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, ev.id, "resize-right", ev.startDate, ev.endDate);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -648,24 +648,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
|||
);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
// valueCode 및 valueId -> {label, color} 매핑 생성
|
||||
// valueCode 및 valueId -> {label, color} 매핑 생성 (트리 재귀 평탄화)
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
response.data.data.forEach((item: any) => {
|
||||
// valueCode로 매핑
|
||||
if (item.valueCode) {
|
||||
mapping[item.valueCode] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
// valueId로도 매핑 (숫자 ID 저장 시 라벨 표시용)
|
||||
if (item.valueId !== undefined && item.valueId !== null) {
|
||||
mapping[String(item.valueId)] = {
|
||||
label: item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
});
|
||||
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
|
||||
items.forEach((item: any) => {
|
||||
const displayLabel = parentLabel
|
||||
? `${parentLabel} / ${item.valueLabel}`
|
||||
: item.valueLabel;
|
||||
if (item.valueCode) {
|
||||
mapping[item.valueCode] = {
|
||||
label: displayLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
if (item.valueId !== undefined && item.valueId !== null) {
|
||||
mapping[String(item.valueId)] = {
|
||||
label: displayLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||
flattenCategoryTree(item.children, item.valueLabel);
|
||||
}
|
||||
});
|
||||
};
|
||||
flattenCategoryTree(response.data.data);
|
||||
mappings[col.columnName] = mapping;
|
||||
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const AlertDialog: React.FC<React.ComponentProps<typeof AlertDialogPrimitive.Roo
|
|||
const isTabActiveRef = React.useRef(isTabActive);
|
||||
isTabActiveRef.current = isTabActive;
|
||||
|
||||
const effectiveOpen = open != null ? open && isTabActive : undefined;
|
||||
const effectiveOpen = open != null ? open : undefined;
|
||||
|
||||
const guardedOnOpenChange = React.useCallback(
|
||||
(newOpen: boolean) => {
|
||||
|
|
@ -94,6 +94,11 @@ const AlertDialogContent = React.forwardRef<
|
|||
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
|
||||
const scoped = React.useContext(ScopedAlertCtx);
|
||||
|
||||
// 탭 비활성 시 content를 언마운트하지 않고 CSS로 숨김 (자식 컴포넌트 상태 보존)
|
||||
const tabId = useTabId();
|
||||
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
|
||||
const isTabActive = !tabId || tabId === activeTabId;
|
||||
|
||||
const adjustedStyle = scoped && style
|
||||
? { ...style, maxHeight: undefined, maxWidth: undefined }
|
||||
: style;
|
||||
|
|
@ -117,7 +122,7 @@ const AlertDialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Portal container={container ?? undefined}>
|
||||
<div
|
||||
className="absolute inset-0 z-1050 flex items-center justify-center overflow-hidden p-4"
|
||||
style={hiddenProp ? { display: "none" } : undefined}
|
||||
style={(hiddenProp || !isTabActive) ? { display: "none" } : undefined}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/80" />
|
||||
<DialogPrimitive.Content
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ const Dialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
|
|||
isTabActiveRef.current = isTabActive;
|
||||
|
||||
const effectiveModal = modal !== undefined ? modal : !scoped ? undefined : false;
|
||||
const effectiveOpen = open != null ? open && isTabActive : undefined;
|
||||
const effectiveOpen = open != null ? open : undefined;
|
||||
|
||||
// 비활성 탭에서 발생하는 onOpenChange(false) 차단
|
||||
// (탭 전환 시 content unmount → focus 이동 → Radix가 onOpenChange(false)를 호출하는 것을 방지)
|
||||
|
|
@ -83,6 +83,11 @@ const DialogContent = React.forwardRef<
|
|||
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
|
||||
const scoped = !!container;
|
||||
|
||||
// 탭 비활성 시 content를 언마운트하지 않고 CSS로 숨김 (자식 컴포넌트 상태 보존)
|
||||
const tabId = useTabId();
|
||||
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
|
||||
const isTabActive = !tabId || tabId === activeTabId;
|
||||
|
||||
// state 기반 ref: DialogPrimitive.Content 마운트/언마운트 시 useEffect 재실행 보장
|
||||
const [contentNode, setContentNode] = React.useState<HTMLDivElement | null>(null);
|
||||
const mergedRef = React.useCallback(
|
||||
|
|
@ -130,7 +135,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPortal container={container ?? undefined}>
|
||||
<div
|
||||
className={scoped ? "absolute inset-0 z-999 flex items-center justify-center overflow-hidden p-4" : undefined}
|
||||
style={hiddenProp ? { display: "none" } : undefined}
|
||||
style={(hiddenProp || (scoped && !isTabActive)) ? { display: "none" } : undefined}
|
||||
>
|
||||
{scoped ? (
|
||||
<div className="absolute inset-0 bg-black/60" />
|
||||
|
|
|
|||
|
|
@ -92,7 +92,14 @@ const DropdownSelect = forwardRef<
|
|||
className={cn("w-full", allowClear && hasValue ? "pr-8" : "", className)}
|
||||
style={style}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
<span data-slot="select-value">
|
||||
{(() => {
|
||||
const val = typeof value === "string" ? value : (value?.[0] ?? "");
|
||||
const opt = options.find(o => o.value === val);
|
||||
if (!opt || !val) return placeholder;
|
||||
return opt.displayLabel || opt.label;
|
||||
})()}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options
|
||||
|
|
@ -139,7 +146,7 @@ const DropdownSelect = forwardRef<
|
|||
const selectedLabels = useMemo(() => {
|
||||
return safeOptions
|
||||
.filter((o) => selectedValues.includes(o.value))
|
||||
.map((o) => o.label)
|
||||
.map((o) => o.displayLabel || o.label)
|
||||
.filter(Boolean) as string[];
|
||||
}, [selectedValues, safeOptions]);
|
||||
|
||||
|
|
@ -896,18 +903,23 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
|
|||
// 트리 구조를 평탄화하여 옵션으로 변환
|
||||
// 🔧 value로 valueCode를 사용 (커스텀 테이블 저장/조회 호환)
|
||||
const flattenTree = (
|
||||
items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[],
|
||||
items: { valueId: number; valueCode: string; valueLabel: string; path?: string; children?: any[] }[],
|
||||
depth: number = 0,
|
||||
parentLabel: string = "",
|
||||
): SelectOption[] => {
|
||||
const result: SelectOption[] = [];
|
||||
for (const item of items) {
|
||||
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
|
||||
const displayLabel = parentLabel
|
||||
? `${parentLabel} / ${item.valueLabel}`
|
||||
: item.valueLabel;
|
||||
result.push({
|
||||
value: item.valueCode, // 🔧 valueCode를 value로 사용
|
||||
label: prefix + item.valueLabel,
|
||||
displayLabel,
|
||||
});
|
||||
if (item.children && item.children.length > 0) {
|
||||
result.push(...flattenTree(item.children, depth + 1));
|
||||
result.push(...flattenTree(item.children, depth + 1, item.valueLabel));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -171,6 +171,80 @@ function SectionHeader({
|
|||
);
|
||||
}
|
||||
|
||||
// ─── 화면 선택 Combobox ───
|
||||
const ScreenSelector: React.FC<{
|
||||
value?: number;
|
||||
onChange: (screenId?: number) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [screens, setScreens] = useState<Array<{ screenId: number; screenName: string; screenCode: string }>>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { screenApi } = await import("@/lib/api/screen");
|
||||
const response = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||
setScreens(
|
||||
response.data.map((s: any) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode })),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("화면 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadScreens();
|
||||
}, []);
|
||||
|
||||
const selectedScreen = screens.find((s) => s.screenId === value);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "로딩 중..." : selectedScreen ? selectedScreen.screenName : "화면 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="화면 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-6 text-center text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-auto">
|
||||
{screens.map((screen) => (
|
||||
<CommandItem
|
||||
key={screen.screenId}
|
||||
value={`${screen.screenName.toLowerCase()} ${screen.screenCode.toLowerCase()} ${screen.screenId}`}
|
||||
onSelect={() => {
|
||||
onChange(screen.screenId === value ? undefined : screen.screenId);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")} />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.screenName}</span>
|
||||
<span className="text-muted-foreground text-[10px]">{screen.screenCode}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── 수평 Switch Row (토스 패턴) ───
|
||||
function SwitchRow({
|
||||
label,
|
||||
|
|
@ -2002,6 +2076,23 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<V2SplitPanelLayoutConfigPan
|
|||
checked={tab.showAdd ?? false}
|
||||
onCheckedChange={(checked) => updateTab(tabIndex, { showAdd: checked })}
|
||||
/>
|
||||
{tab.showAdd && (
|
||||
<div className="border-primary/20 ml-4 space-y-2 border-l-2 pl-3 pb-1">
|
||||
<span className="text-muted-foreground text-[11px]">추가 시 열릴 화면</span>
|
||||
<ScreenSelector
|
||||
value={tab.addButton?.modalScreenId}
|
||||
onChange={(screenId) => {
|
||||
updateTab(tabIndex, {
|
||||
addButton: {
|
||||
enabled: true,
|
||||
mode: screenId ? "modal" : "auto",
|
||||
modalScreenId: screenId,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<SwitchRow
|
||||
label="삭제"
|
||||
checked={tab.showDelete ?? false}
|
||||
|
|
|
|||
|
|
@ -457,6 +457,13 @@ apiClient.interceptors.response.use(
|
|||
}
|
||||
}
|
||||
|
||||
// TOKEN_INVALIDATED → 재로그인 필요 (갱신 시도 없이 즉시)
|
||||
if (errorCode === "TOKEN_INVALIDATED") {
|
||||
authLog("REDIRECT_TO_LOGIN", `토큰 무효화 (보안 정책 변경) → 즉시 로그인 리다이렉트 (${url})`);
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
|
||||
authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`);
|
||||
redirectToLogin();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,188 @@
|
|||
import { apiClient } from "./client";
|
||||
|
||||
// --- 타입 정의 ---
|
||||
|
||||
export interface OutboundItem {
|
||||
id: string;
|
||||
company_code: string;
|
||||
outbound_number: string;
|
||||
outbound_type: string;
|
||||
outbound_date: string;
|
||||
reference_number: string | null;
|
||||
customer_code: string | null;
|
||||
customer_name: string | null;
|
||||
item_code: string | null;
|
||||
item_name: string | null;
|
||||
specification: string | null;
|
||||
material: string | null;
|
||||
unit: string | null;
|
||||
outbound_qty: number;
|
||||
unit_price: number;
|
||||
total_amount: number;
|
||||
lot_number: string | null;
|
||||
warehouse_code: string | null;
|
||||
warehouse_name?: string | null;
|
||||
location_code: string | null;
|
||||
outbound_status: string;
|
||||
manager_id: string | null;
|
||||
memo: string | null;
|
||||
source_type: string | null;
|
||||
sales_order_id: string | null;
|
||||
shipment_plan_id: string | null;
|
||||
item_info_id: string | null;
|
||||
destination_code: string | null;
|
||||
delivery_destination: string | null;
|
||||
delivery_address: string | null;
|
||||
created_date: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
export interface ShipmentInstructionSource {
|
||||
detail_id: number;
|
||||
instruction_id: number;
|
||||
instruction_no: string;
|
||||
instruction_date: string;
|
||||
partner_id: string;
|
||||
instruction_status: string;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
spec: string | null;
|
||||
material: string | null;
|
||||
plan_qty: number;
|
||||
ship_qty: number;
|
||||
order_qty: number;
|
||||
remain_qty: number;
|
||||
source_type: string | null;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderSource {
|
||||
id: string;
|
||||
purchase_no: string;
|
||||
order_date: string;
|
||||
supplier_code: string;
|
||||
supplier_name: string;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
spec: string | null;
|
||||
material: string | null;
|
||||
order_qty: number;
|
||||
received_qty: number;
|
||||
unit_price: number;
|
||||
status: string;
|
||||
due_date: string | null;
|
||||
}
|
||||
|
||||
export interface ItemSource {
|
||||
id: string;
|
||||
item_number: string;
|
||||
item_name: string;
|
||||
spec: string | null;
|
||||
material: string | null;
|
||||
unit: string | null;
|
||||
standard_price: number;
|
||||
}
|
||||
|
||||
export interface WarehouseOption {
|
||||
warehouse_code: string;
|
||||
warehouse_name: string;
|
||||
warehouse_type: string;
|
||||
}
|
||||
|
||||
export interface CreateOutboundPayload {
|
||||
outbound_number: string;
|
||||
outbound_date: string;
|
||||
warehouse_code?: string;
|
||||
location_code?: string;
|
||||
manager_id?: string;
|
||||
memo?: string;
|
||||
items: Array<{
|
||||
outbound_type: string;
|
||||
reference_number?: string;
|
||||
customer_code?: string;
|
||||
customer_name?: string;
|
||||
item_code?: string;
|
||||
item_number?: string;
|
||||
item_name?: string;
|
||||
spec?: string;
|
||||
specification?: string;
|
||||
material?: string;
|
||||
unit?: string;
|
||||
outbound_qty: number;
|
||||
unit_price?: number;
|
||||
total_amount?: number;
|
||||
lot_number?: string;
|
||||
warehouse_code?: string;
|
||||
location_code?: string;
|
||||
outbound_status?: string;
|
||||
manager_id?: string;
|
||||
memo?: string;
|
||||
source_type?: string;
|
||||
source_id?: string;
|
||||
sales_order_id?: string;
|
||||
shipment_plan_id?: string;
|
||||
item_info_id?: string;
|
||||
destination_code?: string;
|
||||
delivery_destination?: string;
|
||||
delivery_address?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// --- API 호출 ---
|
||||
|
||||
export async function getOutboundList(params?: {
|
||||
outbound_type?: string;
|
||||
outbound_status?: string;
|
||||
search_keyword?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}) {
|
||||
const res = await apiClient.get("/outbound/list", { params });
|
||||
return res.data as { success: boolean; data: OutboundItem[] };
|
||||
}
|
||||
|
||||
export async function createOutbound(payload: CreateOutboundPayload) {
|
||||
const res = await apiClient.post("/outbound", payload);
|
||||
return res.data as { success: boolean; data: OutboundItem[]; message?: string };
|
||||
}
|
||||
|
||||
export async function updateOutbound(id: string, payload: Partial<OutboundItem>) {
|
||||
const res = await apiClient.put(`/outbound/${id}`, payload);
|
||||
return res.data as { success: boolean; data: OutboundItem };
|
||||
}
|
||||
|
||||
export async function deleteOutbound(id: string) {
|
||||
const res = await apiClient.delete(`/outbound/${id}`);
|
||||
return res.data as { success: boolean; message?: string };
|
||||
}
|
||||
|
||||
export async function generateOutboundNumber() {
|
||||
const res = await apiClient.get("/outbound/generate-number");
|
||||
return res.data as { success: boolean; data: string };
|
||||
}
|
||||
|
||||
export async function getOutboundWarehouses() {
|
||||
const res = await apiClient.get("/outbound/warehouses");
|
||||
return res.data as { success: boolean; data: WarehouseOption[] };
|
||||
}
|
||||
|
||||
// 소스 데이터 조회
|
||||
export async function getShipmentInstructionSources(keyword?: string) {
|
||||
const res = await apiClient.get("/outbound/source/shipment-instructions", {
|
||||
params: keyword ? { keyword } : {},
|
||||
});
|
||||
return res.data as { success: boolean; data: ShipmentInstructionSource[] };
|
||||
}
|
||||
|
||||
export async function getPurchaseOrderSources(keyword?: string) {
|
||||
const res = await apiClient.get("/outbound/source/purchase-orders", {
|
||||
params: keyword ? { keyword } : {},
|
||||
});
|
||||
return res.data as { success: boolean; data: PurchaseOrderSource[] };
|
||||
}
|
||||
|
||||
export async function getItemSources(keyword?: string) {
|
||||
const res = await apiClient.get("/outbound/source/items", {
|
||||
params: keyword ? { keyword } : {},
|
||||
});
|
||||
return res.data as { success: boolean; data: ItemSource[] };
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { apiClient } from "./client";
|
||||
|
||||
// --- 타입 정의 ---
|
||||
|
||||
export interface PkgUnit {
|
||||
id: string;
|
||||
company_code: string;
|
||||
pkg_code: string;
|
||||
pkg_name: string;
|
||||
pkg_type: string;
|
||||
status: string;
|
||||
width_mm: number | null;
|
||||
length_mm: number | null;
|
||||
height_mm: number | null;
|
||||
self_weight_kg: number | null;
|
||||
max_load_kg: number | null;
|
||||
volume_l: number | null;
|
||||
remarks: string | null;
|
||||
created_date: string;
|
||||
writer: string | null;
|
||||
}
|
||||
|
||||
export interface PkgUnitItem {
|
||||
id: string;
|
||||
company_code: string;
|
||||
pkg_code: string;
|
||||
item_number: string;
|
||||
pkg_qty: number;
|
||||
// JOIN된 필드
|
||||
item_name?: string;
|
||||
spec?: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface LoadingUnit {
|
||||
id: string;
|
||||
company_code: string;
|
||||
loading_code: string;
|
||||
loading_name: string;
|
||||
loading_type: string;
|
||||
status: string;
|
||||
width_mm: number | null;
|
||||
length_mm: number | null;
|
||||
height_mm: number | null;
|
||||
self_weight_kg: number | null;
|
||||
max_load_kg: number | null;
|
||||
max_stack: number | null;
|
||||
remarks: string | null;
|
||||
created_date: string;
|
||||
writer: string | null;
|
||||
}
|
||||
|
||||
export interface LoadingUnitPkg {
|
||||
id: string;
|
||||
company_code: string;
|
||||
loading_code: string;
|
||||
pkg_code: string;
|
||||
max_load_qty: number;
|
||||
load_method: string | null;
|
||||
// JOIN된 필드
|
||||
pkg_name?: string;
|
||||
pkg_type?: string;
|
||||
}
|
||||
|
||||
export interface ItemInfoForPkg {
|
||||
id: string;
|
||||
item_number: string;
|
||||
item_name: string;
|
||||
size: string | null;
|
||||
spec?: string | null;
|
||||
material: string | null;
|
||||
unit: string | null;
|
||||
division: string | null;
|
||||
}
|
||||
|
||||
// --- 포장단위 API ---
|
||||
|
||||
export async function getPkgUnits() {
|
||||
const res = await apiClient.get("/packaging/pkg-units");
|
||||
return res.data as { success: boolean; data: PkgUnit[] };
|
||||
}
|
||||
|
||||
export async function createPkgUnit(data: Partial<PkgUnit>) {
|
||||
const res = await apiClient.post("/packaging/pkg-units", data);
|
||||
return res.data as { success: boolean; data: PkgUnit; message?: string };
|
||||
}
|
||||
|
||||
export async function updatePkgUnit(id: string, data: Partial<PkgUnit>) {
|
||||
const res = await apiClient.put(`/packaging/pkg-units/${id}`, data);
|
||||
return res.data as { success: boolean; data: PkgUnit };
|
||||
}
|
||||
|
||||
export async function deletePkgUnit(id: string) {
|
||||
const res = await apiClient.delete(`/packaging/pkg-units/${id}`);
|
||||
return res.data as { success: boolean; message?: string };
|
||||
}
|
||||
|
||||
// --- 포장단위 매칭품목 API ---
|
||||
|
||||
export async function getPkgUnitItems(pkgCode: string) {
|
||||
const res = await apiClient.get(`/packaging/pkg-unit-items/${encodeURIComponent(pkgCode)}`);
|
||||
return res.data as { success: boolean; data: PkgUnitItem[] };
|
||||
}
|
||||
|
||||
export async function createPkgUnitItem(data: { pkg_code: string; item_number: string; pkg_qty: number }) {
|
||||
const res = await apiClient.post("/packaging/pkg-unit-items", data);
|
||||
return res.data as { success: boolean; data: PkgUnitItem; message?: string };
|
||||
}
|
||||
|
||||
export async function deletePkgUnitItem(id: string) {
|
||||
const res = await apiClient.delete(`/packaging/pkg-unit-items/${id}`);
|
||||
return res.data as { success: boolean; message?: string };
|
||||
}
|
||||
|
||||
// --- 적재함 API ---
|
||||
|
||||
export async function getLoadingUnits() {
|
||||
const res = await apiClient.get("/packaging/loading-units");
|
||||
return res.data as { success: boolean; data: LoadingUnit[] };
|
||||
}
|
||||
|
||||
export async function createLoadingUnit(data: Partial<LoadingUnit>) {
|
||||
const res = await apiClient.post("/packaging/loading-units", data);
|
||||
return res.data as { success: boolean; data: LoadingUnit; message?: string };
|
||||
}
|
||||
|
||||
export async function updateLoadingUnit(id: string, data: Partial<LoadingUnit>) {
|
||||
const res = await apiClient.put(`/packaging/loading-units/${id}`, data);
|
||||
return res.data as { success: boolean; data: LoadingUnit };
|
||||
}
|
||||
|
||||
export async function deleteLoadingUnit(id: string) {
|
||||
const res = await apiClient.delete(`/packaging/loading-units/${id}`);
|
||||
return res.data as { success: boolean; message?: string };
|
||||
}
|
||||
|
||||
// --- 적재함 포장구성 API ---
|
||||
|
||||
export async function getLoadingUnitPkgs(loadingCode: string) {
|
||||
const res = await apiClient.get(`/packaging/loading-unit-pkgs/${encodeURIComponent(loadingCode)}`);
|
||||
return res.data as { success: boolean; data: LoadingUnitPkg[] };
|
||||
}
|
||||
|
||||
export async function createLoadingUnitPkg(data: { loading_code: string; pkg_code: string; max_load_qty: number; load_method?: string }) {
|
||||
const res = await apiClient.post("/packaging/loading-unit-pkgs", data);
|
||||
return res.data as { success: boolean; data: LoadingUnitPkg; message?: string };
|
||||
}
|
||||
|
||||
export async function deleteLoadingUnitPkg(id: string) {
|
||||
const res = await apiClient.delete(`/packaging/loading-unit-pkgs/${id}`);
|
||||
return res.data as { success: boolean; message?: string };
|
||||
}
|
||||
|
||||
// --- 품목정보 연동 API ---
|
||||
|
||||
export async function getItemsByDivision(divisionLabel: string, keyword?: string) {
|
||||
const res = await apiClient.get(`/packaging/items/${encodeURIComponent(divisionLabel)}`, {
|
||||
params: keyword ? { keyword } : {},
|
||||
});
|
||||
return res.data as { success: boolean; data: ItemInfoForPkg[] };
|
||||
}
|
||||
|
||||
export async function getGeneralItems(keyword?: string) {
|
||||
const res = await apiClient.get("/packaging/items/general", {
|
||||
params: keyword ? { keyword } : {},
|
||||
});
|
||||
return res.data as { success: boolean; data: ItemInfoForPkg[] };
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
* 생산계획 API 클라이언트
|
||||
*/
|
||||
|
||||
import apiClient from "./client";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
// ─── 타입 정의 ───
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ export interface OrderSummaryItem {
|
|||
existing_plan_qty: number;
|
||||
in_progress_qty: number;
|
||||
required_plan_qty: number;
|
||||
lead_time: number;
|
||||
orders: OrderDetail[];
|
||||
}
|
||||
|
||||
|
|
@ -94,10 +95,51 @@ export interface GenerateScheduleResponse {
|
|||
deleted_count: number;
|
||||
};
|
||||
schedules: ProductionPlan[];
|
||||
deletedSchedules?: ProductionPlan[];
|
||||
keptSchedules?: ProductionPlan[];
|
||||
}
|
||||
|
||||
// ─── API 함수 ───
|
||||
|
||||
/** 생산계획 목록 조회 */
|
||||
export async function getPlans(params?: {
|
||||
productType?: string;
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
itemCode?: string;
|
||||
}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.productType) queryParams.set("productType", params.productType);
|
||||
if (params?.status) queryParams.set("status", params.status);
|
||||
if (params?.startDate) queryParams.set("startDate", params.startDate);
|
||||
if (params?.endDate) queryParams.set("endDate", params.endDate);
|
||||
if (params?.itemCode) queryParams.set("itemCode", params.itemCode);
|
||||
|
||||
const qs = queryParams.toString();
|
||||
const url = `/production/plans${qs ? `?${qs}` : ""}`;
|
||||
const response = await apiClient.get(url);
|
||||
return response.data as { success: boolean; data: ProductionPlan[] };
|
||||
}
|
||||
|
||||
/** 자동 스케줄 미리보기 (DB 변경 없이 예상 결과) */
|
||||
export async function previewSchedule(request: GenerateScheduleRequest) {
|
||||
const response = await apiClient.post("/production/generate-schedule/preview", request);
|
||||
return response.data as { success: boolean; data: GenerateScheduleResponse };
|
||||
}
|
||||
|
||||
/** 반제품 계획 미리보기 */
|
||||
export async function previewSemiSchedule(
|
||||
planIds: number[],
|
||||
options?: { considerStock?: boolean; excludeUsed?: boolean }
|
||||
) {
|
||||
const response = await apiClient.post("/production/generate-semi-schedule/preview", {
|
||||
plan_ids: planIds,
|
||||
options: options || {},
|
||||
});
|
||||
return response.data as { success: boolean; data: { count: number; schedules: ProductionPlan[] } };
|
||||
}
|
||||
|
||||
/** 수주 데이터 조회 (품목별 그룹핑) */
|
||||
export async function getOrderSummary(params?: {
|
||||
excludePlanned?: boolean;
|
||||
|
|
@ -110,44 +152,44 @@ export async function getOrderSummary(params?: {
|
|||
if (params?.itemName) queryParams.set("itemName", params.itemName);
|
||||
|
||||
const qs = queryParams.toString();
|
||||
const url = `/api/production/order-summary${qs ? `?${qs}` : ""}`;
|
||||
const url = `/production/order-summary${qs ? `?${qs}` : ""}`;
|
||||
const response = await apiClient.get(url);
|
||||
return response.data as { success: boolean; data: OrderSummaryItem[] };
|
||||
}
|
||||
|
||||
/** 안전재고 부족분 조회 */
|
||||
export async function getStockShortage() {
|
||||
const response = await apiClient.get("/api/production/stock-shortage");
|
||||
const response = await apiClient.get("/production/stock-shortage");
|
||||
return response.data as { success: boolean; data: StockShortageItem[] };
|
||||
}
|
||||
|
||||
/** 생산계획 상세 조회 */
|
||||
export async function getPlanById(planId: number) {
|
||||
const response = await apiClient.get(`/api/production/plan/${planId}`);
|
||||
const response = await apiClient.get(`/production/plan/${planId}`);
|
||||
return response.data as { success: boolean; data: ProductionPlan };
|
||||
}
|
||||
|
||||
/** 생산계획 수정 */
|
||||
export async function updatePlan(planId: number, data: Partial<ProductionPlan>) {
|
||||
const response = await apiClient.put(`/api/production/plan/${planId}`, data);
|
||||
const response = await apiClient.put(`/production/plan/${planId}`, data);
|
||||
return response.data as { success: boolean; data: ProductionPlan };
|
||||
}
|
||||
|
||||
/** 생산계획 삭제 */
|
||||
export async function deletePlan(planId: number) {
|
||||
const response = await apiClient.delete(`/api/production/plan/${planId}`);
|
||||
const response = await apiClient.delete(`/production/plan/${planId}`);
|
||||
return response.data as { success: boolean; message: string };
|
||||
}
|
||||
|
||||
/** 자동 스케줄 생성 */
|
||||
export async function generateSchedule(request: GenerateScheduleRequest) {
|
||||
const response = await apiClient.post("/api/production/generate-schedule", request);
|
||||
const response = await apiClient.post("/production/generate-schedule", request);
|
||||
return response.data as { success: boolean; data: GenerateScheduleResponse };
|
||||
}
|
||||
|
||||
/** 스케줄 병합 */
|
||||
export async function mergeSchedules(scheduleIds: number[], productType?: string) {
|
||||
const response = await apiClient.post("/api/production/merge-schedules", {
|
||||
const response = await apiClient.post("/production/merge-schedules", {
|
||||
schedule_ids: scheduleIds,
|
||||
product_type: productType || "완제품",
|
||||
});
|
||||
|
|
@ -159,7 +201,7 @@ export async function generateSemiSchedule(
|
|||
planIds: number[],
|
||||
options?: { considerStock?: boolean; excludeUsed?: boolean }
|
||||
) {
|
||||
const response = await apiClient.post("/api/production/generate-semi-schedule", {
|
||||
const response = await apiClient.post("/production/generate-semi-schedule", {
|
||||
plan_ids: planIds,
|
||||
options: options || {},
|
||||
});
|
||||
|
|
@ -168,7 +210,7 @@ export async function generateSemiSchedule(
|
|||
|
||||
/** 스케줄 분할 */
|
||||
export async function splitSchedule(planId: number, splitQty: number) {
|
||||
const response = await apiClient.post(`/api/production/plan/${planId}/split`, {
|
||||
const response = await apiClient.post(`/production/plan/${planId}/split`, {
|
||||
split_qty: splitQty,
|
||||
});
|
||||
return response.data as {
|
||||
|
|
|
|||
|
|
@ -372,22 +372,27 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
|
||||
if (response.data.success && response.data.data) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
response.data.data.forEach((item: any) => {
|
||||
// API 응답 형식: valueCode, valueLabel (camelCase)
|
||||
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
|
||||
const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
|
||||
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
|
||||
const rawColor = item.color ?? item.badge_color;
|
||||
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
|
||||
mapping[code] = { label, color };
|
||||
});
|
||||
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
|
||||
items.forEach((item: any) => {
|
||||
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
|
||||
const rawLabel = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
|
||||
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
|
||||
const rawColor = item.color ?? item.badge_color;
|
||||
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
|
||||
mapping[code] = { label: displayLabel, color };
|
||||
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||
flattenCategoryTree(item.children, rawLabel);
|
||||
}
|
||||
});
|
||||
};
|
||||
flattenCategoryTree(response.data.data);
|
||||
mappings[columnName] = mapping;
|
||||
}
|
||||
} catch (error) {
|
||||
// 카테고리 매핑 로드 실패 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setCategoryMappings(mappings);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -223,7 +223,9 @@ export const CategorySelectComponent: React.FC<
|
|||
key={categoryValue.valueId}
|
||||
value={categoryValue.valueCode}
|
||||
>
|
||||
{categoryValue.valueLabel}
|
||||
{categoryValue.path && categoryValue.path.includes('/')
|
||||
? categoryValue.path.replace(/\//g, ' / ')
|
||||
: categoryValue.valueLabel}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
|
|||
|
|
@ -258,7 +258,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
const activeValues = response.data.filter((v: any) => v.isActive !== false);
|
||||
const options = activeValues.map((v: any) => ({
|
||||
value: v.valueCode,
|
||||
label: v.valueLabel || v.valueCode,
|
||||
label: (v.path && v.path.includes('/'))
|
||||
? v.path.replace(/\//g, ' / ')
|
||||
: (v.valueLabel || v.valueCode),
|
||||
}));
|
||||
setCategoryOptions(options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1613,12 +1613,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
if (response.data.success && response.data.data) {
|
||||
const valueMap: Record<string, { label: string; color?: string }> = {};
|
||||
response.data.data.forEach((item: any) => {
|
||||
valueMap[item.value_code || item.valueCode] = {
|
||||
label: item.value_label || item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
});
|
||||
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
|
||||
items.forEach((item: any) => {
|
||||
const rawLabel = item.value_label || item.valueLabel;
|
||||
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
|
||||
valueMap[item.value_code || item.valueCode] = {
|
||||
label: displayLabel,
|
||||
color: item.color,
|
||||
};
|
||||
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||
flattenCategoryTree(item.children, rawLabel);
|
||||
}
|
||||
});
|
||||
};
|
||||
flattenCategoryTree(response.data.data);
|
||||
mappings[columnName] = valueMap;
|
||||
console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
|
||||
}
|
||||
|
|
@ -1675,12 +1683,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
|
||||
if (response.data.success && response.data.data) {
|
||||
const valueMap: Record<string, { label: string; color?: string }> = {};
|
||||
response.data.data.forEach((item: any) => {
|
||||
valueMap[item.value_code || item.valueCode] = {
|
||||
label: item.value_label || item.valueLabel,
|
||||
color: item.color,
|
||||
};
|
||||
});
|
||||
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
|
||||
items.forEach((item: any) => {
|
||||
const rawLabel = item.value_label || item.valueLabel;
|
||||
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
|
||||
valueMap[item.value_code || item.valueCode] = {
|
||||
label: displayLabel,
|
||||
color: item.color,
|
||||
};
|
||||
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||
flattenCategoryTree(item.children, rawLabel);
|
||||
}
|
||||
});
|
||||
};
|
||||
flattenCategoryTree(response.data.data);
|
||||
|
||||
// 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장
|
||||
const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`;
|
||||
|
|
@ -1729,7 +1745,48 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
(panel: "left" | "right") => {
|
||||
console.log("🆕 [추가모달] handleAddClick 호출:", { panel, activeTabIndex });
|
||||
|
||||
// screenId 기반 모달 확인
|
||||
// 추가 탭의 addButton.modalScreenId 확인
|
||||
if (panel === "right" && activeTabIndex > 0) {
|
||||
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
|
||||
if (tabConfig?.addButton?.mode === "modal" && tabConfig.addButton.modalScreenId) {
|
||||
if (!selectedLeftItem) {
|
||||
toast({
|
||||
title: "항목을 선택해주세요",
|
||||
description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tableName = tabConfig.tableName || "";
|
||||
const urlParams: Record<string, any> = { mode: "add", tableName };
|
||||
const parentData: Record<string, any> = {};
|
||||
|
||||
if (selectedLeftItem) {
|
||||
const relation = tabConfig.relation;
|
||||
if (relation?.keys && Array.isArray(relation.keys)) {
|
||||
for (const key of relation.keys) {
|
||||
if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) {
|
||||
parentData[key.rightColumn] = selectedLeftItem[key.leftColumn];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
screenId: tabConfig.addButton.modalScreenId,
|
||||
urlParams,
|
||||
splitPanelParentData: parentData,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// screenId 기반 모달 확인 (기본 패널)
|
||||
const panelConfig = panel === "left" ? componentConfig.leftPanel : componentConfig.rightPanel;
|
||||
const addModalConfig = panelConfig?.addModal;
|
||||
|
||||
|
|
|
|||
|
|
@ -675,8 +675,50 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */}
|
||||
{/* ===== 7. 추가 버튼 설정 (showAdd일 때) ===== */}
|
||||
{tab.showAdd && (
|
||||
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
|
||||
<Label className="text-xs font-semibold text-purple-700">추가 버튼 설정</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">추가 모드</Label>
|
||||
<Select
|
||||
value={tab.addButton?.mode || "auto"}
|
||||
onValueChange={(value: "auto" | "modal") => {
|
||||
updateTab({
|
||||
addButton: { ...tab.addButton, enabled: true, mode: value },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">자동 (인라인)</SelectItem>
|
||||
<SelectItem value="modal">모달 화면</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{tab.addButton?.mode === "modal" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">추가 모달 화면</Label>
|
||||
<ScreenSelector
|
||||
value={tab.addButton?.modalScreenId}
|
||||
onChange={(screenId) => {
|
||||
updateTab({
|
||||
addButton: { ...tab.addButton, enabled: true, mode: "modal", modalScreenId: screenId },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 7-1. 추가 모달 컬럼 설정 (showAdd && mode=auto일 때) ===== */}
|
||||
{tab.showAdd && (!tab.addButton?.mode || tab.addButton?.mode === "auto") && (
|
||||
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold text-purple-700">추가 모달 컬럼 설정</Label>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,12 @@ export interface AdditionalTabConfig {
|
|||
}>;
|
||||
};
|
||||
|
||||
addButton?: {
|
||||
enabled: boolean;
|
||||
mode: "auto" | "modal";
|
||||
modalScreenId?: number;
|
||||
};
|
||||
|
||||
addConfig?: {
|
||||
targetTable?: string;
|
||||
autoFillColumns?: Record<string, any>;
|
||||
|
|
|
|||
|
|
@ -1337,7 +1337,8 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
|
|||
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
|
||||
for (const item of result.data) {
|
||||
if (item.valueCode && item.valueLabel) {
|
||||
labelMap[item.valueCode] = item.valueLabel;
|
||||
// 계층 경로 표시: path가 있고 '/'를 포함하면 전체 경로를 ' > ' 구분자로 표시
|
||||
labelMap[item.valueCode] = item.path && item.path.includes('/') ? item.path.replace(/\//g, ' > ') : item.valueLabel;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1287,22 +1287,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||
|
||||
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
|
||||
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
|
||||
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>, parentLabel: string = "") => {
|
||||
items.forEach((item: any) => {
|
||||
const displayLabel = parentLabel
|
||||
? `${parentLabel} / ${item.valueLabel}`
|
||||
: item.valueLabel;
|
||||
if (item.valueCode) {
|
||||
mapping[String(item.valueCode)] = {
|
||||
label: item.valueLabel,
|
||||
label: displayLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
if (item.valueId !== undefined && item.valueId !== null) {
|
||||
mapping[String(item.valueId)] = {
|
||||
label: item.valueLabel,
|
||||
label: displayLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||
flattenTree(item.children, mapping);
|
||||
flattenTree(item.children, mapping, item.valueLabel);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@ export function FieldDetailSettingsModal({
|
|||
const [cascadingRelationOpen, setCascadingRelationOpen] = useState(false);
|
||||
const [parentFieldOpen, setParentFieldOpen] = useState(false);
|
||||
|
||||
// 기본 선택값용 옵션 목록 상태
|
||||
const [defaultValueCategoryValues, setDefaultValueCategoryValues] = useState<{value: string; label: string}[]>([]);
|
||||
const [defaultValueTableOptions, setDefaultValueTableOptions] = useState<{value: string; label: string}[]>([]);
|
||||
const [loadingDefaultValueOptions, setLoadingDefaultValueOptions] = useState(false);
|
||||
|
||||
// Combobox 열림 상태
|
||||
const [sourceTableOpen, setSourceTableOpen] = useState(false);
|
||||
const [targetColumnOpenMap, setTargetColumnOpenMap] = useState<Record<number, boolean>>({});
|
||||
|
|
@ -209,6 +214,69 @@ export function FieldDetailSettingsModal({
|
|||
loadCascadingRelations();
|
||||
}, [open]);
|
||||
|
||||
// 기본 선택값용: code 타입 카테고리 값 로드
|
||||
useEffect(() => {
|
||||
const loadCategoryValues = async () => {
|
||||
const categoryKey = localField.selectOptions?.categoryKey;
|
||||
if (!open || localField.selectOptions?.type !== "code" || !categoryKey) {
|
||||
setDefaultValueCategoryValues([]);
|
||||
return;
|
||||
}
|
||||
setLoadingDefaultValueOptions(true);
|
||||
try {
|
||||
const [tableName, columnName] = categoryKey.split(".");
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
if (response.data?.success && response.data?.data) {
|
||||
setDefaultValueCategoryValues(
|
||||
response.data.data.map((item: any) => ({
|
||||
value: item.valueCode || item.value_code,
|
||||
label: item.valueLabel || item.value_label,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
setDefaultValueCategoryValues([]);
|
||||
}
|
||||
} catch {
|
||||
setDefaultValueCategoryValues([]);
|
||||
} finally {
|
||||
setLoadingDefaultValueOptions(false);
|
||||
}
|
||||
};
|
||||
loadCategoryValues();
|
||||
}, [open, localField.selectOptions?.type, localField.selectOptions?.categoryKey]);
|
||||
|
||||
// 기본 선택값용: table 타입 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadTableOptions = async () => {
|
||||
const opts = localField.selectOptions;
|
||||
if (!open || opts?.type !== "table" || !opts?.tableName || !opts?.valueColumn || !opts?.labelColumn) {
|
||||
setDefaultValueTableOptions([]);
|
||||
return;
|
||||
}
|
||||
setLoadingDefaultValueOptions(true);
|
||||
try {
|
||||
const response = await apiClient.post(`/table-management/tables/${opts.tableName}/data`, {
|
||||
page: 1,
|
||||
size: 200,
|
||||
autoFilter: { enabled: true, filterColumn: "company_code" },
|
||||
});
|
||||
const dataArray = response.data?.data?.data || response.data?.data || [];
|
||||
setDefaultValueTableOptions(
|
||||
dataArray.map((row: any) => ({
|
||||
value: String(row[opts.valueColumn!] || ""),
|
||||
label: String(row[opts.labelColumn!] || ""),
|
||||
}))
|
||||
);
|
||||
} catch {
|
||||
setDefaultValueTableOptions([]);
|
||||
} finally {
|
||||
setLoadingDefaultValueOptions(false);
|
||||
}
|
||||
};
|
||||
loadTableOptions();
|
||||
}, [open, localField.selectOptions?.type, localField.selectOptions?.tableName,
|
||||
localField.selectOptions?.valueColumn, localField.selectOptions?.labelColumn]);
|
||||
|
||||
// 관계 코드 선택 시 상세 설정 자동 채움
|
||||
const handleRelationCodeSelect = async (relationCode: string) => {
|
||||
if (!relationCode) return;
|
||||
|
|
@ -1181,6 +1249,80 @@ export function FieldDetailSettingsModal({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기본 선택값 설정 (cascading 제외) */}
|
||||
{(() => {
|
||||
const effectiveType = localField.selectOptions?.type || "static";
|
||||
if (effectiveType === "cascading") return null;
|
||||
return (
|
||||
<div className="border-t pt-3 mt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-medium">기본 선택값</span>
|
||||
{/* static 타입 */}
|
||||
{effectiveType === "static" && (localField.selectOptions?.staticOptions?.length || 0) > 0 && (
|
||||
<Select
|
||||
value={localField.defaultValue || "_none_"}
|
||||
onValueChange={(value) => updateField({ defaultValue: value === "_none_" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[180px] text-xs">
|
||||
<SelectValue placeholder="선택 안함" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none_">선택 안함</SelectItem>
|
||||
{(localField.selectOptions?.staticOptions || []).map((opt, idx) => (
|
||||
<SelectItem key={`default-${idx}`} value={opt.value}>
|
||||
{opt.label || opt.value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* code 타입 */}
|
||||
{effectiveType === "code" && defaultValueCategoryValues.length > 0 && (
|
||||
<Select
|
||||
value={localField.defaultValue || "_none_"}
|
||||
onValueChange={(value) => updateField({ defaultValue: value === "_none_" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[180px] text-xs">
|
||||
<SelectValue placeholder="선택 안함" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none_">선택 안함</SelectItem>
|
||||
{defaultValueCategoryValues.map((cv) => (
|
||||
<SelectItem key={cv.value} value={cv.value}>
|
||||
{cv.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* table 타입 */}
|
||||
{effectiveType === "table" && defaultValueTableOptions.length > 0 && (
|
||||
<Select
|
||||
value={localField.defaultValue || "_none_"}
|
||||
onValueChange={(value) => updateField({ defaultValue: value === "_none_" ? "" : value })}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[180px] text-xs">
|
||||
<SelectValue placeholder="선택 안함" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_none_">선택 안함</SelectItem>
|
||||
{defaultValueTableOptions.map((opt, idx) => (
|
||||
<SelectItem key={`default-table-${idx}`} value={opt.value}>
|
||||
{opt.label} ({opt.value})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{loadingDefaultValueOptions && (
|
||||
<span className="text-[9px] text-muted-foreground">로딩 중...</span>
|
||||
)}
|
||||
</div>
|
||||
<HelpText>폼이 열릴 때 자동으로 선택될 기본값을 설정합니다</HelpText>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -372,22 +372,27 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
|
|||
|
||||
if (response.data.success && response.data.data) {
|
||||
const mapping: Record<string, { label: string; color?: string }> = {};
|
||||
response.data.data.forEach((item: any) => {
|
||||
// API 응답 형식: valueCode, valueLabel (camelCase)
|
||||
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
|
||||
const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
|
||||
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
|
||||
const rawColor = item.color ?? item.badge_color;
|
||||
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
|
||||
mapping[code] = { label, color };
|
||||
});
|
||||
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
|
||||
items.forEach((item: any) => {
|
||||
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
|
||||
const rawLabel = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
|
||||
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
|
||||
const rawColor = item.color ?? item.badge_color;
|
||||
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
|
||||
mapping[code] = { label: displayLabel, color };
|
||||
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||
flattenCategoryTree(item.children, rawLabel);
|
||||
}
|
||||
});
|
||||
};
|
||||
flattenCategoryTree(response.data.data);
|
||||
mappings[columnName] = mapping;
|
||||
}
|
||||
} catch (error) {
|
||||
// 카테고리 매핑 로드 실패 시 무시
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setCategoryMappings(mappings);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -458,7 +458,7 @@ export function ItemRoutingComponent({
|
|||
|
||||
{/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */}
|
||||
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||
<DialogContent className="max-w-[95vw]" style={{ maxWidth: `min(95vw, ${config.addModalMaxWidth || "600px"})` }}>
|
||||
<DialogContent className="!max-w-none" style={{ width: `min(100%, 95vw, ${config.addModalMaxWidth || "600px"})` }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">품목 추가</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
|
|
|
|||
|
|
@ -667,8 +667,62 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
|
||||
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 엔티티 관계 자동 감지 캐시 (좌측↔우측 테이블 간 FK 매핑)
|
||||
const [autoDetectedTabRelations, setAutoDetectedTabRelations] = useState<
|
||||
Record<string, Array<{ leftColumn: string; rightColumn: string }>>
|
||||
>({});
|
||||
const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false);
|
||||
|
||||
// 좌측↔우측 테이블 간 엔티티 관계 자동 감지 (table_type_columns 기반)
|
||||
useEffect(() => {
|
||||
const leftTable = componentConfig.leftPanel?.tableName;
|
||||
if (!leftTable) return;
|
||||
|
||||
const detectAll = async () => {
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const cache: Record<string, Array<{ leftColumn: string; rightColumn: string }>> = {};
|
||||
|
||||
// 기본 우측 패널
|
||||
const rightTable = componentConfig.rightPanel?.tableName;
|
||||
if (rightTable && rightTable !== leftTable) {
|
||||
try {
|
||||
const res = await tableManagementApi.getTableEntityRelations(leftTable, rightTable);
|
||||
if (res.success && res.data?.relations?.length) {
|
||||
cache[rightTable] = res.data.relations.map((r: any) => ({
|
||||
leftColumn: r.leftColumn,
|
||||
rightColumn: r.rightColumn,
|
||||
}));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 추가 탭들
|
||||
const tabs = componentConfig.rightPanel?.additionalTabs || [];
|
||||
for (const tab of tabs) {
|
||||
const tabTable = tab.tableName;
|
||||
if (!tabTable || cache[tabTable] !== undefined) continue;
|
||||
try {
|
||||
const res = await tableManagementApi.getTableEntityRelations(leftTable, tabTable);
|
||||
if (res.success && res.data?.relations?.length) {
|
||||
cache[tabTable] = res.data.relations.map((r: any) => ({
|
||||
leftColumn: r.leftColumn,
|
||||
rightColumn: r.rightColumn,
|
||||
}));
|
||||
} else {
|
||||
cache[tabTable] = [];
|
||||
}
|
||||
} catch {
|
||||
cache[tabTable] = [];
|
||||
}
|
||||
}
|
||||
|
||||
setAutoDetectedTabRelations(cache);
|
||||
};
|
||||
|
||||
detectAll();
|
||||
}, [componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs]);
|
||||
|
||||
// 수정 모달 상태
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editModalPanel, setEditModalPanel] = useState<"left" | "right" | null>(null);
|
||||
|
|
@ -2518,6 +2572,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
}
|
||||
|
||||
// table_type_columns 기반 엔티티 관계 자동 감지 (패널 설정 없어도 동작)
|
||||
if (currentTableName && autoDetectedTabRelations[currentTableName]) {
|
||||
for (const rel of autoDetectedTabRelations[currentTableName]) {
|
||||
if (parentData[rel.rightColumn] == null && selectedLeftItem[rel.leftColumn] != null) {
|
||||
parentData[rel.rightColumn] = selectedLeftItem[rel.leftColumn];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
|
|
@ -2539,23 +2602,73 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
setAddModalPanel(panel);
|
||||
|
||||
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
|
||||
if (
|
||||
panel === "right" &&
|
||||
selectedLeftItem &&
|
||||
componentConfig.leftPanel?.leftColumn &&
|
||||
componentConfig.rightPanel?.rightColumn
|
||||
) {
|
||||
const leftColumnValue = selectedLeftItem[componentConfig.leftPanel.leftColumn];
|
||||
setAddModalFormData({
|
||||
[componentConfig.rightPanel.rightColumn]: leftColumnValue,
|
||||
});
|
||||
if (panel === "right" && selectedLeftItem) {
|
||||
const prefill: Record<string, any> = {};
|
||||
|
||||
// 현재 활성 탭의 설정 가져오기 (기본 탭 or 추가 탭)
|
||||
const currentAddConfig =
|
||||
activeTabIndex === 0
|
||||
? componentConfig.rightPanel?.addConfig
|
||||
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addConfig;
|
||||
const currentRelation =
|
||||
activeTabIndex === 0
|
||||
? componentConfig.rightPanel?.relation
|
||||
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation;
|
||||
|
||||
// 1) relation.keys 기반 FK 자동 채움
|
||||
if (currentRelation?.keys && Array.isArray(currentRelation.keys)) {
|
||||
for (const key of currentRelation.keys) {
|
||||
if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) {
|
||||
prefill[key.rightColumn] = selectedLeftItem[key.leftColumn];
|
||||
}
|
||||
}
|
||||
} else if (currentRelation) {
|
||||
const leftCol = currentRelation.leftColumn || componentConfig.leftPanel?.leftColumn;
|
||||
const rightCol = currentRelation.foreignKey || currentRelation.rightColumn || componentConfig.rightPanel?.rightColumn;
|
||||
if (leftCol && rightCol && selectedLeftItem[leftCol] != null) {
|
||||
prefill[rightCol] = selectedLeftItem[leftCol];
|
||||
}
|
||||
} else if (componentConfig.leftPanel?.leftColumn && componentConfig.rightPanel?.rightColumn) {
|
||||
// 하위호환: leftPanel.leftColumn → rightPanel.rightColumn
|
||||
prefill[componentConfig.rightPanel.rightColumn] = selectedLeftItem[componentConfig.leftPanel.leftColumn];
|
||||
}
|
||||
|
||||
// 2) addConfig.leftPanelColumn → targetColumn (단일 키, 하위호환)
|
||||
if (currentAddConfig?.leftPanelColumn && currentAddConfig?.targetColumn) {
|
||||
const val = selectedLeftItem[currentAddConfig.leftPanelColumn];
|
||||
if (val != null) prefill[currentAddConfig.targetColumn] = val;
|
||||
}
|
||||
|
||||
// 3) addConfig.autoFillFromLeft — 복수 컬럼 자동 채움
|
||||
if (currentAddConfig?.autoFillFromLeft) {
|
||||
for (const mapping of currentAddConfig.autoFillFromLeft) {
|
||||
const val = selectedLeftItem[mapping.source];
|
||||
if (val != null) prefill[mapping.target] = val;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) table_type_columns 기반 엔티티 관계 자동 감지 (패널 설정 없어도 동작)
|
||||
const currentTableName =
|
||||
activeTabIndex === 0
|
||||
? componentConfig.rightPanel?.tableName
|
||||
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName;
|
||||
if (currentTableName && autoDetectedTabRelations[currentTableName]) {
|
||||
for (const rel of autoDetectedTabRelations[currentTableName]) {
|
||||
// 이미 다른 방식으로 채워진 값이 없을 때만 자동 채움
|
||||
if (prefill[rel.rightColumn] == null && selectedLeftItem[rel.leftColumn] != null) {
|
||||
prefill[rel.rightColumn] = selectedLeftItem[rel.leftColumn];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setAddModalFormData(prefill);
|
||||
} else {
|
||||
setAddModalFormData({});
|
||||
}
|
||||
|
||||
setShowAddModal(true);
|
||||
},
|
||||
[selectedLeftItem, componentConfig, activeTabIndex],
|
||||
[selectedLeftItem, componentConfig, activeTabIndex, autoDetectedTabRelations],
|
||||
);
|
||||
|
||||
// 수정 버튼 핸들러
|
||||
|
|
@ -3234,21 +3347,38 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
tableName = componentConfig.leftPanel?.tableName;
|
||||
modalColumns = componentConfig.leftPanel?.addModalColumns;
|
||||
} else if (addModalPanel === "right") {
|
||||
// 우측 패널: 중계 테이블 설정이 있는지 확인
|
||||
const addConfig = componentConfig.rightPanel?.addConfig;
|
||||
// 현재 활성 탭의 설정 가져오기 (기본 탭 or 추가 탭)
|
||||
const isAdditionalTab = activeTabIndex > 0;
|
||||
const tabConfig = isAdditionalTab
|
||||
? (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)
|
||||
: null;
|
||||
|
||||
const addConfig = isAdditionalTab
|
||||
? tabConfig?.addConfig
|
||||
: componentConfig.rightPanel?.addConfig;
|
||||
|
||||
if (addConfig?.targetTable) {
|
||||
// 중계 테이블 모드
|
||||
tableName = addConfig.targetTable;
|
||||
modalColumns = componentConfig.rightPanel?.addModalColumns;
|
||||
modalColumns = isAdditionalTab
|
||||
? tabConfig?.addModalColumns
|
||||
: componentConfig.rightPanel?.addModalColumns;
|
||||
|
||||
// 좌측 패널에서 선택된 값 자동 채우기
|
||||
// 좌측 패널에서 선택된 값 자동 채우기 (단일 키, 하위호환)
|
||||
if (addConfig.leftPanelColumn && addConfig.targetColumn && selectedLeftItem) {
|
||||
const leftValue = selectedLeftItem[addConfig.leftPanelColumn];
|
||||
finalData[addConfig.targetColumn] = leftValue;
|
||||
}
|
||||
|
||||
// 자동 채움 컬럼 추가
|
||||
// autoFillFromLeft — 복수 컬럼 자동 채움
|
||||
if (addConfig.autoFillFromLeft && selectedLeftItem) {
|
||||
for (const mapping of addConfig.autoFillFromLeft) {
|
||||
const val = selectedLeftItem[mapping.source];
|
||||
if (val != null) finalData[mapping.target] = val;
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 채움 컬럼 추가 (정적 값)
|
||||
if (addConfig.autoFillColumns) {
|
||||
Object.entries(addConfig.autoFillColumns).forEach(([key, value]) => {
|
||||
finalData[key] = value;
|
||||
|
|
@ -3256,8 +3386,29 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
}
|
||||
} else {
|
||||
// 일반 테이블 모드
|
||||
tableName = componentConfig.rightPanel?.tableName;
|
||||
modalColumns = componentConfig.rightPanel?.addModalColumns;
|
||||
tableName = isAdditionalTab
|
||||
? tabConfig?.tableName
|
||||
: componentConfig.rightPanel?.tableName;
|
||||
modalColumns = isAdditionalTab
|
||||
? tabConfig?.addModalColumns
|
||||
: componentConfig.rightPanel?.addModalColumns;
|
||||
|
||||
// 일반 모드에서도 autoFillFromLeft 적용
|
||||
if (addConfig?.autoFillFromLeft && selectedLeftItem) {
|
||||
for (const mapping of addConfig.autoFillFromLeft) {
|
||||
const val = selectedLeftItem[mapping.source];
|
||||
if (val != null) finalData[mapping.target] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// table_type_columns 기반 엔티티 관계 자동 감지 (패널 설정 없어도 동작)
|
||||
if (tableName && autoDetectedTabRelations[tableName] && selectedLeftItem) {
|
||||
for (const rel of autoDetectedTabRelations[tableName]) {
|
||||
if (finalData[rel.rightColumn] == null && selectedLeftItem[rel.leftColumn] != null) {
|
||||
finalData[rel.rightColumn] = selectedLeftItem[rel.leftColumn];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (addModalPanel === "left-item") {
|
||||
// 하위 항목 추가 (좌측 테이블에 추가)
|
||||
|
|
@ -3305,8 +3456,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
// 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가)
|
||||
loadLeftData();
|
||||
} else if (addModalPanel === "right") {
|
||||
// 우측 패널 데이터 새로고침
|
||||
loadRightData(selectedLeftItem);
|
||||
// 우측 패널 데이터 새로고침 (추가 탭이면 loadTabData)
|
||||
if (activeTabIndex > 0) {
|
||||
loadTabData(activeTabIndex, selectedLeftItem);
|
||||
} else {
|
||||
loadRightData(selectedLeftItem);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
|
|
@ -3896,13 +4051,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
const canDragLeftGroupedColumns = !isDesignMode && columnsToShow.length > 1;
|
||||
if (groupedLeftData.length > 0) {
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<>
|
||||
{groupedLeftData.map((group, groupIdx) => (
|
||||
<div key={groupIdx} className="mb-4">
|
||||
<div className="bg-muted px-3 py-2 text-sm font-semibold">
|
||||
{group.groupKey} ({group.count}개)
|
||||
</div>
|
||||
<table className="divide-border min-w-full divide-y">
|
||||
<table className="divide-border min-w-full divide-y" style={{ minWidth: `${Math.max(columnsToShow.length * 120, 400)}px` }}>
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
{columnsToShow.map((col, idx) => {
|
||||
|
|
@ -4016,7 +4171,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -4027,8 +4182,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
componentConfig.leftPanel?.showDelete !== false);
|
||||
const canDragLeftColumns = !isDesignMode && columnsToShow.length > 1;
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className="divide-border min-w-full divide-y">
|
||||
<table className="divide-border min-w-full divide-y" style={{ minWidth: `${Math.max(columnsToShow.length * 120, 400)}px` }}>
|
||||
<thead className="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
{columnsToShow.map((col, idx) => {
|
||||
|
|
@ -4135,7 +4289,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
|
@ -5189,19 +5342,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
columnsToShow = [...keyCols, ...applyRuntimeOrder(dataCols, "main")];
|
||||
}
|
||||
|
||||
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
|
||||
const rightTotalColWidth = columnsToShow.reduce((sum, col) => {
|
||||
const w = col.width && col.width <= 100 ? col.width : 0;
|
||||
return sum + w;
|
||||
}, 0);
|
||||
|
||||
const rightConfigColumnStart = columnsToShow.filter((c: any) => c._isKeyColumn).length;
|
||||
const canDragRightColumns = displayColumns.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="min-w-full">
|
||||
<table className="min-w-full" style={{ minWidth: `${Math.max(columnsToShow.length * 120, 400)}px` }}>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-border/60 border-b-2">
|
||||
{columnsToShow.map((col, idx) => {
|
||||
|
|
@ -5221,7 +5368,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
isDragging && "opacity-50",
|
||||
)}
|
||||
style={{
|
||||
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
|
||||
minWidth: col.width ? `${col.width}px` : "80px",
|
||||
textAlign: col.align || "left",
|
||||
}}
|
||||
draggable={isDraggable}
|
||||
|
|
@ -5387,14 +5534,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
return filteredData.length > 0 ? (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<table className="min-w-full text-sm" style={{ minWidth: `${Math.max(columnsToDisplay.length * 120, 400)}px` }}>
|
||||
<thead className="bg-background sticky top-0 z-10">
|
||||
<tr className="border-border/60 border-b-2">
|
||||
{columnsToDisplay.map((col) => (
|
||||
<th
|
||||
key={col.name}
|
||||
className="text-muted-foreground px-3 py-[7px] text-left text-[9px] font-bold tracking-[0.04em] whitespace-nowrap uppercase"
|
||||
style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}
|
||||
style={{ minWidth: col.width ? `${col.width}px` : "80px" }}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
|
|
|
|||
|
|
@ -422,6 +422,7 @@ interface AdditionalTabConfigPanelProps {
|
|||
availableRightTables: TableInfo[];
|
||||
leftTableColumns: ColumnInfo[];
|
||||
menuObjid?: number;
|
||||
screenTableName?: string;
|
||||
// 공유 컬럼 로드 상태
|
||||
loadedTableColumns: Record<string, ColumnInfo[]>;
|
||||
loadTableColumns: (tableName: string) => Promise<void>;
|
||||
|
|
@ -466,14 +467,45 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
|||
loadTableColumns,
|
||||
loadingColumns,
|
||||
entityJoinColumns: entityJoinColumnsMap,
|
||||
screenTableName,
|
||||
}) => {
|
||||
// 탭 테이블 변경 시 컬럼 로드
|
||||
// 탭 테이블 변경 시 컬럼 로드 + 엔티티 관계 자동 감지
|
||||
useEffect(() => {
|
||||
if (tab.tableName && !loadedTableColumns[tab.tableName] && !loadingColumns[tab.tableName]) {
|
||||
loadTableColumns(tab.tableName);
|
||||
}
|
||||
}, [tab.tableName, loadedTableColumns, loadingColumns, loadTableColumns]);
|
||||
|
||||
// 탭 테이블 변경 시 좌측 테이블과의 관계 자동 감지
|
||||
useEffect(() => {
|
||||
const leftTable = config.leftPanel?.tableName || screenTableName;
|
||||
const rightTable = tab.tableName;
|
||||
if (!leftTable || !rightTable) return;
|
||||
// 이미 relation이 설정되어 있으면 스킵
|
||||
if (tab.relation?.keys && tab.relation.keys.length > 0) return;
|
||||
|
||||
const detectRelations = async () => {
|
||||
try {
|
||||
const { tableManagementApi } = await import("@/lib/api/tableManagement");
|
||||
const response = await tableManagementApi.getTableEntityRelations(leftTable, rightTable);
|
||||
if (response.success && response.data?.relations?.length > 0) {
|
||||
const firstRel = response.data.relations[0];
|
||||
updateTab({
|
||||
relation: {
|
||||
type: "join",
|
||||
keys: [{ leftColumn: firstRel.leftColumn, rightColumn: firstRel.rightColumn }],
|
||||
},
|
||||
});
|
||||
console.log(`✅ 추가 탭 [${tab.label}] 엔티티 관계 자동 감지:`, firstRel);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`추가 탭 [${tab.label}] 관계 감지 실패:`, error);
|
||||
}
|
||||
};
|
||||
detectRelations();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab.tableName, config.leftPanel?.tableName, screenTableName]);
|
||||
|
||||
// 현재 탭의 컬럼 목록
|
||||
const tabColumns = useMemo(() => {
|
||||
return tab.tableName ? loadedTableColumns[tab.tableName] || [] : [];
|
||||
|
|
@ -3707,6 +3739,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
availableRightTables={availableRightTables}
|
||||
leftTableColumns={leftTableColumns}
|
||||
menuObjid={menuObjid}
|
||||
screenTableName={screenTableName}
|
||||
loadedTableColumns={loadedTableColumns}
|
||||
loadTableColumns={loadTableColumns}
|
||||
loadingColumns={loadingColumns}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,19 @@ import { DataFilterConfig, TabInlineComponent } from "@/types/screen-management"
|
|||
*/
|
||||
export type PanelInlineComponent = TabInlineComponent;
|
||||
|
||||
/** 우측 패널 추가 시 좌측 데이터 자동 채움 설정 */
|
||||
export interface AddConfig {
|
||||
targetTable?: string; // 실제로 INSERT할 테이블 (중계 테이블)
|
||||
autoFillColumns?: Record<string, any>; // 자동으로 채워질 컬럼과 기본값
|
||||
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지 (단일 키, 하위호환)
|
||||
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지 (단일 키, 하위호환)
|
||||
/** 좌측 선택 데이터에서 복수 컬럼을 자동으로 채움 (엔티티 관계 포함) */
|
||||
autoFillFromLeft?: Array<{
|
||||
source: string; // 좌측 데이터의 컬럼명
|
||||
target: string; // 저장할 테이블의 컬럼명
|
||||
}>;
|
||||
}
|
||||
|
||||
/** 페이징 처리 설정 (좌측/우측 패널 공통) */
|
||||
export interface PaginationConfig {
|
||||
enabled: boolean;
|
||||
|
|
@ -88,12 +101,7 @@ export interface AdditionalTabConfig {
|
|||
}>;
|
||||
};
|
||||
|
||||
addConfig?: {
|
||||
targetTable?: string;
|
||||
autoFillColumns?: Record<string, any>;
|
||||
leftPanelColumn?: string;
|
||||
targetColumn?: string;
|
||||
};
|
||||
addConfig?: AddConfig;
|
||||
|
||||
tableConfig?: {
|
||||
showCheckbox?: boolean;
|
||||
|
|
@ -304,12 +312,7 @@ export interface SplitPanelLayoutConfig {
|
|||
};
|
||||
|
||||
// 우측 패널 추가 시 중계 테이블 설정 (N:M 관계)
|
||||
addConfig?: {
|
||||
targetTable?: string; // 실제로 INSERT할 테이블 (중계 테이블)
|
||||
autoFillColumns?: Record<string, any>; // 자동으로 채워질 컬럼과 기본값
|
||||
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지
|
||||
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지
|
||||
};
|
||||
addConfig?: AddConfig;
|
||||
|
||||
// 테이블 모드 설정
|
||||
tableConfig?: {
|
||||
|
|
|
|||
|
|
@ -369,6 +369,8 @@ import {
|
|||
Trash2,
|
||||
Lock,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import * as XLSX from "xlsx";
|
||||
import { FileText, ChevronRightIcon } from "lucide-react";
|
||||
|
|
@ -810,17 +812,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
||||
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
|
||||
|
||||
// 🆕 서버에서 가져온 컬럼별 고유값 캐시 (헤더 필터 드롭다운용)
|
||||
const [asyncColumnUniqueValues, setAsyncColumnUniqueValues] = useState<
|
||||
Record<string, { value: string; label: string }[]>
|
||||
>({});
|
||||
const [loadingFilterColumn, setLoadingFilterColumn] = useState<string | null>(null);
|
||||
const [filterSearchTerm, setFilterSearchTerm] = useState("");
|
||||
|
||||
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
|
||||
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
|
||||
|
||||
|
||||
// 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함
|
||||
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
|
||||
|
||||
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터
|
||||
// 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용)
|
||||
// 헤더 필터와 필터 빌더는 서버사이드에서 처리됨 (fetchTableDataInternal에서 API 파라미터로 전달)
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data;
|
||||
|
||||
// 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링
|
||||
// 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링
|
||||
if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) {
|
||||
const addedIds = splitPanelContext.addedItemIds;
|
||||
result = result.filter((row) => {
|
||||
|
|
@ -829,78 +839,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
});
|
||||
}
|
||||
|
||||
// 2. 헤더 필터 적용 (joinColumnMapping 사용 - 조인된 컬럼과 일치해야 함)
|
||||
if (Object.keys(headerFilters).length > 0) {
|
||||
result = result.filter((row) => {
|
||||
return Object.entries(headerFilters).every(([columnName, values]) => {
|
||||
if (values.size === 0) return true;
|
||||
|
||||
// joinColumnMapping을 사용하여 조인된 컬럼명 확인
|
||||
const mappedColumnName = joinColumnMapping[columnName] || columnName;
|
||||
|
||||
// 여러 가능한 컬럼명 시도 (mappedColumnName 우선)
|
||||
const cellValue = row[mappedColumnName] ?? row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
|
||||
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : "";
|
||||
|
||||
return values.has(cellStr);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 🆕 Filter Builder 적용
|
||||
if (filterGroups.length > 0) {
|
||||
result = result.filter((row) => {
|
||||
return filterGroups.every((group) => {
|
||||
const validConditions = group.conditions.filter(
|
||||
(c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value),
|
||||
);
|
||||
if (validConditions.length === 0) return true;
|
||||
|
||||
const evaluateCondition = (value: any, condition: (typeof group.conditions)[0]): boolean => {
|
||||
const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : "";
|
||||
const condValue = condition.value.toLowerCase();
|
||||
|
||||
switch (condition.operator) {
|
||||
case "equals":
|
||||
return strValue === condValue;
|
||||
case "notEquals":
|
||||
return strValue !== condValue;
|
||||
case "contains":
|
||||
return strValue.includes(condValue);
|
||||
case "notContains":
|
||||
return !strValue.includes(condValue);
|
||||
case "startsWith":
|
||||
return strValue.startsWith(condValue);
|
||||
case "endsWith":
|
||||
return strValue.endsWith(condValue);
|
||||
case "greaterThan":
|
||||
return parseFloat(strValue) > parseFloat(condValue);
|
||||
case "lessThan":
|
||||
return parseFloat(strValue) < parseFloat(condValue);
|
||||
case "greaterOrEqual":
|
||||
return parseFloat(strValue) >= parseFloat(condValue);
|
||||
case "lessOrEqual":
|
||||
return parseFloat(strValue) <= parseFloat(condValue);
|
||||
case "isEmpty":
|
||||
return strValue === "" || value === null || value === undefined;
|
||||
case "isNotEmpty":
|
||||
return strValue !== "" && value !== null && value !== undefined;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
if (group.logic === "AND") {
|
||||
return validConditions.every((cond) => evaluateCondition(row[cond.column], cond));
|
||||
} else {
|
||||
return validConditions.some((cond) => evaluateCondition(row[cond.column], cond));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]);
|
||||
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds]);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
|
|
@ -1650,16 +1590,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
|
||||
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
|
||||
// valueCode만 키로 사용 (valueId까지 넣으면 같은 라벨이 2번 나옴)
|
||||
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
|
||||
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>, parentLabel: string = "") => {
|
||||
items.forEach((item: any) => {
|
||||
if (item.valueCode) {
|
||||
const displayLabel = parentLabel
|
||||
? `${parentLabel} / ${item.valueLabel}`
|
||||
: item.valueLabel;
|
||||
mapping[String(item.valueCode)] = {
|
||||
label: item.valueLabel,
|
||||
label: displayLabel,
|
||||
color: item.color,
|
||||
};
|
||||
}
|
||||
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
|
||||
flattenTree(item.children, mapping);
|
||||
flattenTree(item.children, mapping, item.valueLabel);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -1956,11 +1899,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
};
|
||||
}
|
||||
|
||||
// 검색 필터, 연결 필터, RelatedDataButtons 필터 병합
|
||||
// 🆕 헤더 필터를 서버 필터 형식으로 변환
|
||||
const headerFilterValues: Record<string, any> = {};
|
||||
Object.entries(headerFilters).forEach(([columnName, values]) => {
|
||||
if (values.size > 0) {
|
||||
const mappedCol = joinColumnMapping[columnName] || columnName;
|
||||
headerFilterValues[mappedCol] = { value: Array.from(values), operator: "in" };
|
||||
}
|
||||
});
|
||||
|
||||
// 🆕 필터 빌더를 서버 필터 형식으로 변환
|
||||
const filterBuilderValues: Record<string, any> = {};
|
||||
filterGroups.forEach((group) => {
|
||||
group.conditions.forEach((cond) => {
|
||||
if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) {
|
||||
filterBuilderValues[cond.column] = { value: cond.value, operator: cond.operator };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 검색 필터, 연결 필터, RelatedDataButtons 필터, 헤더 필터, 필터 빌더 병합
|
||||
const filters = {
|
||||
...(Object.keys(searchValues).length > 0 ? searchValues : {}),
|
||||
...linkedFilterValues,
|
||||
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
|
||||
...headerFilterValues, // 🆕 헤더 필터 추가
|
||||
...filterBuilderValues, // 🆕 필터 빌더 추가
|
||||
};
|
||||
const hasFilters = Object.keys(filters).length > 0;
|
||||
|
||||
|
|
@ -2137,6 +2101,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
isRelatedButtonTarget,
|
||||
// 🆕 프리뷰용 회사 코드 오버라이드
|
||||
companyCode,
|
||||
// 🆕 서버사이드 헤더 필터 / 필터 빌더
|
||||
headerFilters,
|
||||
filterGroups,
|
||||
joinColumnMapping,
|
||||
]);
|
||||
|
||||
const fetchTableDataDebounced = useCallback(
|
||||
|
|
@ -2594,6 +2562,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
return result;
|
||||
}, [data, tableConfig.columns, joinColumnMapping]);
|
||||
|
||||
// 데이터 변경 시 헤더 필터 드롭다운 캐시 초기화
|
||||
useEffect(() => {
|
||||
setAsyncColumnUniqueValues({});
|
||||
}, [data]);
|
||||
|
||||
// 🆕 헤더 필터 토글
|
||||
const toggleHeaderFilter = useCallback((columnName: string, value: string) => {
|
||||
setHeaderFilters((prev) => {
|
||||
|
|
@ -6122,11 +6095,40 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
<span className="text-primary">{sortDirection === "asc" ? "↑" : "↓"}</span>
|
||||
)}
|
||||
{/* 🆕 헤더 필터 버튼 */}
|
||||
{tableConfig.headerFilter !== false &&
|
||||
columnUniqueValues[column.columnName]?.length > 0 && (
|
||||
{tableConfig.headerFilter !== false && (
|
||||
<Popover
|
||||
open={openFilterColumn === column.columnName}
|
||||
onOpenChange={(open) => setOpenFilterColumn(open ? column.columnName : null)}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setOpenFilterColumn(column.columnName);
|
||||
setFilterSearchTerm("");
|
||||
// 서버에서 고유값 가져오기
|
||||
if (!asyncColumnUniqueValues[column.columnName]) {
|
||||
setLoadingFilterColumn(column.columnName);
|
||||
const mappedCol = joinColumnMapping[column.columnName] || column.columnName;
|
||||
const tableName = tableConfig.selectedTable;
|
||||
if (tableName) {
|
||||
import("@/lib/api/client").then(({ apiClient }) => {
|
||||
apiClient
|
||||
.get(`/table-management/tables/${tableName}/column-values/${mappedCol}`)
|
||||
.then((res) => {
|
||||
const values = (res.data?.data || []).map((v: any) => ({
|
||||
value: String(v.value ?? ""),
|
||||
label: String(v.label ?? v.value ?? ""),
|
||||
}));
|
||||
setAsyncColumnUniqueValues((prev) => ({ ...prev, [column.columnName]: values }));
|
||||
})
|
||||
.catch(() => {
|
||||
setAsyncColumnUniqueValues((prev) => ({ ...prev, [column.columnName]: [] }));
|
||||
})
|
||||
.finally(() => setLoadingFilterColumn(null));
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setOpenFilterColumn(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
|
|
@ -6146,7 +6148,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-48 p-2"
|
||||
className="w-56 p-2"
|
||||
align="start"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
|
@ -6164,35 +6166,66 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3 w-3 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={filterSearchTerm}
|
||||
onChange={(e) => setFilterSearchTerm(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="border-input bg-background w-full rounded border py-1 pr-2 pl-7 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
|
||||
const isSelected = headerFilters[column.columnName]?.has(val);
|
||||
return (
|
||||
<div
|
||||
key={val}
|
||||
className={cn(
|
||||
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
|
||||
isSelected && "bg-primary/10",
|
||||
)}
|
||||
onClick={() => toggleHeaderFilter(column.columnName, val)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded border",
|
||||
isSelected ? "bg-primary border-primary" : "border-input",
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
|
||||
</div>
|
||||
<span className="truncate">{val || "(빈 값)"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(columnUniqueValues[column.columnName]?.length || 0) > 50 && (
|
||||
<div className="text-muted-foreground px-2 py-1 text-xs">
|
||||
...외 {(columnUniqueValues[column.columnName]?.length || 0) - 50}개
|
||||
{loadingFilterColumn === column.columnName ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-xs">로딩중...</span>
|
||||
</div>
|
||||
)}
|
||||
) : (asyncColumnUniqueValues[column.columnName] || []).length === 0 ? (
|
||||
<div className="text-muted-foreground px-2 py-2 text-center text-xs">
|
||||
필터 값이 없습니다
|
||||
</div>
|
||||
) : (() => {
|
||||
const filteredItems = (asyncColumnUniqueValues[column.columnName] || []).filter((item) => {
|
||||
if (!filterSearchTerm) return true;
|
||||
const term = filterSearchTerm.toLowerCase();
|
||||
return item.value.toLowerCase().includes(term) || item.label.toLowerCase().includes(term);
|
||||
});
|
||||
return filteredItems.length === 0 ? (
|
||||
<div className="text-muted-foreground px-2 py-2 text-center text-xs">
|
||||
검색 결과가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredItems.map((item) => {
|
||||
const isSelected = headerFilters[column.columnName]?.has(item.value);
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
|
||||
isSelected && "bg-primary/10",
|
||||
)}
|
||||
onClick={() => toggleHeaderFilter(column.columnName, item.value)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded border",
|
||||
isSelected ? "bg-primary border-primary" : "border-input",
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
|
||||
</div>
|
||||
<span className="truncate">{item.label || item.value || "(빈 값)"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Settings, X, ChevronsUpDown } from "lucide-react";
|
||||
import { Settings, X, ChevronsUpDown, Search } from "lucide-react";
|
||||
import { useTableOptions } from "@/contexts/TableOptionsContext";
|
||||
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
|
||||
import { useActiveTab } from "@/contexts/ActiveTabContext";
|
||||
|
|
@ -77,6 +77,10 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
|
||||
// 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지)
|
||||
const [selectedLabels, setSelectedLabels] = useState<Record<string, string>>({});
|
||||
// select 필터 드롭다운 내 검색 텍스트
|
||||
const [selectSearchTexts, setSelectSearchTexts] = useState<Record<string, string>>({});
|
||||
// select 필터 Popover 열림 상태
|
||||
const [selectPopoverOpen, setSelectPopoverOpen] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 높이 감지를 위한 ref
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -695,6 +699,14 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
[] as Array<{ value: string; label: string }>,
|
||||
);
|
||||
|
||||
// 검색 텍스트로 필터링
|
||||
const searchText = selectSearchTexts[filter.columnName] || "";
|
||||
const filteredOptions = searchText
|
||||
? uniqueOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
: uniqueOptions;
|
||||
|
||||
// 항상 다중선택 모드
|
||||
const selectedValues: string[] = Array.isArray(value) ? value : value ? [value] : [];
|
||||
|
||||
|
|
@ -719,7 +731,15 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover
|
||||
open={selectPopoverOpen[filter.columnName] || false}
|
||||
onOpenChange={(open) => {
|
||||
setSelectPopoverOpen((prev) => ({ ...prev, [filter.columnName]: open }));
|
||||
if (!open) {
|
||||
setSelectSearchTexts((prev) => ({ ...prev, [filter.columnName]: "" }));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -735,12 +755,34 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<div className="border-b p-2">
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="검색..."
|
||||
value={selectSearchTexts[filter.columnName] || ""}
|
||||
onChange={(e) =>
|
||||
setSelectSearchTexts((prev) => ({
|
||||
...prev,
|
||||
[filter.columnName]: e.target.value,
|
||||
}))
|
||||
}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") e.preventDefault(); }}
|
||||
className="h-8 pl-8 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 sm:text-sm"
|
||||
style={{ outline: "none", boxShadow: "none" }}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-auto">
|
||||
{uniqueOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">옵션 없음</div>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">
|
||||
{searchText ? "검색 결과 없음" : "옵션 없음"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{uniqueOptions.map((option, index) => (
|
||||
{filteredOptions.map((option, index) => (
|
||||
<div
|
||||
key={`${filter.columnName}-multi-${option.value}-${index}`}
|
||||
className="hover:bg-accent flex cursor-pointer items-center space-x-2 rounded-sm px-2 py-1.5"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* 공통 폼 유효성 검증 + 자동 포맷팅 유틸리티
|
||||
*/
|
||||
|
||||
// --- 자동 포맷팅 ---
|
||||
|
||||
// 전화번호: 숫자만 추출 → 자동 하이픈
|
||||
// 010-1234-5678 / 02-1234-5678 / 031-123-4567
|
||||
export function formatPhone(value: string): string {
|
||||
const nums = value.replace(/\D/g, "").slice(0, 11);
|
||||
if (nums.startsWith("02")) {
|
||||
if (nums.length <= 2) return nums;
|
||||
if (nums.length <= 5) return `${nums.slice(0, 2)}-${nums.slice(2)}`;
|
||||
if (nums.length <= 9) return `${nums.slice(0, 2)}-${nums.slice(2, 5)}-${nums.slice(5)}`;
|
||||
return `${nums.slice(0, 2)}-${nums.slice(2, 6)}-${nums.slice(6)}`;
|
||||
}
|
||||
if (nums.length <= 3) return nums;
|
||||
if (nums.length <= 7) return `${nums.slice(0, 3)}-${nums.slice(3)}`;
|
||||
return `${nums.slice(0, 3)}-${nums.slice(3, 7)}-${nums.slice(7)}`;
|
||||
}
|
||||
|
||||
// 사업자번호: 000-00-00000
|
||||
export function formatBusinessNumber(value: string): string {
|
||||
const nums = value.replace(/\D/g, "").slice(0, 10);
|
||||
if (nums.length <= 3) return nums;
|
||||
if (nums.length <= 5) return `${nums.slice(0, 3)}-${nums.slice(3)}`;
|
||||
return `${nums.slice(0, 3)}-${nums.slice(3, 5)}-${nums.slice(5)}`;
|
||||
}
|
||||
|
||||
// 필드명으로 자동 포맷팅
|
||||
export function formatField(fieldName: string, value: string): string {
|
||||
switch (fieldName) {
|
||||
case "contact_phone":
|
||||
case "phone":
|
||||
case "cell_phone":
|
||||
return formatPhone(value);
|
||||
case "business_number":
|
||||
return formatBusinessNumber(value);
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 유효성 검증 ---
|
||||
|
||||
export function validatePhone(value: string): string | null {
|
||||
if (!value) return null;
|
||||
const nums = value.replace(/\D/g, "");
|
||||
if (nums.length < 9) return "전화번호를 끝까지 입력해주세요";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateEmail(value: string): string | null {
|
||||
if (!value) return null;
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "올바른 이메일 형식이 아닙니다";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateBusinessNumber(value: string): string | null {
|
||||
if (!value) return null;
|
||||
const nums = value.replace(/\D/g, "");
|
||||
if (nums.length < 10) return "사업자번호를 끝까지 입력해주세요";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateField(fieldName: string, value: string): string | null {
|
||||
if (!value) return null;
|
||||
switch (fieldName) {
|
||||
case "contact_phone":
|
||||
case "phone":
|
||||
case "cell_phone":
|
||||
return validatePhone(value);
|
||||
case "email":
|
||||
return validateEmail(value);
|
||||
case "business_number":
|
||||
return validateBusinessNumber(value);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateForm(
|
||||
data: Record<string, any>,
|
||||
fields: string[]
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const field of fields) {
|
||||
const error = validateField(field, data[field] || "");
|
||||
if (error) errors[field] = error;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
|
@ -267,7 +267,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -309,7 +308,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -343,7 +341,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
|
@ -3076,7 +3073,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
|
||||
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/react-reconciler": "^0.32.0",
|
||||
|
|
@ -3730,7 +3726,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
|
||||
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.6"
|
||||
},
|
||||
|
|
@ -3825,7 +3820,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
|
||||
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
|
|
@ -4139,7 +4133,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
|
||||
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-collab": "^1.3.1",
|
||||
|
|
@ -6640,7 +6633,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -6651,7 +6643,6 @@
|
|||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -6694,7 +6685,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
|
|
@ -6777,7 +6767,6 @@
|
|||
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.2",
|
||||
"@typescript-eslint/types": "8.46.2",
|
||||
|
|
@ -7410,7 +7399,6 @@
|
|||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -8561,8 +8549,7 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "7.9.0",
|
||||
|
|
@ -8884,7 +8871,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -9644,7 +9630,6 @@
|
|||
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -9733,7 +9718,6 @@
|
|||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
|
|
@ -9835,7 +9819,6 @@
|
|||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
|
|
@ -11007,7 +10990,6 @@
|
|||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
|
|
@ -11788,8 +11770,7 @@
|
|||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
|
|
@ -13128,7 +13109,6 @@
|
|||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
|
|
@ -13422,7 +13402,6 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
|
||||
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
|
|
@ -13452,7 +13431,6 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
|
|
@ -13501,7 +13479,6 @@
|
|||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
|
||||
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
|
|
@ -13705,7 +13682,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -13775,7 +13751,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -13826,7 +13801,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
|
||||
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
|
@ -13859,8 +13833,7 @@
|
|||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "5.0.0",
|
||||
|
|
@ -14168,7 +14141,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
|
|
@ -14191,8 +14163,7 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/recharts/node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -15222,8 +15193,7 @@
|
|||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
|
|
@ -15311,7 +15281,6 @@
|
|||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -15660,7 +15629,6 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface TableCategoryValue {
|
|||
// 계층 구조
|
||||
parentValueId?: number;
|
||||
depth?: number;
|
||||
path?: string;
|
||||
|
||||
// 추가 정보
|
||||
description?: string;
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ export type V2SelectSource = "static" | "code" | "db" | "api" | "entity" | "cate
|
|||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
displayLabel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue