From ec7308bf43edef6edeac269f57c767e31ae28b5b Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 24 Mar 2026 11:36:00 +0900 Subject: [PATCH] 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. --- .omc/project-memory.json | 419 ++++++++----- .../d2bc3862-569e-4904-a3f9-6b20e3f14c43.json | 8 + .../d6a10e69-4ebc-48f9-b451-c1d0587badc8.json | 8 + .omc/state/hud-state.json | 6 - .omc/state/hud-stdin-cache.json | 1 - .omc/state/idle-notif-cooldown.json | 2 +- .omc/state/mission-state.json | 53 ++ frontend/.omc/state/idle-notif-cooldown.json | 3 + frontend/app/(main)/sales/order/page.tsx | 200 +++--- frontend/components/common/ConfirmDialog.tsx | 118 ++++ frontend/components/common/DataGrid.tsx | 528 ++++++++++++++++ .../common/ShippingPlanBatchModal.tsx | 398 ++++++++++++ .../components/common/ShippingPlanModal.tsx | 575 ++++++++++++++++++ 13 files changed, 2078 insertions(+), 241 deletions(-) create mode 100644 .omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json create mode 100644 .omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json delete mode 100644 .omc/state/hud-state.json delete mode 100644 .omc/state/hud-stdin-cache.json create mode 100644 .omc/state/mission-state.json create mode 100644 frontend/.omc/state/idle-notif-cooldown.json create mode 100644 frontend/components/common/ConfirmDialog.tsx create mode 100644 frontend/components/common/DataGrid.tsx create mode 100644 frontend/components/common/ShippingPlanBatchModal.tsx create mode 100644 frontend/components/common/ShippingPlanModal.tsx diff --git a/.omc/project-memory.json b/.omc/project-memory.json index 9a424223..b780218b 100644 --- a/.omc/project-memory.json +++ b/.omc/project-memory.json @@ -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": [] } \ No newline at end of file diff --git a/.omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json b/.omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json new file mode 100644 index 00000000..5d45e30d --- /dev/null +++ b/.omc/sessions/d2bc3862-569e-4904-a3f9-6b20e3f14c43.json @@ -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": [] +} \ No newline at end of file diff --git a/.omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json b/.omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json new file mode 100644 index 00000000..123b9291 --- /dev/null +++ b/.omc/sessions/d6a10e69-4ebc-48f9-b451-c1d0587badc8.json @@ -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": [] +} \ No newline at end of file diff --git a/.omc/state/hud-state.json b/.omc/state/hud-state.json deleted file mode 100644 index 5fbc9b8f..00000000 --- a/.omc/state/hud-state.json +++ /dev/null @@ -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" -} \ No newline at end of file diff --git a/.omc/state/hud-stdin-cache.json b/.omc/state/hud-stdin-cache.json deleted file mode 100644 index d5a8e668..00000000 --- a/.omc/state/hud-stdin-cache.json +++ /dev/null @@ -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} \ No newline at end of file diff --git a/.omc/state/idle-notif-cooldown.json b/.omc/state/idle-notif-cooldown.json index 84ff7ebe..351027da 100644 --- a/.omc/state/idle-notif-cooldown.json +++ b/.omc/state/idle-notif-cooldown.json @@ -1,3 +1,3 @@ { - "lastSentAt": "2026-03-04T07:30:30.883Z" + "lastSentAt": "2026-03-24T02:34:43.277Z" } \ No newline at end of file diff --git a/.omc/state/mission-state.json b/.omc/state/mission-state.json new file mode 100644 index 00000000..f23e7222 --- /dev/null +++ b/.omc/state/mission-state.json @@ -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" + } + ] + } + ] +} \ No newline at end of file diff --git a/frontend/.omc/state/idle-notif-cooldown.json b/frontend/.omc/state/idle-notif-cooldown.json new file mode 100644 index 00000000..e0410a83 --- /dev/null +++ b/frontend/.omc/state/idle-notif-cooldown.json @@ -0,0 +1,3 @@ +{ + "lastSentAt": "2026-03-24T01:08:38.875Z" +} \ No newline at end of file diff --git a/frontend/app/(main)/sales/order/page.tsx b/frontend/app/(main)/sales/order/page.tsx index e23e8fe5..9a9800f6 100644 --- a/frontend/app/(main)/sales/order/page.tsx +++ b/frontend/app/(main)/sales/order/page.tsx @@ -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([]); 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>({}); - // 선택된 행 - const [selectedId, setSelectedId] = useState(null); + // 체크된 행 (다중선택) + const [checkedIds, setCheckedIds] = useState([]); // 카테고리 로드 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 = {}; + 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 = {}; - 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() { - - + -
- {loading ? ( -
- ) : orders.length === 0 ? ( -
- 등록된 수주가 없습니다 -
- ) : ( - - - - No - {LIST_COLUMNS.map((col) => {col.label})} - - - - {orders.map((item, idx) => ( - setSelectedId(item.id)} - onDoubleClick={() => openEditModal(item.order_no)}> - {idx + 1} - {LIST_COLUMNS.map((col) => ( - {item[col.key] || ""} - ))} - - ))} - -
- )} -
+ openEditModal(row.order_no)} + tableName={DETAIL_TABLE} + emptyMessage="등록된 수주가 없습니다" + onCellEdit={() => fetchOrders()} + /> {/* 수주 등록/수정 모달 */} @@ -535,7 +564,7 @@ export default function SalesOrderPage() {
setMasterForm((p) => ({ ...p, order_no: e.target.value }))} - placeholder="자동 채번 또는 직접 입력" className="h-9" disabled={isEditMode} /> + placeholder="수주번호" className="h-9" disabled={isEditMode} />
@@ -789,6 +818,14 @@ export default function SalesOrderPage() { + {/* 출하계획 동시 등록 모달 */} + + {/* 엑셀 업로드 */} fetchOrders()} /> + + {/* 공통 확인 다이얼로그 */} + {ConfirmDialogComponent}
); } diff --git a/frontend/components/common/ConfirmDialog.tsx b/frontend/components/common/ConfirmDialog.tsx new file mode 100644 index 00000000..6450ffdc --- /dev/null +++ b/frontend/components/common/ConfirmDialog.tsx @@ -0,0 +1,118 @@ +"use client"; + +/** + * ConfirmDialog — 공통 확인/취소 다이얼로그 + * + * 사용법: + * const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + * + * // JSX에 넣기 + * + * + * // 호출 + * 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({}); + const resolveRef = useRef<((value: boolean) => void) | null>(null); + + const confirm = useCallback((msg: string, opts?: ConfirmOptions): Promise => { + setTitle(msg); + setOptions(opts || {}); + setOpen(true); + return new Promise((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 = ( + { if (!v) handleCancel(); }}> + + +
+
+ +
+
+ {title} + {options.description && ( + + {options.description} + + )} +
+
+
+ + + {options.cancelText || "취소"} + + + {options.confirmText || "확인"} + + +
+
+ ); + + return { confirm, ConfirmDialogComponent }; +} diff --git a/frontend/components/common/DataGrid.tsx b/frontend/components/common/DataGrid.tsx new file mode 100644 index 00000000..56e0575a --- /dev/null +++ b/frontend/components/common/DataGrid.tsx @@ -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; + 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 ( + +
+
{ + e.stopPropagation(); + if (col.sortable !== false) onSort(col.key); + }} + > + {col.label} + {isSorted && ( + {sortDir === "asc" ? "↑" : "↓"} + )} +
+ + {/* 컬럼 필터 아이콘 → Popover */} + {col.filterable !== false && uniqueValues.length > 0 && ( + + + + + e.stopPropagation()}> +
+
+ 필터: {col.label} + {hasFilter && ( + + )} +
+ {/* 검색 */} +
+ + setFilterSearch(e.target.value)} + placeholder="검색..." + className="h-7 text-xs pl-7" + /> +
+
+ {uniqueValues + .filter((v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())) + .slice(0, 50).map((val) => { + const isSelected = headerFilterValues.has(val); + return ( +
onToggleFilter(col.key, val)} + > +
+ {isSelected && } +
+ {val || "(빈 값)"} +
+ ); + })} + {uniqueValues.filter((v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())).length > 50 && ( +
+ ...외 {uniqueValues.filter((v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())).length - 50}개 +
+ )} +
+
+
+
+ )} +
+
+ ); +} + +// --- 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(null); + const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + + // 헤더 필터 (컬럼별 선택된 값 Set) + const [headerFilters, setHeaderFilters] = useState>>({}); + + // 인라인 편집 + const [editingCell, setEditingCell] = useState<{ rowIdx: number; colKey: string } | null>(null); + const [editValue, setEditValue] = useState(""); + const editRef = useRef(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 = {}; + for (const col of columns) { + const values = new Set(); + 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 ( + + ); + } + return ( + 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 ( + + {display} + + ); + }; + + return ( +
+ + + + c.key)} strategy={horizontalListSortingStrategy}> + + {showCheckbox && ( + + 0 && checkedIds.length === processedData.length} + onCheckedChange={(checked) => { + onCheckedChange?.(checked ? processedData.map((r) => r.id) : []); + }} + /> + + )} + {showRowNumber && !showCheckbox && No} + {columns.map((col) => ( + + ))} + + + + + {loading ? ( + + + 로딩 중... + + + ) : processedData.length === 0 ? ( + + + {emptyMessage} + + + ) : processedData.map((row, rowIdx) => ( + { + 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 && ( + e.stopPropagation()}> + { + const next = checked + ? [...checkedIds, row.id] + : checkedIds.filter((id) => id !== row.id); + onCheckedChange?.(next); + }} + /> + + )} + {showRowNumber && !showCheckbox && {rowIdx + 1}} + {columns.map((col) => ( + { + e.stopPropagation(); + if (col.editable) startEdit(rowIdx, col.key, row[col.key]); + }} + > + {renderCell(row, col, rowIdx)} + + ))} + + ))} + +
+
+
+ ); +} diff --git a/frontend/components/common/ShippingPlanBatchModal.tsx b/frontend/components/common/ShippingPlanBatchModal.tsx new file mode 100644 index 00000000..942174c7 --- /dev/null +++ b/frontend/components/common/ShippingPlanBatchModal.tsx @@ -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 ( + + + + + +
+ {/* 오전/오후 */} +
+ {(["오전", "오후"] as const).map((p) => ( + + ))} +
+ {/* 시 */} +
+ {[12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((h) => ( + + ))} +
+ {/* 분 */} +
+ {minutes.map((m) => ( + + ))} +
+
+
+
+ ); +} + +// --- 타입 --- +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 ( +
+
{label}
+
{value.toLocaleString()}
+
+ ); +} + +// --- 메인 --- +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({}); + const [newPlans, setNewPlans] = useState>({}); + + 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 = {}; + 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 ( + + + +
+
+ + 출하계획 동시 등록 + + 출하계획 설정: {totalNewPlans}개 +
+ +
+
+ +
+ {loading ? ( +
+ ) : partCodes.length === 0 ? ( +
선택된 품목이 없습니다.
+ ) : partCodes.map((partCode) => { + const agg = aggregate[partCode]; + const orders = agg.orders || []; + const existingPlans = agg.existingPlans || []; + const rows = newPlans[partCode] || []; + const firstOrder = orders[0]; + + return ( +
+ {/* 품목 헤더 */} +
+
+ +
+
품목코드
+
{partCode}
+
+
+
+
품명
+
{firstOrder?.partName || "-"}
+
+
+ + {/* 통계 카드 (신규 입력량 반영) */} + {(() => { + const newQtySum = (newPlans[partCode] || []).reduce((sum, r) => sum + (Number(r.planQty) || 0), 0); + const totalPlanQty = agg.totalPlanQty + newQtySum; + const availableStock = agg.currentStock - totalPlanQty; + return ( +
+ + + + + +
+ ); + })()} + + {/* 테이블 — overflow-x-auto로 겹침 방지 */} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* 기존 계획 */} + {existingPlans.map((plan) => { + const order = orders.find((o) => o.sourceId === plan.sourceId); + return ( + + + + + + + + + + + + + ); + })} + + {/* 신규 입력 행 */} + {rows.map((row, rowIdx) => { + const order = orders.find((o) => o.sourceId === row.sourceId) || orders[0]; + return ( + + + + + + + + + + + + + ); + })} + +
구분수주번호거래처납기일미출하출하계획량출하계획일시간출하정보분할
기존{order?.orderNo || "-"}{order?.partnerName || "-"}{order?.dueDate?.split("T")[0] || "-"}{order?.balanceQty?.toLocaleString() || "-"}{plan.planQty.toLocaleString()}{plan.planDate?.split("T")[0] || "-"}-{plan.shipmentPlanNo || "-"}
신규{order?.orderNo || "-"}{order?.partnerName || "-"}{order?.dueDate?.split("T")[0] || "-"}{order?.balanceQty?.toLocaleString() || "-"} + { + 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" /> + + updateRow(partCode, row._id, "planDate", v)} placeholder="계획일" /> + + updateRow(partCode, row._id, "planTime", v)} /> + + updateRow(partCode, row._id, "shipInfo", e.target.value)} + className="h-8 text-xs" placeholder="출하정보 입력" /> + + {rowIdx === rows.length - 1 ? ( + + ) : ( + + )} +
+
+
+ ); + })} +
+ + +
+ 💡 수주 등 시 출하계획도 함께 저장됩니다 +
+ + +
+
+
+
+
+ ); +} diff --git a/frontend/components/common/ShippingPlanModal.tsx b/frontend/components/common/ShippingPlanModal.tsx new file mode 100644 index 00000000..3f05fe65 --- /dev/null +++ b/frontend/components/common/ShippingPlanModal.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [checkedIds, setCheckedIds] = useState([]); + 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(); + 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 ( + + e.preventDefault()} + > + +
+
+ 출하계획 관리 + + {orderNo ? `수주번호 ${orderNo}의 출하계획` : "전체 출하계획을 관리합니다."} + +
+ +
+
+ + {/* 검색 영역 */} +
+
+ +
+ setSearchDateFrom(e.target.value)} /> + ~ + setSearchDateTo(e.target.value)} /> +
+
+
+ + +
+
+ + setSearchCustomer(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} /> +
+
+ + setSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} /> +
+
+ + +
+
+ + {/* 목록 + 상세 패널 */} +
+ + +
+
+
+ 출하계획 목록 + {data.length}건 +
+
+ +
+ {loading ? ( +
+ +
+ ) : ( + + + + + 0 && checkedIds.length === data.filter((p) => p.status !== "CANCELLED").length} + onCheckedChange={handleCheckAll} + /> + + 수주번호 + 납기일 + 거래처 + 품목코드 + 품목명 + 수주수량 + 계획수량 + 출하계획일 + 상태 + + + + {groupedData.length === 0 ? ( + + + 출하계획이 없습니다. + + + ) : ( + groupedData.map((group) => + group.plans.map((plan, planIdx) => ( + handleRowClick(plan)} + > + e.stopPropagation()}> + {planIdx === 0 && ( + 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))); + } + }} + /> + )} + + + {planIdx === 0 ? (plan.order_no || "-") : ""} + + + {planIdx === 0 ? formatDate(plan.due_date) : ""} + + + {planIdx === 0 ? (plan.customer_name || "-") : ""} + + {plan.part_code || "-"} + {plan.part_name || "-"} + {formatNumber(plan.order_qty)} + {formatNumber(plan.plan_qty)} + {formatDate(plan.plan_date)} + + + {getStatusLabel(plan.status)} + + + + )) + ) + )} + +
+ )} +
+
+
+ + {/* 상세 패널 */} + {selectedId && selectedPlan && ( + <> + + +
+
+ + {selectedPlan.shipment_plan_no || `#${selectedPlan.id}`} + +
+ + +
+
+ +
+ {/* 기본 정보 */} +
+

기본 정보

+
+
+ 상태 + + {getStatusLabel(selectedPlan.status)} + +
+
+ 수주번호 + {selectedPlan.order_no || "-"} +
+
+ 거래처 + {selectedPlan.customer_name || "-"} +
+
+ 납기일 + {formatDate(selectedPlan.due_date)} +
+
+
+ + {/* 품목 정보 */} +
+

품목 정보

+
+
+ 품목코드 + {selectedPlan.part_code || "-"} +
+
+ 품목명 + {selectedPlan.part_name || "-"} +
+
+ 규격 + {selectedPlan.spec || "-"} +
+
+ 재질 + {selectedPlan.material || "-"} +
+
+
+ + {/* 수량 정보 */} +
+

수량 정보

+
+
+ 수주수량 + {formatNumber(selectedPlan.order_qty)} +
+
+ + { setEditPlanQty(e.target.value); setIsDetailChanged(true); }} + disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"} + /> +
+
+ 출하수량 + {formatNumber(selectedPlan.shipped_qty)} +
+
+ 잔여수량 + 0 + ? "text-destructive" + : "text-emerald-600" + )}> + {formatNumber(Number(selectedPlan.plan_qty) - Number(selectedPlan.shipped_qty))} + +
+
+
+ + {/* 출하 정보 */} +
+

출하 정보

+
+
+ + { setEditPlanDate(e.target.value); setIsDetailChanged(true); }} + disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"} + /> +
+
+ +