Update project memory configuration and add new components for shipping plans
- Updated project memory settings, including last scanned timestamp and project root path. - Added new session files to track session data and state. - Introduced ConfirmDialog and DataGrid components for improved user interaction and data management. - Implemented ShippingPlanBatchModal and ShippingPlanModal components for batch processing and management of shipping plans. - Enhanced sales order page with dynamic data handling and multi-select deletion functionality.
This commit is contained in:
parent
3f8204e662
commit
ec7308bf43
|
|
@ -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:34:43.277Z"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"lastSentAt": "2026-03-24T01:08:38.875Z"
|
||||
}
|
||||
|
|
@ -13,16 +13,19 @@ import {
|
|||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download,
|
||||
ClipboardList, Pencil, Search, X, Maximize2, Minimize2,
|
||||
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 { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
|
||||
|
||||
const DETAIL_TABLE = "sales_order_detail";
|
||||
|
||||
|
|
@ -38,21 +41,19 @@ const parseNumber = (val: string) => val.replace(/,/g, "");
|
|||
const MASTER_TABLE = "sales_order_mng";
|
||||
|
||||
// 메인 목록 테이블 컬럼 (sales_order_detail 기준)
|
||||
const LIST_COLUMNS = [
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "order_no", label: "수주번호", width: "w-[120px]" },
|
||||
{ key: "part_code", label: "품번", width: "w-[120px]" },
|
||||
{ key: "part_name", label: "품명", width: "min-w-[140px]" },
|
||||
{ key: "spec", label: "규격", width: "w-[100px]" },
|
||||
{ key: "width", label: "가로", width: "w-[70px]" },
|
||||
{ key: "height", label: "세로", width: "w-[70px]" },
|
||||
{ key: "thickness", label: "두께", width: "w-[70px]" },
|
||||
{ key: "area", label: "면적", width: "w-[70px]" },
|
||||
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||
{ key: "qty", label: "수량", width: "w-[70px]" },
|
||||
{ key: "ship_qty", label: "출하수량", width: "w-[80px]" },
|
||||
{ key: "balance_qty", label: "잔량", width: "w-[70px]" },
|
||||
{ key: "unit_price", label: "단가", width: "w-[90px]" },
|
||||
{ key: "amount", label: "금액", width: "w-[100px]" },
|
||||
{ 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에 따라 표시 필드가 달라짐)
|
||||
|
|
@ -61,6 +62,7 @@ const LIST_COLUMNS = [
|
|||
|
||||
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);
|
||||
|
|
@ -86,11 +88,14 @@ export default function SalesOrderPage() {
|
|||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
|
||||
// 출하계획 모달
|
||||
const [shippingPlanOpen, setShippingPlanOpen] = useState(false);
|
||||
|
||||
// 카테고리 옵션
|
||||
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
|
||||
|
||||
// 선택된 행
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
// 체크된 행 (다중선택)
|
||||
const [checkedIds, setCheckedIds] = useState<string[]>([]);
|
||||
|
||||
// 카테고리 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -159,7 +164,36 @@ export default function SalesOrderPage() {
|
|||
autoFilter: true,
|
||||
sort: { columnName: "order_no", order: "desc" },
|
||||
});
|
||||
const data = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
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 data = rows.map((row: any) => {
|
||||
const item = itemMap[row.part_code];
|
||||
return {
|
||||
...row,
|
||||
part_name: row.part_name || item?.item_name || "",
|
||||
spec: row.spec || item?.size || "",
|
||||
unit: row.unit || item?.unit || "",
|
||||
};
|
||||
});
|
||||
|
||||
setOrders(data);
|
||||
setTotalCount(res.data?.data?.total || data.length);
|
||||
} catch (err) {
|
||||
|
|
@ -216,38 +250,46 @@ export default function SalesOrderPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
// 삭제 (다중 선택)
|
||||
const handleDelete = async () => {
|
||||
const item = orders.find((o) => o.id === selectedId);
|
||||
if (!item) { toast.error("삭제할 수주를 선택해주세요."); return; }
|
||||
if (!confirm(`수주번호 ${item.order_no}의 데이터를 삭제하시겠습니까?`)) return;
|
||||
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 {
|
||||
// 디테일 삭제
|
||||
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: item.order_no }] },
|
||||
autoFilter: true,
|
||||
// 선택된 디테일 행 삭제
|
||||
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
||||
data: checkedIds.map((id) => ({ id })),
|
||||
});
|
||||
const details = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
|
||||
if (details.length > 0) {
|
||||
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
|
||||
data: details.map((d: any) => ({ id: d.id })),
|
||||
});
|
||||
}
|
||||
// 마스터 삭제
|
||||
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: item.order_no }] },
|
||||
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 })),
|
||||
// 해당 수주번호의 남은 디테일이 없으면 마스터도 삭제
|
||||
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("삭제되었습니다.");
|
||||
setSelectedId(null);
|
||||
setCheckedIds([]);
|
||||
fetchOrders();
|
||||
} catch (err) {
|
||||
console.error("삭제 실패:", err);
|
||||
|
|
@ -429,7 +471,7 @@ export default function SalesOrderPage() {
|
|||
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
|
||||
const data = orders.map((o) => {
|
||||
const row: Record<string, any> = {};
|
||||
for (const col of LIST_COLUMNS) row[col.label] = o[col.key] || "";
|
||||
for (const col of GRID_COLUMNS) row[col.label] = o[col.key] || "";
|
||||
return row;
|
||||
});
|
||||
await exportToExcel(data, "수주관리.xlsx", "수주목록");
|
||||
|
|
@ -463,48 +505,35 @@ export default function SalesOrderPage() {
|
|||
<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 = orders.find((o) => o.id === selectedId);
|
||||
<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={!selectedId} onClick={handleDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" /> 삭제
|
||||
<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>
|
||||
|
||||
<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>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground gap-2">
|
||||
<ClipboardList 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>
|
||||
{LIST_COLUMNS.map((col) => <TableHead key={col.key} className={col.width}>{col.label}</TableHead>)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.map((item, idx) => (
|
||||
<TableRow key={item.id} className={cn("cursor-pointer", selectedId === item.id && "bg-primary/5")}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
onDoubleClick={() => openEditModal(item.order_no)}>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
|
||||
{LIST_COLUMNS.map((col) => (
|
||||
<TableCell key={col.key} className="text-sm">{item[col.key] || ""}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* 수주 등록/수정 모달 */}
|
||||
|
|
@ -535,7 +564,7 @@ export default function SalesOrderPage() {
|
|||
<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} />
|
||||
placeholder="수주번호" className="h-9" disabled={isEditMode} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">수주일</Label>
|
||||
|
|
@ -789,6 +818,14 @@ export default function SalesOrderPage() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 출하계획 동시 등록 모달 */}
|
||||
<ShippingPlanBatchModal
|
||||
open={shippingPlanOpen}
|
||||
onOpenChange={setShippingPlanOpen}
|
||||
selectedDetailIds={checkedIds}
|
||||
onSuccess={fetchOrders}
|
||||
/>
|
||||
|
||||
{/* 엑셀 업로드 */}
|
||||
<ExcelUploadModal
|
||||
open={excelUploadOpen}
|
||||
|
|
@ -797,6 +834,9 @@ export default function SalesOrderPage() {
|
|||
userId={user?.userId}
|
||||
onSuccess={() => fetchOrders()}
|
||||
/>
|
||||
|
||||
{/* 공통 확인 다이얼로그 */}
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,528 @@
|
|||
"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 } 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;
|
||||
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 [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")} />
|
||||
);
|
||||
}
|
||||
|
||||
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-1.5", col.editable && "cursor-text")}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (col.editable) startEdit(rowIdx, col.key, row[col.key]);
|
||||
}}
|
||||
>
|
||||
{renderCell(row, col, rowIdx)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
"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 {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Plus, X, Loader2, Maximize2, Minimize2, Package, Truck, Clock } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
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 [isFullscreen, setIsFullscreen] = 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={cn(
|
||||
"overflow-hidden flex flex-col transition-all duration-200",
|
||||
isFullscreen ? "max-w-screen max-h-screen w-screen h-screen rounded-none" : "max-w-[1200px] w-[95vw] max-h-[90vh]"
|
||||
)}>
|
||||
<DialogHeader className="shrink-0">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<div>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Truck className="h-5 w-5" /> 출하계획 동시 등록
|
||||
</DialogTitle>
|
||||
<DialogDescription>출하계획 설정: <strong>{totalNewPlans}개</strong></DialogDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
|
||||
onClick={() => setIsFullscreen((p) => !p)}>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<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>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t pt-3">
|
||||
<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>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue