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:
kjs 2026-03-24 11:36:00 +09:00
parent 3f8204e662
commit ec7308bf43
13 changed files with 2078 additions and 241 deletions

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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"
}

View File

@ -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}

View File

@ -1,3 +1,3 @@
{
"lastSentAt": "2026-03-04T07:30:30.883Z"
"lastSentAt": "2026-03-24T02:34:43.277Z"
}

View File

@ -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"
}
]
}
]
}

View File

@ -0,0 +1,3 @@
{
"lastSentAt": "2026-03-24T01:08:38.875Z"
}

View File

@ -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>
);
}

View File

@ -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 };
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}