jskim-node #426
|
|
@ -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,168 @@
|
|||
"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": 16,
|
||||
"lastAccessed": 1774313958064,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/app/(main)/sales/shipping-plan/page.tsx",
|
||||
"accessCount": 4,
|
||||
"lastAccessed": 1774313720455,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/components/common/DataGrid.tsx",
|
||||
"accessCount": 3,
|
||||
"lastAccessed": 1774313504763,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"path": "frontend/components/common/DynamicSearchFilter.tsx",
|
||||
"accessCount": 2,
|
||||
"lastAccessed": 1774313460662,
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"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"; // 임시 주석
|
||||
|
|
@ -353,6 +354,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); // 차량 운행 이력 관리
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -3357,16 +3357,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 +3412,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 +3453,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 +5499,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,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-25T01:37:37.051Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"tool_name": "Read",
|
||||
"tool_input_preview": "{\"file_path\":\"/Users/kimjuseok/ERP-node/frontend/app/(main)/sales/sales-item/page.tsx\"}",
|
||||
"error": "File content (13282 tokens) exceeds maximum allowed tokens (10000). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.",
|
||||
"timestamp": "2026-03-25T01:36:58.910Z",
|
||||
"retry_count": 1
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
{
|
||||
"updatedAt": "2026-03-25T01:37:19.659Z",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
],
|
||||
"total_spawned": 5,
|
||||
"total_completed": 5,
|
||||
"total_failed": 0,
|
||||
"last_updated": "2026-03-25T01:37:19.762Z"
|
||||
}
|
||||
|
|
@ -0,0 +1,728 @@
|
|||
"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,
|
||||
} 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 { 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 [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);
|
||||
|
||||
// 카테고리 로드
|
||||
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}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<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(); }} />
|
||||
)}
|
||||
|
||||
{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,474 @@
|
|||
"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,
|
||||
} 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 { 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 [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 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}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<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}
|
||||
</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,510 @@
|
|||
"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 } 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 { 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 [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);
|
||||
|
||||
// 카테고리 로드
|
||||
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}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<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()}
|
||||
/>
|
||||
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -62,9 +62,11 @@ import {
|
|||
Check,
|
||||
ChevronsUpDown,
|
||||
Loader2,
|
||||
FileSpreadsheet,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
||||
|
||||
// --- Types ---
|
||||
type ClaimType = "불량" | "교환" | "반품" | "배송지연" | "기타";
|
||||
|
|
@ -94,57 +96,7 @@ interface SalesOrderOption {
|
|||
status: string;
|
||||
}
|
||||
|
||||
// --- Sample Data ---
|
||||
const initialData: Claim[] = [
|
||||
{
|
||||
claimNo: "CLM-2025-004",
|
||||
claimDate: "2025-11-09",
|
||||
claimType: "불량",
|
||||
claimStatus: "접수",
|
||||
customerCode: "CUST-0001",
|
||||
customerName: "주식회사 코아스포트",
|
||||
managerName: "김철수",
|
||||
orderNo: "SO-2025-0102",
|
||||
claimContent: "제품 표면에 스크래치가 발견되었습니다.",
|
||||
processContent: "",
|
||||
},
|
||||
{
|
||||
claimNo: "CLM-2025-001",
|
||||
claimDate: "2025-01-05",
|
||||
claimType: "불량",
|
||||
claimStatus: "접수",
|
||||
customerCode: "CUST-0002",
|
||||
customerName: "(주)현상산업",
|
||||
managerName: "김철수",
|
||||
orderNo: "SO-2025-0102",
|
||||
claimContent: "제품 불량",
|
||||
processContent: "",
|
||||
},
|
||||
{
|
||||
claimNo: "CLM-2025-002",
|
||||
claimDate: "2025-01-04",
|
||||
claimType: "교환",
|
||||
claimStatus: "처리중",
|
||||
customerCode: "CUST-0003",
|
||||
customerName: "대한전섬",
|
||||
managerName: "이영희",
|
||||
orderNo: "SO-2025-0095",
|
||||
claimContent: "규격 불일치",
|
||||
processContent: "교환 진행 중",
|
||||
},
|
||||
{
|
||||
claimNo: "CLM-2025-003",
|
||||
claimDate: "2025-01-03",
|
||||
claimType: "반품",
|
||||
claimStatus: "완료",
|
||||
customerCode: "CUST-0004",
|
||||
customerName: "삼성전자",
|
||||
managerName: "박민수",
|
||||
orderNo: "SO-2024-1285",
|
||||
claimContent: "수량 초과 납품",
|
||||
processContent: "반품 완료",
|
||||
},
|
||||
];
|
||||
const initialData: Claim[] = [];
|
||||
|
||||
const getClaimTypeStyle = (type: ClaimType) => {
|
||||
switch (type) {
|
||||
|
|
@ -193,6 +145,9 @@ export default function ClaimManagementPage() {
|
|||
const [searchCustomer, setSearchCustomer] = useState("");
|
||||
const [searchClaimNo, setSearchClaimNo] = useState("");
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
|
@ -563,9 +518,14 @@ export default function ClaimManagementPage() {
|
|||
{filteredData.length}건
|
||||
</Badge>
|
||||
</div>
|
||||
<Button size="sm" onClick={openRegisterModal}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> 클레임 등록
|
||||
</Button>
|
||||
<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={openRegisterModal}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> 클레임 등록
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
|
|
@ -1122,6 +1082,16 @@ export default function ClaimManagementPage() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 엑셀 업로드 모달 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
onOpenChange={setExcelUploadOpen}
|
||||
tableName="claim_mng"
|
||||
onSuccess={() => {
|
||||
// TODO: 클레임 테이블 API 연동 후 데이터 새로고침
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,900 @@
|
|||
"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,
|
||||
} 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";
|
||||
|
||||
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[]>([]);
|
||||
|
||||
// 카테고리 로드
|
||||
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}
|
||||
/>
|
||||
|
||||
{/* 메인 테이블 */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
gridId="sales-order"
|
||||
columns={GRID_COLUMNS}
|
||||
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()}
|
||||
/>
|
||||
|
||||
{/* 공통 확인 다이얼로그 */}
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,889 @@
|
|||
"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 } 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 { 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 [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);
|
||||
|
||||
// 카테고리 로드
|
||||
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}
|
||||
extraActions={
|
||||
<div className="flex gap-1.5">
|
||||
<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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,460 @@
|
|||
"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 } 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 DynamicSearchFilterProps {
|
||||
/** 테이블명 (컬럼 목록 + 카테고리 옵션 로드에 사용) */
|
||||
tableName: string;
|
||||
/** 고유 ID (localStorage 키 분리용, 예: "item-info", "sales-order") */
|
||||
filterId: string;
|
||||
/** 필터 변경 시 콜백 — API 필터 배열 형태로 전달 */
|
||||
onFilterChange: (filters: FilterValue[]) => void;
|
||||
/** 데이터 건수 표시 (optional) */
|
||||
dataCount?: number;
|
||||
/** 추가 액션 버튼 영역 */
|
||||
extraActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
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,
|
||||
}: 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 [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]);
|
||||
|
||||
// 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 : "");
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={widthStyle}>
|
||||
<Popover>
|
||||
<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">
|
||||
<div className="max-h-60 overflow-auto p-1">
|
||||
{options.length === 0 ? (
|
||||
<div className="text-muted-foreground px-3 py-2 text-xs">옵션 없음</div>
|
||||
) : options.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}
|
||||
<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,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;
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -3896,13 +3896,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 +4016,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -4027,8 +4027,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 +4134,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
|||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
|
|
@ -5189,19 +5187,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 +5213,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 +5379,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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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