Merge remote-tracking branch 'origin/main'

This commit is contained in:
SeongHyun Kim 2026-03-27 10:56:31 +09:00
commit 0aef19578a
96 changed files with 19211 additions and 1251 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,174 @@
"path": "mcp-agent-orchestrator/src",
"purpose": "Source code",
"fileCount": 1,
"lastAccessed": 1772609393885,
"lastAccessed": 1774313213047,
"keyFiles": [
"index.ts"
]
},
"src/controllers": {
"path": "src/controllers",
"purpose": "Controllers",
"fileCount": 1,
"lastAccessed": 1772609393885,
"keyFiles": [
"dataflowDiagramController.ts"
]
},
"src/routes": {
"path": "src/routes",
"purpose": "Route handlers",
"fileCount": 1,
"lastAccessed": 1772609393885,
"keyFiles": [
"dataflowDiagramRoutes.ts"
]
},
"src/services": {
"path": "src/services",
"purpose": "Business logic services",
"fileCount": 1,
"lastAccessed": 1772609393885,
"keyFiles": [
"dataflowDiagramService.ts"
]
},
"src/utils": {
"path": "src/utils",
"purpose": "Utility functions",
"mcp-task-queue/data": {
"path": "mcp-task-queue/data",
"purpose": "Data files",
"fileCount": 2,
"lastAccessed": 1772609393885,
"lastAccessed": 1774313213047,
"keyFiles": [
"databaseValidator.ts",
"queryBuilder.ts"
"knowledge.json",
"tasks.json"
]
},
"mcp-task-queue/dist": {
"path": "mcp-task-queue/dist",
"purpose": "Distribution/build output",
"fileCount": 28,
"lastAccessed": 1774313213048,
"keyFiles": [
"agent-runner.d.ts",
"agent-runner.d.ts.map",
"agent-runner.js"
]
},
"mcp-task-queue/node_modules": {
"path": "mcp-task-queue/node_modules",
"purpose": "Dependencies",
"fileCount": 1,
"lastAccessed": 1774313213049,
"keyFiles": []
},
"mcp-task-queue/src": {
"path": "mcp-task-queue/src",
"purpose": "Source code",
"fileCount": 7,
"lastAccessed": 1774313213049,
"keyFiles": [
"agent-runner.ts",
"index.ts",
"knowledge-store.ts"
]
},
"mcp-task-server/data": {
"path": "mcp-task-server/data",
"purpose": "Data files",
"fileCount": 0,
"lastAccessed": 1774313213049,
"keyFiles": []
},
"mcp-task-server/dist": {
"path": "mcp-task-server/dist",
"purpose": "Distribution/build output",
"fileCount": 6,
"lastAccessed": 1774313213050,
"keyFiles": [
"index.d.ts",
"index.js",
"taskStore.d.ts"
]
},
"mcp-task-server/node_modules": {
"path": "mcp-task-server/node_modules",
"purpose": "Dependencies",
"fileCount": 1,
"lastAccessed": 1774313213050,
"keyFiles": []
},
"mcp-task-server/src": {
"path": "mcp-task-server/src",
"purpose": "Source code",
"fileCount": 0,
"lastAccessed": 1774313213052,
"keyFiles": []
}
},
"hotPaths": [],
"hotPaths": [
{
"path": "frontend/app/(main)/sales/order/page.tsx",
"accessCount": 19,
"lastAccessed": 1774408850812,
"type": "file"
},
{
"path": "frontend/app/(main)/sales/shipping-plan/page.tsx",
"accessCount": 4,
"lastAccessed": 1774313720455,
"type": "file"
},
{
"path": "frontend/components/common/DataGrid.tsx",
"accessCount": 4,
"lastAccessed": 1774408732451,
"type": "file"
},
{
"path": "frontend/components/common/DynamicSearchFilter.tsx",
"accessCount": 3,
"lastAccessed": 1774408732309,
"type": "file"
},
{
"path": "frontend/app/(main)/production/plan-management/page.tsx",
"accessCount": 2,
"lastAccessed": 1774313461313,
"type": "file"
},
{
"path": "frontend/app/(main)",
"accessCount": 2,
"lastAccessed": 1774313529384,
"type": "directory"
},
{
"path": "frontend/lib/api/shipping.ts",
"accessCount": 2,
"lastAccessed": 1774313725308,
"type": "file"
},
{
"path": ".claude/plans/lively-wishing-yeti.md",
"accessCount": 2,
"lastAccessed": 1774313824670,
"type": "file"
},
{
"path": "frontend/app/(main)/sales/shipping-order/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313447495,
"type": "file"
},
{
"path": "frontend/app/(main)/sales/claim/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313450420,
"type": "file"
},
{
"path": "frontend/app/(main)/production/process-info/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313450623,
"type": "file"
},
{
"path": "frontend/components/common/ExcelUploadModal.tsx",
"accessCount": 1,
"lastAccessed": 1774313454238,
"type": "file"
},
{
"path": "frontend/app/(main)/master-data/item-info/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313528166,
"type": "file"
},
{
"path": "frontend/components/common/ShippingPlanModal.tsx",
"accessCount": 1,
"lastAccessed": 1774313925751,
"type": "file"
},
{
"path": "frontend/components/common/TableSettingsModal.tsx",
"accessCount": 1,
"lastAccessed": 1774409034693,
"type": "file"
}
],
"userDirectives": []
}

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:36:44.477Z"
}

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

@ -151,6 +151,7 @@ import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
import outboundRoutes from "./routes/outboundRoutes"; // 출고관리
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
@ -367,6 +368,7 @@ app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
app.use("/api/design", designRoutes); // 설계 모듈
app.use("/api/receiving", receivingRoutes); // 입고관리
app.use("/api/outbound", outboundRoutes); // 출고관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리

View File

@ -2504,7 +2504,9 @@ export const changeUserStatus = async (
// 필수 파라미터 검증
if (!userId || !status) {
res.status(400).json({
success: false,
result: false,
message: "사용자 ID와 상태는 필수입니다.",
msg: "사용자 ID와 상태는 필수입니다.",
});
return;
@ -2513,7 +2515,9 @@ export const changeUserStatus = async (
// 상태 값 검증
if (!["active", "inactive"].includes(status)) {
res.status(400).json({
success: false,
result: false,
message: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
});
return;
@ -2528,7 +2532,9 @@ export const changeUserStatus = async (
if (!currentUser) {
res.status(404).json({
success: false,
result: false,
message: "사용자를 찾을 수 없습니다.",
msg: "사용자를 찾을 수 없습니다.",
});
return;
@ -2549,6 +2555,12 @@ export const changeUserStatus = async (
if (updateResult.length > 0) {
// 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
// inactive로 변경 시 기존 JWT 토큰 무효화
if (status === "inactive") {
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateUserTokens(userId);
}
logger.info("사용자 상태 변경 성공", {
userId,
oldStatus: currentUser.status,
@ -2571,12 +2583,16 @@ export const changeUserStatus = async (
});
res.json({
success: true,
result: true,
message: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
});
} else {
res.status(400).json({
success: false,
result: false,
message: "사용자 상태 변경에 실패했습니다.",
msg: "사용자 상태 변경에 실패했습니다.",
});
}
@ -2587,7 +2603,9 @@ export const changeUserStatus = async (
status: req.body.status,
});
res.status(500).json({
success: false,
result: false,
message: "시스템 오류가 발생했습니다.",
msg: "시스템 오류가 발생했습니다.",
});
}
@ -2627,12 +2645,214 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
}
}
// 추가 유효성 검증
// 1. email 형식 검증 (값이 있는 경우만)
if (userData.email && userData.email.trim() !== "") {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(userData.email.trim())) {
res.status(400).json({
success: false,
message: "이메일 형식이 올바르지 않습니다.",
error: {
code: "INVALID_EMAIL_FORMAT",
details: `Invalid email format: ${userData.email}`,
},
});
return;
}
}
// 2. companyCode 존재 확인 (값이 있는 경우만)
if (userData.companyCode && userData.companyCode.trim() !== "") {
const companyExists = await queryOne<{ company_code: string }>(
`SELECT company_code FROM company_mng WHERE company_code = $1`,
[userData.companyCode.trim()]
);
if (!companyExists) {
res.status(400).json({
success: false,
message: `존재하지 않는 회사 코드입니다: ${userData.companyCode}`,
error: {
code: "INVALID_COMPANY_CODE",
details: `Company code not found: ${userData.companyCode}`,
},
});
return;
}
}
// 3. userType 유효값 검증 (값이 있는 경우만)
if (userData.userType && userData.userType.trim() !== "") {
const validUserTypes = ["SUPER_ADMIN", "COMPANY_ADMIN", "USER", "GUEST", "PARTNER"];
if (!validUserTypes.includes(userData.userType.trim())) {
res.status(400).json({
success: false,
message: `유효하지 않은 사용자 유형입니다: ${userData.userType}. 허용값: ${validUserTypes.join(", ")}`,
error: {
code: "INVALID_USER_TYPE",
details: `Invalid userType: ${userData.userType}. Allowed: ${validUserTypes.join(", ")}`,
},
});
return;
}
}
// 4. 비밀번호 최소 길이 검증 (신규 등록 시)
if (!isUpdate && userData.userPassword && userData.userPassword.length < 4) {
res.status(400).json({
success: false,
message: "비밀번호는 최소 4자 이상이어야 합니다.",
error: {
code: "PASSWORD_TOO_SHORT",
details: "Password must be at least 4 characters long",
},
});
return;
}
// 비밀번호 암호화 (비밀번호가 제공된 경우에만)
let encryptedPassword = null;
if (userData.userPassword) {
encryptedPassword = await EncryptUtil.encrypt(userData.userPassword);
}
// PUT(수정) 요청 시 company_code / dept_code 변경 감지
if (isUpdate) {
const existingUser = await queryOne<{ company_code: string; dept_code: string }>(
`SELECT company_code, dept_code FROM user_info WHERE user_id = $1`,
[userData.userId]
);
// company_code 변경 감지 → 이전 회사 권한 그룹 제거
if (
userData.companyCode &&
existingUser &&
existingUser.company_code &&
existingUser.company_code !== userData.companyCode
) {
const oldCompanyCode = existingUser.company_code;
logger.info("사용자 회사 코드 변경 감지 - 이전 회사 권한 그룹 제거", {
userId: userData.userId,
oldCompanyCode,
newCompanyCode: userData.companyCode,
});
// 이전 회사의 권한 그룹에서 해당 사용자 제거
await query(
`DELETE FROM authority_sub_user
WHERE user_id = $1
AND master_objid IN (
SELECT objid FROM authority_master WHERE company_code = $2
)`,
[userData.userId, oldCompanyCode]
);
}
// dept_code 변경 감지 → 결재 템플릿/진행중 결재라인 경고 로그
const newDeptCode = userData.deptCode || null;
const oldDeptCode = existingUser?.dept_code || null;
if (existingUser && oldDeptCode && newDeptCode && oldDeptCode !== newDeptCode) {
logger.warn("사용자 부서 변경 감지 - 결재라인 영향 확인 시작", {
userId: userData.userId,
userName: userData.userName,
oldDeptCode,
newDeptCode,
});
try {
// 1) 결재선 템플릿 스텝에서 해당 사용자가 결재자로 등록된 건 조회
const templateSteps = await query<{
template_id: number;
step_order: number;
approver_label: string | null;
approver_dept_code: string | null;
}>(
`SELECT s.template_id, s.step_order, s.approver_label, s.approver_dept_code
FROM approval_line_template_steps s
WHERE s.approver_user_id = $1`,
[userData.userId]
);
if (templateSteps && templateSteps.length > 0) {
logger.warn(
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})가 결재선 템플릿 ${templateSteps.length}건에 결재자로 등록되어 있습니다. 수동 확인이 필요합니다.`,
{
userId: userData.userId,
oldDeptCode,
newDeptCode,
affectedTemplates: templateSteps.map((s) => ({
templateId: s.template_id,
stepOrder: s.step_order,
label: s.approver_label,
currentDeptInStep: s.approver_dept_code,
})),
}
);
}
// 2) 진행중인 결재 요청에서 해당 사용자가 대기중 결재자인 건 조회
const pendingLines = await query<{
request_id: number;
step_order: number;
approver_dept: string | null;
status: string;
}>(
`SELECT l.request_id, l.step_order, l.approver_dept, l.status
FROM approval_lines l
JOIN approval_requests r ON r.request_id = l.request_id
WHERE l.approver_id = $1
AND l.status = 'pending'
AND r.status IN ('in_progress', 'pending')`,
[userData.userId]
);
if (pendingLines && pendingLines.length > 0) {
logger.warn(
`[결재라인 경고] 부서 변경된 사용자(${userData.userId})에게 대기중인 결재 ${pendingLines.length}건이 있습니다. 수동 확인이 필요합니다.`,
{
userId: userData.userId,
oldDeptCode,
newDeptCode,
pendingApprovals: pendingLines.map((l) => ({
requestId: l.request_id,
stepOrder: l.step_order,
currentDeptInLine: l.approver_dept,
})),
}
);
}
// 감사 로그 기록
auditLogService.log({
companyCode: userData.companyCode || req.user?.companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: "DEPT_CHANGE_WARNING",
resourceType: "USER",
resourceId: userData.userId,
resourceName: userData.userName,
summary: `사용자 "${userData.userName}"의 부서 변경 (${oldDeptCode}${newDeptCode}). 결재 템플릿 ${templateSteps?.length || 0}건, 대기중 결재 ${pendingLines?.length || 0}건 영향 가능`,
changes: {
before: { deptCode: oldDeptCode },
after: {
deptCode: newDeptCode,
affectedTemplateCount: templateSteps?.length || 0,
pendingApprovalCount: pendingLines?.length || 0,
},
},
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
} catch (approvalCheckError) {
// 결재 테이블이 없는 환경에서도 사용자 저장은 계속 진행
logger.warn("결재라인 영향 확인 중 오류 (사용자 저장은 계속 진행)", {
error: approvalCheckError instanceof Error ? approvalCheckError.message : approvalCheckError,
});
}
}
}
// Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT)
const updatePasswordClause = encryptedPassword ? "user_password = $4," : "";
@ -2688,6 +2908,12 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
savedUser.regdate &&
new Date(savedUser.regdate).getTime() < Date.now() - 1000;
// 기존 사용자의 비밀번호 변경 시 JWT 토큰 무효화
if (encryptedPassword && isExistingUser) {
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateUserTokens(userData.userId);
}
logger.info(
isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료",
{
@ -3534,6 +3760,10 @@ export const resetUserPassword = async (
if (updateResult.length > 0) {
// 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
// 비밀번호 변경 후 기존 JWT 토큰 무효화
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateUserTokens(userId);
logger.info("비밀번호 초기화 성공", {
userId,
updatedBy: req.user?.userId,
@ -4153,6 +4383,140 @@ export const saveUserWithDept = async (
* GET /api/admin/users/:userId/with-dept
* + API ( )
*/
/**
* DELETE /api/admin/users/:userId
* API (soft delete)
* status = 'deleted', end_date = now()
* authority_sub_user , JWT
*/
export const deleteUser = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { userId } = req.params;
// 1. userId 파라미터 검증
if (!userId) {
res.status(400).json({
success: false,
result: false,
message: "사용자 ID는 필수입니다.",
});
return;
}
// 2. 자기 자신 삭제 방지
if (req.user?.userId === userId) {
res.status(400).json({
success: false,
result: false,
message: "자기 자신은 삭제할 수 없습니다.",
});
return;
}
// 3. 사용자 존재 여부 확인
const currentUser = await queryOne<any>(
`SELECT user_id, user_name, status, company_code FROM user_info WHERE user_id = $1`,
[userId]
);
if (!currentUser) {
res.status(404).json({
success: false,
result: false,
message: "사용자를 찾을 수 없습니다.",
});
return;
}
// 이미 삭제된 사용자 체크
if (currentUser.status === "deleted") {
res.status(400).json({
success: false,
result: false,
message: "이미 삭제된 사용자입니다.",
});
return;
}
// 4. soft delete: status = 'deleted', end_date = now()
const updateResult = await query<any>(
`UPDATE user_info
SET status = 'deleted', end_date = NOW()
WHERE user_id = $1
RETURNING *`,
[userId]
);
if (updateResult.length === 0) {
res.status(500).json({
success: false,
result: false,
message: "사용자 삭제에 실패했습니다.",
});
return;
}
// 5. authority_sub_user에서 해당 사용자 멤버십 제거
await query(
`DELETE FROM authority_sub_user WHERE user_id = $1`,
[userId]
);
// 6. JWT 토큰 무효화
try {
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateUserTokens(userId);
} catch (tokenError) {
logger.warn("토큰 무효화 중 오류 (삭제는 정상 처리됨)", { userId, error: tokenError });
}
logger.info("사용자 삭제(soft delete) 성공", {
userId,
userName: currentUser.user_name,
deletedBy: req.user?.userId,
});
// 7. 감사 로그 기록
auditLogService.log({
companyCode: currentUser.company_code || req.user?.companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: "DELETE",
resourceType: "USER",
resourceId: userId,
resourceName: currentUser.user_name,
summary: `사용자 "${currentUser.user_name}" (${userId}) 삭제 처리`,
changes: {
before: { status: currentUser.status },
after: { status: "deleted" },
fields: ["status", "end_date"],
},
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
// 8. 응답
res.json({
success: true,
result: true,
message: `사용자 "${currentUser.user_name}" (${userId})이(가) 삭제되었습니다.`,
});
} catch (error: any) {
logger.error("사용자 삭제 중 오류 발생", {
error: error.message,
userId: req.params.userId,
});
res.status(500).json({
success: false,
result: false,
message: "시스템 오류가 발생했습니다.",
});
}
};
export const getUserWithDept = async (
req: AuthenticatedRequest,
res: Response

View File

@ -561,6 +561,34 @@ export class EntityJoinController {
});
}
}
/**
* ( )
* GET /api/table-management/tables/:tableName/column-values/:columnName
*/
async getColumnUniqueValues(req: Request, res: Response): Promise<void> {
try {
const { tableName, columnName } = req.params;
const companyCode = (req as any).user?.companyCode;
const data = await tableManagementService.getColumnDistinctValues(
tableName,
columnName,
companyCode
);
res.status(200).json({
success: true,
data,
});
} catch (error) {
logger.error(`컬럼 고유값 조회 실패: ${req.params.tableName}.${req.params.columnName}`, error);
res.status(500).json({
success: false,
message: "컬럼 고유값 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
}
export const entityJoinController = new EntityJoinController();

View File

@ -191,18 +191,30 @@ export const getLangKeys = async (
): Promise<void> => {
try {
const { companyCode, menuCode, keyType, searchText, categoryId } = req.query;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 키 목록 조회 요청", {
query: req.query,
user: req.user,
});
// company_code 필터링: 비관리자는 자기 회사 + 공통(*) 키만 조회 가능
let effectiveCompanyCode = companyCode as string;
if (userCompanyCode !== "*") {
// 비관리자가 다른 회사의 데이터를 요청하면 자기 회사로 제한
if (companyCode && companyCode !== userCompanyCode && companyCode !== "*") {
effectiveCompanyCode = userCompanyCode || "";
}
}
const multiLangService = new MultiLangService();
const langKeys = await multiLangService.getLangKeys({
companyCode: companyCode as string,
companyCode: effectiveCompanyCode,
menuCode: menuCode as string,
keyType: keyType as string,
searchText: searchText as string,
categoryId: categoryId ? parseInt(categoryId as string, 10) : undefined,
// 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환
userCompanyCode: userCompanyCode,
});
const response: ApiResponse<any[]> = {
@ -235,9 +247,24 @@ export const getLangTexts = async (
): Promise<void> => {
try {
const { keyId } = req.params;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 텍스트 조회 요청", { keyId, user: req.user });
const multiLangService = new MultiLangService();
// 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증
if (userCompanyCode !== "*") {
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
if (keyOwner && keyOwner !== "*" && keyOwner !== userCompanyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 다국어 키에 접근할 권한이 없습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
}
const langTexts = await multiLangService.getLangTexts(parseInt(keyId));
const response: ApiResponse<any[]> = {
@ -270,6 +297,7 @@ export const createLangKey = async (
): Promise<void> => {
try {
const keyData: CreateLangKeyRequest = req.body;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 키 생성 요청", { keyData, user: req.user });
// 필수 입력값 검증
@ -285,6 +313,26 @@ export const createLangKey = async (
return;
}
// 권한 검사: 공통 키(*)는 최고 관리자만 생성 가능
if (keyData.companyCode === "*" && userCompanyCode !== "*") {
res.status(403).json({
success: false,
message: "공통 키는 최고 관리자만 생성할 수 있습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
// 비관리자: 자기 회사 키만 생성 가능
if (userCompanyCode !== "*" && keyData.companyCode !== userCompanyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 키를 생성할 권한이 없습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
const multiLangService = new MultiLangService();
const keyId = await multiLangService.createLangKey({
...keyData,
@ -323,10 +371,33 @@ export const updateLangKey = async (
try {
const { keyId } = req.params;
const keyData: UpdateLangKeyRequest = req.body;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 키 수정 요청", { keyId, keyData, user: req.user });
const multiLangService = new MultiLangService();
// 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키는 수정 불가)
if (userCompanyCode !== "*") {
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
if (!keyOwner) {
res.status(404).json({
success: false,
message: "다국어 키를 찾을 수 없습니다.",
error: { code: "KEY_NOT_FOUND" },
});
return;
}
if (keyOwner !== userCompanyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 다국어 키를 수정할 권한이 없습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
}
await multiLangService.updateLangKey(parseInt(keyId), {
...keyData,
updatedBy: req.user?.userId || "system",
@ -362,9 +433,32 @@ export const deleteLangKey = async (
): Promise<void> => {
try {
const { keyId } = req.params;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 키 삭제 요청", { keyId, user: req.user });
const multiLangService = new MultiLangService();
// 비관리자: 해당 키가 자기 회사 키인지 검증 (공통 키 삭제 불가)
if (userCompanyCode !== "*") {
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
if (!keyOwner) {
res.status(404).json({
success: false,
message: "다국어 키를 찾을 수 없습니다.",
error: { code: "KEY_NOT_FOUND" },
});
return;
}
if (keyOwner !== userCompanyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 다국어 키를 삭제할 권한이 없습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
}
await multiLangService.deleteLangKey(parseInt(keyId));
const response: ApiResponse<string> = {
@ -397,9 +491,32 @@ export const toggleLangKey = async (
): Promise<void> => {
try {
const { keyId } = req.params;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 키 상태 토글 요청", { keyId, user: req.user });
const multiLangService = new MultiLangService();
// 비관리자: 해당 키가 자기 회사 키인지 검증
if (userCompanyCode !== "*") {
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
if (!keyOwner) {
res.status(404).json({
success: false,
message: "다국어 키를 찾을 수 없습니다.",
error: { code: "KEY_NOT_FOUND" },
});
return;
}
if (keyOwner !== userCompanyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 다국어 키를 변경할 권한이 없습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
}
const result = await multiLangService.toggleLangKey(parseInt(keyId));
const response: ApiResponse<string> = {
@ -433,6 +550,7 @@ export const saveLangTexts = async (
try {
const { keyId } = req.params;
const textData: SaveLangTextsRequest = req.body;
const userCompanyCode = req.user?.companyCode;
logger.info("다국어 텍스트 저장 요청", { keyId, textData, user: req.user });
@ -454,6 +572,28 @@ export const saveLangTexts = async (
}
const multiLangService = new MultiLangService();
// 비관리자: 해당 키가 자기 회사 또는 공통(*) 키인지 검증
if (userCompanyCode !== "*") {
const keyOwner = await multiLangService.getKeyCompanyCode(parseInt(keyId));
if (!keyOwner) {
res.status(404).json({
success: false,
message: "다국어 키를 찾을 수 없습니다.",
error: { code: "KEY_NOT_FOUND" },
});
return;
}
if (keyOwner !== userCompanyCode) {
res.status(403).json({
success: false,
message: "다른 회사의 다국어 텍스트를 수정할 권한이 없습니다.",
error: { code: "PERMISSION_DENIED" },
});
return;
}
}
await multiLangService.saveLangTexts(parseInt(keyId), {
texts: textData.texts.map((text) => ({
...text,

View File

@ -0,0 +1,509 @@
/**
*
*
* :
* - shipment_instruction + shipment_instruction_detail ()
* - purchase_order_mng (/)
* - item_info ()
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// 출고 목록 조회
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const {
outbound_type,
outbound_status,
search_keyword,
date_from,
date_to,
} = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIdx = 1;
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`om.company_code = $${paramIdx}`);
params.push(companyCode);
paramIdx++;
}
if (outbound_type && outbound_type !== "all") {
conditions.push(`om.outbound_type = $${paramIdx}`);
params.push(outbound_type);
paramIdx++;
}
if (outbound_status && outbound_status !== "all") {
conditions.push(`om.outbound_status = $${paramIdx}`);
params.push(outbound_status);
paramIdx++;
}
if (search_keyword) {
conditions.push(
`(om.outbound_number ILIKE $${paramIdx} OR om.item_name ILIKE $${paramIdx} OR om.item_code ILIKE $${paramIdx} OR om.customer_name ILIKE $${paramIdx} OR om.reference_number ILIKE $${paramIdx})`
);
params.push(`%${search_keyword}%`);
paramIdx++;
}
if (date_from) {
conditions.push(`om.outbound_date >= $${paramIdx}`);
params.push(date_from);
paramIdx++;
}
if (date_to) {
conditions.push(`om.outbound_date <= $${paramIdx}`);
params.push(date_to);
paramIdx++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
om.*,
wh.warehouse_name
FROM outbound_mng om
LEFT JOIN warehouse_info wh
ON om.warehouse_code = wh.warehouse_code
AND om.company_code = wh.company_code
${whereClause}
ORDER BY om.created_date DESC
`;
const pool = getPool();
const result = await pool.query(query, params);
logger.info("출고 목록 조회", {
companyCode,
rowCount: result.rowCount,
});
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 출고 등록 (다건)
export async function create(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { items, outbound_number, outbound_date, warehouse_code, location_code, manager_id, memo } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "출고 품목이 없습니다." });
}
await client.query("BEGIN");
const insertedRows: any[] = [];
for (const item of items) {
const result = await client.query(
`INSERT INTO outbound_mng (
company_code, outbound_number, outbound_type, outbound_date,
reference_number, customer_code, customer_name,
item_code, item_name, specification, material, unit,
outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, manager_id, memo,
source_type, sales_order_id, shipment_plan_id, item_info_id,
destination_code, delivery_destination, delivery_address,
created_date, created_by, writer, status
) VALUES (
$1, $2, $3, $4,
$5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20, $21,
$22, $23, $24, $25,
$26, $27, $28,
NOW(), $29, $29, '출고'
) RETURNING *`,
[
companyCode,
outbound_number || item.outbound_number,
item.outbound_type,
outbound_date || item.outbound_date,
item.reference_number || null,
item.customer_code || null,
item.customer_name || null,
item.item_code || item.item_number || null,
item.item_name || null,
item.spec || item.specification || null,
item.material || null,
item.unit || "EA",
item.outbound_qty || 0,
item.unit_price || 0,
item.total_amount || 0,
item.lot_number || null,
warehouse_code || item.warehouse_code || null,
location_code || item.location_code || null,
item.outbound_status || "대기",
manager_id || item.manager_id || null,
memo || item.memo || null,
item.source_type || null,
item.sales_order_id || null,
item.shipment_plan_id || null,
item.item_info_id || null,
item.destination_code || null,
item.delivery_destination || null,
item.delivery_address || null,
userId,
]
);
insertedRows.push(result.rows[0]);
// 재고 업데이트 (inventory_stock): 출고 수량 차감
const itemCode = item.item_code || item.item_number || null;
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
const outQty = Number(item.outbound_qty) || 0;
if (itemCode && outQty > 0) {
const existingStock = await client.query(
`SELECT id FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
if (existingStock.rows.length > 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(GREATEST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) - $1, 0) AS text),
last_out_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[outQty, existingStock.rows[0].id]
);
} else {
// 재고 레코드가 없으면 0으로 생성 (마이너스 방지)
await client.query(
`INSERT INTO inventory_stock (
company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_out_date,
created_date, updated_date, writer
) VALUES ($1, $2, $3, $4, '0', '0', NOW(), NOW(), NOW(), $5)`,
[companyCode, itemCode, whCode, locCode, userId]
);
}
}
// 판매출고인 경우 출하지시의 ship_qty 업데이트
if (item.outbound_type === "판매출고" && item.source_id && item.source_type === "shipment_instruction_detail") {
await client.query(
`UPDATE shipment_instruction_detail
SET ship_qty = COALESCE(ship_qty, 0) + $1,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.outbound_qty || 0, item.source_id, companyCode]
);
}
}
await client.query("COMMIT");
logger.info("출고 등록 완료", {
companyCode,
userId,
count: insertedRows.length,
outbound_number,
});
return res.json({
success: true,
data: insertedRows,
message: `${insertedRows.length}건 출고 등록 완료`,
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("출고 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// 출고 수정
export async function update(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
const {
outbound_date, outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, manager_id: mgr, memo,
} = req.body;
const pool = getPool();
const result = await pool.query(
`UPDATE outbound_mng SET
outbound_date = COALESCE($1, outbound_date),
outbound_qty = COALESCE($2, outbound_qty),
unit_price = COALESCE($3, unit_price),
total_amount = COALESCE($4, total_amount),
lot_number = COALESCE($5, lot_number),
warehouse_code = COALESCE($6, warehouse_code),
location_code = COALESCE($7, location_code),
outbound_status = COALESCE($8, outbound_status),
manager_id = COALESCE($9, manager_id),
memo = COALESCE($10, memo),
updated_date = NOW(),
updated_by = $11
WHERE id = $12 AND company_code = $13
RETURNING *`,
[
outbound_date, outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, mgr, memo,
userId, id, companyCode,
]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "출고 데이터를 찾을 수 없습니다." });
}
logger.info("출고 수정", { companyCode, userId, id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("출고 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 출고 삭제
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM outbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
logger.info("출고 삭제", { companyCode, id });
return res.json({ success: true, message: "삭제 완료" });
} catch (error: any) {
logger.error("출고 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 판매출고용: 출하지시 데이터 조회
export async function getShipmentInstructions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["si.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
sid.id AS detail_id,
si.id AS instruction_id,
si.instruction_no,
si.instruction_date,
si.partner_id,
si.status AS instruction_status,
sid.item_code,
sid.item_name,
sid.spec,
sid.material,
COALESCE(sid.plan_qty, 0) AS plan_qty,
COALESCE(sid.ship_qty, 0) AS ship_qty,
COALESCE(sid.order_qty, 0) AS order_qty,
GREATEST(COALESCE(sid.plan_qty, 0) - COALESCE(sid.ship_qty, 0), 0) AS remain_qty,
sid.source_type
FROM shipment_instruction si
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id
AND si.company_code = sid.company_code
WHERE ${conditions.join(" AND ")}
AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0)
ORDER BY si.instruction_date DESC, si.instruction_no`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출하지시 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 반품출고용: 발주(입고) 데이터 조회
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
// 입고된 것만 (반품 대상)
conditions.push(
`COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) > 0`
);
if (keyword) {
conditions.push(
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
id, purchase_no, order_date, supplier_code, supplier_name,
item_code, item_name, spec, material,
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty,
COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price,
status, due_date
FROM purchase_order_mng
WHERE ${conditions.join(" AND ")}
ORDER BY order_date DESC, purchase_no`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("발주 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 기타출고용: 품목 데이터 조회
export async function getItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
id, item_number, item_name, size AS spec, material, unit,
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 출고번호 자동생성
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const today = new Date();
const yyyy = today.getFullYear();
const prefix = `OUT-${yyyy}-`;
const result = await pool.query(
`SELECT outbound_number FROM outbound_mng
WHERE company_code = $1 AND outbound_number LIKE $2
ORDER BY outbound_number DESC LIMIT 1`,
[companyCode, `${prefix}%`]
);
let seq = 1;
if (result.rows.length > 0) {
const lastNo = result.rows[0].outbound_number;
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
if (!isNaN(lastSeq)) seq = lastSeq + 1;
}
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
return res.json({ success: true, data: newNumber });
} catch (error: any) {
logger.error("출고번호 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 창고 목록 조회
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const result = await pool.query(
`SELECT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1 AND status != '삭제'
ORDER BY warehouse_name`,
[companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("창고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -173,7 +173,11 @@ export async function getPkgUnitItems(
const pool = getPool();
const result = await pool.query(
`SELECT * FROM pkg_unit_item WHERE pkg_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
`SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit
FROM pkg_unit_item pui
LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code
WHERE pui.pkg_code=$1 AND pui.company_code=$2
ORDER BY pui.created_date DESC`,
[pkgCode, companyCode]
);
@ -410,7 +414,11 @@ export async function getLoadingUnitPkgs(
const pool = getPool();
const result = await pool.query(
`SELECT * FROM loading_unit_pkg WHERE loading_code=$1 AND company_code=$2 ORDER BY created_date DESC`,
`SELECT lup.*, pu.pkg_name, pu.pkg_type
FROM loading_unit_pkg lup
LEFT JOIN pkg_unit pu ON lup.pkg_code = pu.pkg_code AND lup.company_code = pu.company_code
WHERE lup.loading_code=$1 AND lup.company_code=$2
ORDER BY lup.created_date DESC`,
[loadingCode, companyCode]
);
@ -476,3 +484,112 @@ export async function deleteLoadingUnitPkg(
res.status(500).json({ success: false, message: error.message });
}
}
// ──────────────────────────────────────────────
// 품목정보 연동 (division별 item_info 조회)
// ──────────────────────────────────────────────
export async function getItemsByDivision(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { divisionLabel } = req.params;
const { keyword } = req.query;
const pool = getPool();
// division 카테고리에서 해당 라벨의 코드 찾기
const catResult = await pool.query(
`SELECT value_code FROM category_values
WHERE table_name = 'item_info' AND column_name = 'division'
AND value_label = $1 AND company_code = $2
LIMIT 1`,
[divisionLabel, companyCode]
);
if (catResult.rows.length === 0) {
res.json({ success: true, data: [] });
return;
}
const divisionCode = catResult.rows[0].value_code;
const conditions: string[] = ["company_code = $1", `$2 = ANY(string_to_array(division, ','))`];
const params: any[] = [companyCode, divisionCode];
let paramIdx = 3;
if (keyword) {
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
params.push(`%${keyword}%`);
paramIdx++;
}
const result = await pool.query(
`SELECT id, item_number, item_name, size, material, unit, division
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name`,
params
);
logger.info(`품목 조회 (division=${divisionLabel})`, { companyCode, count: result.rowCount });
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}
// 일반 품목 조회 (포장재/적재함 제외, 매칭용)
export async function getGeneralItems(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const pool = getPool();
// 포장재/적재함 division 코드 조회
const catResult = await pool.query(
`SELECT value_code FROM category_values
WHERE table_name = 'item_info' AND column_name = 'division'
AND value_label IN ('포장재', '적재함') AND company_code = $1`,
[companyCode]
);
const excludeCodes = catResult.rows.map((r: any) => r.value_code);
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (excludeCodes.length > 0) {
// 다중 값(콤마 구분) 지원: 포장재/적재함 코드가 포함된 품목 제외
const excludeConditions = excludeCodes.map((_: any, i: number) => `$${paramIdx + i} = ANY(string_to_array(division, ','))`);
conditions.push(`(division IS NULL OR division = '' OR NOT (${excludeConditions.join(" OR ")}))`);
params.push(...excludeCodes);
paramIdx += excludeCodes.length;
}
if (keyword) {
conditions.push(`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`);
params.push(`%${keyword}%`);
paramIdx++;
}
const result = await pool.query(
`SELECT id, item_number, item_name, size AS spec, material, unit, division
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name
LIMIT 200`,
params
);
res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("일반 품목 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: error.message });
}
}

View File

@ -148,9 +148,9 @@ export async function getProcessEquipments(req: AuthenticatedRequest, res: Respo
const { processCode } = req.params;
const result = await pool.query(
`SELECT pe.*, ei.equipment_name
`SELECT pe.*, em.equipment_name
FROM process_equipment pe
LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code
LEFT JOIN equipment_mng em ON pe.equipment_code = em.equipment_code AND pe.company_code = em.company_code
WHERE pe.process_code = $1 AND pe.company_code = $2
ORDER BY pe.equipment_code`,
[processCode, companyCode]
@ -214,7 +214,7 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response)
const params = companyCode === "*" ? [] : [companyCode];
const result = await pool.query(
`SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`,
`SELECT objid AS id, equipment_code, equipment_name FROM equipment_mng ${condition} ORDER BY equipment_code`,
params
);

View File

@ -40,6 +40,28 @@ export async function getStockShortage(req: AuthenticatedRequest, res: Response)
}
}
// ─── 생산계획 목록 조회 ───
export async function getPlans(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { productType, status, startDate, endDate, itemCode } = req.query;
const data = await productionService.getPlans(companyCode, {
productType: productType as string,
status: status as string,
startDate: startDate as string,
endDate: endDate as string,
itemCode: itemCode as string,
});
return res.json({ success: true, data });
} catch (error: any) {
logger.error("생산계획 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 생산계획 상세 조회 ───
export async function getPlanById(req: AuthenticatedRequest, res: Response) {

View File

@ -170,6 +170,42 @@ export async function create(req: AuthenticatedRequest, res: Response) {
insertedRows.push(result.rows[0]);
// 재고 업데이트 (inventory_stock): 입고 수량 증가
const itemCode = item.item_number || null;
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
const inQty = Number(item.inbound_qty) || 0;
if (itemCode && inQty > 0) {
const existingStock = await client.query(
`SELECT id FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || '', locCode || '']
);
if (existingStock.rows.length > 0) {
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
last_in_date = NOW(),
updated_date = NOW()
WHERE id = $2`,
[inQty, existingStock.rows[0].id]
);
} else {
await client.query(
`INSERT INTO inventory_stock (
company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_in_date,
created_date, updated_date, writer
) VALUES ($1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)`,
[companyCode, itemCode, whCode, locCode, String(inQty), userId]
);
}
}
// 구매입고인 경우 발주의 received_qty 업데이트
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
await client.query(

View File

@ -472,6 +472,10 @@ export const addRoleMembers = async (
req.user?.userId || "SYSTEM"
);
// 권한 변경된 사용자들의 JWT 토큰 무효화
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
const response: ApiResponse<null> = {
success: true,
message: "권한 그룹 멤버 추가 성공",
@ -568,6 +572,13 @@ export const updateRoleMembers = async (
);
}
// 권한 변경된 사용자들의 JWT 토큰 무효화
const allAffectedUsers = [...new Set([...toAdd, ...toRemove])];
if (allAffectedUsers.length > 0) {
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateMultipleUserTokens(allAffectedUsers);
}
logger.info("권한 그룹 멤버 일괄 업데이트 성공", {
masterObjid,
added: toAdd.length,
@ -646,6 +657,10 @@ export const removeRoleMembers = async (
req.user?.userId || "SYSTEM"
);
// 권한 변경된 사용자들의 JWT 토큰 무효화
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateMultipleUserTokens(userIds);
const response: ApiResponse<null> = {
success: true,
message: "권한 그룹 멤버 제거 성공",
@ -777,6 +792,18 @@ export const setMenuPermissions = async (
req.user?.userId || "SYSTEM"
);
// 해당 권한 그룹의 모든 멤버 JWT 토큰 무효화
try {
const members = await RoleService.getRoleMembers(authObjid);
const memberIds = members.map((m: any) => m.userId);
if (memberIds.length > 0) {
const { TokenInvalidationService } = require("../services/tokenInvalidationService");
await TokenInvalidationService.invalidateMultipleUserTokens(memberIds);
}
} catch (invalidateError) {
logger.warn("메뉴 권한 변경 후 토큰 무효화 실패 (권한 설정은 성공)", { invalidateError });
}
const response: ApiResponse<null> = {
success: true,
message: "메뉴 권한 설정 성공",

View File

@ -5,6 +5,7 @@ import { Request, Response, NextFunction } from "express";
import { JwtUtils } from "../utils/jwtUtils";
import { AuthenticatedRequest, PersonBean } from "../types/auth";
import { logger } from "../utils/logger";
import { TokenInvalidationService } from "../services/tokenInvalidationService";
// AuthenticatedRequest 타입을 다른 모듈에서 사용할 수 있도록 re-export
export { AuthenticatedRequest } from "../types/auth";
@ -22,11 +23,11 @@ declare global {
* JWT
*
*/
export const authenticateToken = (
export const authenticateToken = async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
): Promise<void> => {
try {
// Authorization 헤더에서 토큰 추출
const authHeader = req.get("Authorization");
@ -46,6 +47,25 @@ export const authenticateToken = (
// JWT 토큰 검증 및 사용자 정보 추출
const userInfo: PersonBean = JwtUtils.verifyToken(token);
// token_version 검증 (JWT payload vs DB)
const decoded = JwtUtils.decodeToken(token);
const tokenVersion = decoded?.tokenVersion;
// tokenVersion이 undefined면 구버전 토큰이므로 통과 (하위 호환)
if (tokenVersion !== undefined) {
const dbVersion = await TokenInvalidationService.getUserTokenVersion(userInfo.userId);
if (tokenVersion !== dbVersion) {
res.status(401).json({
success: false,
error: {
code: "TOKEN_INVALIDATED",
details: "보안 정책에 의해 재로그인이 필요합니다.",
},
});
return;
}
}
// 요청 객체에 사용자 정보 설정 (기존 PersonBean과 동일)
req.user = userInfo;
@ -173,11 +193,11 @@ export const requireUserOrAdmin = (targetUserId: string) => {
*
*
*/
export const refreshTokenIfNeeded = (
export const refreshTokenIfNeeded = async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
): void => {
): Promise<void> => {
try {
const authHeader = req.get("Authorization");
const token = authHeader && authHeader.split(" ")[1];
@ -191,6 +211,16 @@ export const refreshTokenIfNeeded = (
// 1시간(3600초) 이내에 만료되는 경우 갱신
if (timeUntilExpiry > 0 && timeUntilExpiry < 3600) {
// 갱신 전 token_version 검증
if (decoded.tokenVersion !== undefined) {
const dbVersion = await TokenInvalidationService.getUserTokenVersion(decoded.userId);
if (decoded.tokenVersion !== dbVersion) {
// 무효화된 토큰은 갱신하지 않음
next();
return;
}
}
const newToken = JwtUtils.refreshToken(token);
// 새로운 토큰을 응답 헤더에 포함

View File

@ -21,6 +21,7 @@ import {
saveUser, // 사용자 등록/수정
saveUserWithDept, // 사원 + 부서 통합 저장 (NEW!)
getUserWithDept, // 사원 + 부서 조회 (NEW!)
deleteUser, // 사용자 삭제 (soft delete)
getCompanyList,
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
getCompanyByCode, // 회사 단건 조회
@ -62,6 +63,7 @@ router.put("/users/:userId", saveUser); // 사용자 수정 (REST API)
router.put("/profile", updateProfile); // 프로필 수정
router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크
router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화
router.delete("/users/:userId", deleteUser); // 사용자 삭제 (soft delete)
// 부서 관리 API
router.get("/departments", getDepartmentList); // 부서 목록 조회

View File

@ -55,6 +55,15 @@ router.get(
entityJoinController.getTableDataWithJoins.bind(entityJoinController)
);
/**
* ( )
* GET /api/table-management/tables/:tableName/column-values/:columnName
*/
router.get(
"/tables/:tableName/column-values/:columnName",
entityJoinController.getColumnUniqueValues.bind(entityJoinController)
);
// ========================================
// 🎯 Entity 조인 설정 관리
// ========================================

View File

@ -0,0 +1,40 @@
/**
*
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as outboundController from "../controllers/outboundController";
const router = Router();
router.use(authenticateToken);
// 출고 목록 조회
router.get("/list", outboundController.getList);
// 출고번호 자동생성
router.get("/generate-number", outboundController.generateNumber);
// 창고 목록 조회
router.get("/warehouses", outboundController.getWarehouses);
// 소스 데이터: 출하지시 (판매출고)
router.get("/source/shipment-instructions", outboundController.getShipmentInstructions);
// 소스 데이터: 발주 (반품출고)
router.get("/source/purchase-orders", outboundController.getPurchaseOrders);
// 소스 데이터: 품목 (기타출고)
router.get("/source/items", outboundController.getItems);
// 출고 등록
router.post("/", outboundController.create);
// 출고 수정
router.put("/:id", outboundController.update);
// 출고 삭제
router.delete("/:id", outboundController.deleteOutbound);
export default router;

View File

@ -5,6 +5,7 @@ import {
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
getItemsByDivision, getGeneralItems,
} from "../controllers/packagingController";
const router = Router();
@ -33,4 +34,8 @@ router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
// 품목정보 연동 (division별)
router.get("/items/general", getGeneralItems);
router.get("/items/:divisionLabel", getItemsByDivision);
export default router;

View File

@ -16,6 +16,9 @@ router.get("/order-summary", productionController.getOrderSummary);
// 안전재고 부족분 조회
router.get("/stock-shortage", productionController.getStockShortage);
// 생산계획 목록 조회
router.get("/plans", productionController.getPlans);
// 생산계획 CRUD
router.get("/plan/:id", productionController.getPlanById);
router.put("/plan/:id", productionController.updatePlan);

View File

@ -24,7 +24,8 @@ export type AuditAction =
| "STATUS_CHANGE"
| "BATCH_CREATE"
| "BATCH_UPDATE"
| "BATCH_DELETE";
| "BATCH_DELETE"
| "DEPT_CHANGE_WARNING";
export type AuditResourceType =
| "MENU"

View File

@ -134,12 +134,14 @@ export class AuthService {
company_code: string | null;
locale: string | null;
photo: Buffer | null;
token_version: number | null;
}>(
`SELECT
sabun, user_id, user_name, user_name_eng, user_name_cn,
dept_code, dept_name, position_code, position_name,
email, tel, cell_phone, user_type, user_type_name,
partner_objid, company_code, locale, photo
partner_objid, company_code, locale, photo,
COALESCE(token_version, 0) as token_version
FROM user_info
WHERE user_id = $1`,
[userId]
@ -210,6 +212,7 @@ export class AuthService {
? `data:image/jpeg;base64,${Buffer.from(userInfo.photo).toString("base64")}`
: undefined,
locale: userInfo.locale || "KR",
tokenVersion: userInfo.token_version ?? 0,
// 권한 레벨 정보 추가 (3단계 체계)
isSuperAdmin: companyCode === "*" && userType === "SUPER_ADMIN",
isCompanyAdmin: userType === "COMPANY_ADMIN" && companyCode !== "*",

View File

@ -673,6 +673,22 @@ export class MultiLangService {
}
}
/**
* ( )
*/
async getKeyCompanyCode(keyId: number): Promise<string | null> {
try {
const result = await queryOne<{ company_code: string }>(
`SELECT company_code FROM multi_lang_key_master WHERE key_id = $1`,
[keyId]
);
return result?.company_code || null;
} catch (error) {
logger.error("키 소유 회사 코드 조회 실패:", error);
return null;
}
}
/**
*
*/
@ -688,6 +704,10 @@ export class MultiLangService {
if (params.companyCode) {
whereConditions.push(`company_code = $${paramIndex++}`);
values.push(params.companyCode);
} else if (params.userCompanyCode && params.userCompanyCode !== "*") {
// 비관리자: companyCode 필터가 없으면 자기 회사 + 공통(*) 키만 반환
whereConditions.push(`company_code IN ($${paramIndex++}, '*')`);
values.push(params.userCompanyCode);
}
// 메뉴 코드 필터

View File

@ -35,6 +35,33 @@ export async function getOrderSummary(
const whereClause = conditions.join(" AND ");
// item_info에 lead_time 컬럼이 존재하는지 확인
const leadTimeColCheck = await pool.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'item_info' AND column_name = 'lead_time'
) AS has_lead_time
`);
const hasLeadTime = leadTimeColCheck.rows[0]?.has_lead_time === true;
const itemLeadTimeCte = hasLeadTime
? `item_lead_time AS (
SELECT
item_number,
id AS item_id,
COALESCE(lead_time::int, 0) AS lead_time
FROM item_info
WHERE company_code = $1
),`
: `item_lead_time AS (
SELECT
item_number,
id AS item_id,
0 AS lead_time
FROM item_info
WHERE company_code = $1
),`;
const query = `
WITH order_summary AS (
SELECT
@ -49,6 +76,7 @@ export async function getOrderSummary(
WHERE ${whereClause}
GROUP BY so.part_code, so.part_name
),
${itemLeadTimeCte}
stock_info AS (
SELECT
item_code,
@ -85,10 +113,12 @@ export async function getOrderSummary(
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
0
) AS required_plan_qty
) AS required_plan_qty,
COALESCE(ilt.lead_time, 0) AS lead_time
FROM order_summary os
LEFT JOIN stock_info si ON os.item_code = si.item_code
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
LEFT JOIN item_lead_time ilt ON (os.item_code = ilt.item_number OR os.item_code = ilt.item_id)
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
ORDER BY os.item_code;
`;
@ -155,6 +185,80 @@ export async function getStockShortage(companyCode: string) {
return result.rows;
}
// ─── 생산계획 목록 조회 ───
export async function getPlans(
companyCode: string,
options?: {
productType?: string;
status?: string;
startDate?: string;
endDate?: string;
itemCode?: string;
}
) {
const pool = getPool();
const conditions: string[] = ["p.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (companyCode !== "*") {
// 일반 회사: 자사 데이터만
} else {
// 최고관리자: 전체 데이터 (company_code 조건 제거)
conditions.length = 0;
}
if (options?.productType) {
conditions.push(`COALESCE(p.product_type, '완제품') = $${paramIdx}`);
params.push(options.productType);
paramIdx++;
}
if (options?.status && options.status !== "all") {
conditions.push(`p.status = $${paramIdx}`);
params.push(options.status);
paramIdx++;
}
if (options?.startDate) {
conditions.push(`p.end_date >= $${paramIdx}::date`);
params.push(options.startDate);
paramIdx++;
}
if (options?.endDate) {
conditions.push(`p.start_date <= $${paramIdx}::date`);
params.push(options.endDate);
paramIdx++;
}
if (options?.itemCode) {
conditions.push(`(p.item_code ILIKE $${paramIdx} OR p.item_name ILIKE $${paramIdx})`);
params.push(`%${options.itemCode}%`);
paramIdx++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
p.id, p.company_code, p.plan_no, p.plan_date,
p.item_code, p.item_name, p.product_type,
p.plan_qty, p.completed_qty, p.progress_rate,
p.start_date, p.end_date, p.due_date,
p.equipment_id, p.equipment_code, p.equipment_name,
p.status, p.priority, p.work_shift,
p.work_order_no, p.manager_name,
p.order_no, p.parent_plan_id, p.remarks,
p.hourly_capacity, p.daily_capacity, p.lead_time,
p.created_date, p.updated_date
FROM production_plan_mng p
${whereClause}
ORDER BY p.start_date ASC, p.item_code ASC
`;
const result = await pool.query(query, params);
logger.info("생산계획 목록 조회", { companyCode, count: result.rowCount });
return result.rows;
}
// ─── 생산계획 CRUD ───
export async function getPlanById(companyCode: string, planId: number) {
@ -267,49 +371,81 @@ export async function previewSchedule(
const deletedSchedules: any[] = [];
const keptSchedules: any[] = [];
for (const item of items) {
if (options.recalculate_unstarted) {
// 삭제 대상(planned) 상세 조회
// 같은 item_code에 대한 삭제/유지 조회는 한 번만 수행
if (options.recalculate_unstarted) {
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
for (const itemCode of uniqueItemCodes) {
const deleteResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
deletedSchedules.push(...deleteResult.rows);
// 유지 대상(진행중 등) 상세 조회
const keptResult = await pool.query(
`SELECT id, plan_no, item_code, item_name, plan_qty, start_date, end_date, status, completed_qty
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status NOT IN ('planned', 'completed', 'cancelled')`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
keptSchedules.push(...keptResult.rows);
}
}
for (const item of items) {
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
const itemLeadTime = item.lead_time || 0;
let requiredQty = item.required_qty;
// recalculate_unstarted 시, 삭제된 수량을 비율로 분배
if (options.recalculate_unstarted) {
const deletedQtyForItem = deletedSchedules
.filter((d: any) => d.item_code === item.item_code)
.reduce((sum: number, d: any) => sum + (parseFloat(d.plan_qty) || 0), 0);
if (deletedQtyForItem > 0) {
const totalRequestedForItem = items
.filter((i) => i.item_code === item.item_code)
.reduce((sum, i) => sum + i.required_qty, 0);
if (totalRequestedForItem > 0) {
requiredQty += Math.round(deletedQtyForItem * (item.required_qty / totalRequestedForItem));
}
}
}
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
let startDate: Date;
let endDate: Date;
if (itemLeadTime > 0) {
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
endDate = new Date(dueDate);
startDate = new Date(dueDate);
startDate.setDate(startDate.getDate() - itemLeadTime);
} else {
// 리드타임이 없으면 기존 로직 (생산능력 기반)
const productionDays = Math.ceil(requiredQty / dailyCapacity);
endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
}
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays);
endDate.setDate(endDate.getDate() + duration);
}
// 해당 품목의 수주 건수 확인
@ -326,10 +462,11 @@ export async function previewSchedule(
required_qty: requiredQty,
daily_capacity: dailyCapacity,
hourly_capacity: item.hourly_capacity || 100,
production_days: productionDays,
production_days: itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity),
start_date: startDate.toISOString().split("T")[0],
end_date: endDate.toISOString().split("T")[0],
due_date: item.earliest_due_date,
lead_time: itemLeadTime,
order_count: orderCount,
status: "planned",
});
@ -343,7 +480,7 @@ export async function previewSchedule(
};
logger.info("자동 스케줄 미리보기", { companyCode, summary });
return { summary, previews, deletedSchedules, keptSchedules };
return { summary, schedules: previews, deletedSchedules, keptSchedules };
}
export async function generateSchedule(
@ -363,10 +500,22 @@ export async function generateSchedule(
let deletedCount = 0;
let keptCount = 0;
const newSchedules: any[] = [];
const deletedQtyByItem = new Map<string, number>();
// 같은 item_code에 대한 삭제는 한 번만 수행
if (options.recalculate_unstarted) {
const uniqueItemCodes = [...new Set(items.map((i) => i.item_code))];
for (const itemCode of uniqueItemCodes) {
const deletedQtyResult = await client.query(
`SELECT COALESCE(SUM(COALESCE(plan_qty::numeric, 0)), 0) AS deleted_qty
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`,
[companyCode, itemCode, productType]
);
deletedQtyByItem.set(itemCode, parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0);
for (const item of items) {
// 기존 미진행(planned) 스케줄 처리
if (options.recalculate_unstarted) {
const deleteResult = await client.query(
`DELETE FROM production_plan_mng
WHERE company_code = $1
@ -374,7 +523,7 @@ export async function generateSchedule(
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'
RETURNING id`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
deletedCount += deleteResult.rowCount || 0;
@ -384,32 +533,58 @@ export async function generateSchedule(
AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status NOT IN ('planned', 'completed', 'cancelled')`,
[companyCode, item.item_code, productType]
[companyCode, itemCode, productType]
);
keptCount += parseInt(keptResult.rows[0].cnt, 10);
}
}
// 생산일수 계산
for (const item of items) {
// 필요 수량 계산 (삭제된 planned 수량을 비율로 분배)
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
const itemLeadTime = item.lead_time || 0;
let requiredQty = item.required_qty;
if (options.recalculate_unstarted) {
const deletedQty = deletedQtyByItem.get(item.item_code) || 0;
if (deletedQty > 0) {
const totalRequestedForItem = items
.filter((i) => i.item_code === item.item_code)
.reduce((sum, i) => sum + i.required_qty, 0);
if (totalRequestedForItem > 0) {
requiredQty += Math.round(deletedQty * (item.required_qty / totalRequestedForItem));
}
}
}
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
// 시작일 = 납기일 - 생산일수 - 안전리드타임
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
let startDate: Date;
let endDate: Date;
if (itemLeadTime > 0) {
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
endDate = new Date(dueDate);
startDate = new Date(dueDate);
startDate.setDate(startDate.getDate() - itemLeadTime);
} else {
// 리드타임이 없으면 기존 로직 (생산능력 기반)
const productionDays = Math.ceil(requiredQty / dailyCapacity);
endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
}
// 시작일이 오늘보다 이전이면 오늘로 조정
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays);
endDate.setDate(endDate.getDate() + duration);
}
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
@ -576,13 +751,24 @@ async function getBomChildItems(
companyCode: string,
itemCode: string
) {
// item_info에 lead_time 컬럼 존재 여부 확인
const colCheck = await client.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'item_info' AND column_name = 'lead_time'
) AS has_lead_time
`);
const hasLeadTime = colCheck.rows[0]?.has_lead_time === true;
const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time::int, 0)" : "0";
const bomQuery = `
SELECT
bd.child_item_id,
ii.item_name AS child_item_name,
ii.item_number AS child_item_code,
bd.quantity AS bom_qty,
bd.unit
bd.unit,
${leadTimeCol} AS child_lead_time
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND bd.company_code = ii.company_code
@ -641,9 +827,12 @@ export async function previewSemiSchedule(
if (requiredQty <= 0) continue;
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
const semiDueDate = plan.start_date;
const semiEndDate = new Date(plan.start_date);
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
previews.push({
parent_plan_id: plan.id,
@ -653,13 +842,14 @@ export async function previewSemiSchedule(
item_name: bomItem.child_item_name || bomItem.child_item_id,
plan_qty: requiredQty,
bom_qty: parseFloat(bomItem.bom_qty) || 1,
lead_time: childLeadTime,
start_date: semiStartDate.toISOString().split("T")[0],
end_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
: semiEndDate.toISOString().split("T")[0],
due_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
: semiEndDate.toISOString().split("T")[0],
product_type: "반제품",
status: "planned",
});
@ -683,7 +873,7 @@ export async function previewSemiSchedule(
parent_count: plansResult.rowCount,
};
return { summary, previews, deletedSchedules, keptSchedules };
return { summary, schedules: previews, deletedSchedules, keptSchedules };
}
// ─── 반제품 계획 자동 생성 ───
@ -740,10 +930,12 @@ export async function generateSemiSchedule(
if (requiredQty <= 0) continue;
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
const semiDueDate = plan.start_date;
const semiEndDate = plan.start_date;
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
const planNoResult = await client.query(

View File

@ -1,4 +1,4 @@
import { query } from "../database/db";
import { query, transaction } from "../database/db";
import { logger } from "../utils/logger";
/**
@ -145,10 +145,19 @@ export class RoleService {
writer: string;
}): Promise<RoleGroup> {
try {
// 동일 회사 내 같은 이름의 권한 그룹 중복 체크
const dupCheck = await query<{ count: string }>(
`SELECT COUNT(*) AS count FROM authority_master WHERE company_code = $1 AND auth_name = $2`,
[data.companyCode, data.authName]
);
if (dupCheck.length > 0 && parseInt(dupCheck[0].count, 10) > 0) {
throw new Error(`동일 회사 내에 이미 같은 이름의 권한 그룹이 존재합니다: ${data.authName}`);
}
const sql = `
INSERT INTO authority_master (objid, auth_name, auth_code, company_code, status, writer, regdate)
VALUES (nextval('seq_authority_master'), $1, $2, $3, 'active', $4, NOW())
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
RETURNING objid, auth_name AS "authName", auth_code AS "authCode",
company_code AS "companyCode", status, writer, regdate
`;
@ -460,35 +469,37 @@ export class RoleService {
writer: string
): Promise<void> {
try {
// 기존 권한 삭제
await query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
authObjid,
]);
// 새로운 권한 삽입
if (permissions.length > 0) {
const values = permissions
.map(
(_, index) =>
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
)
.join(", ");
const params = permissions.flatMap((p) => [
p.menuObjid,
p.createYn,
p.readYn,
p.updateYn,
p.deleteYn,
await transaction(async (client) => {
// 기존 권한 삭제
await client.query("DELETE FROM rel_menu_auth WHERE auth_objid = $1", [
authObjid,
]);
const sql = `
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
VALUES ${values}
`;
// 새로운 권한 삽입
if (permissions.length > 0) {
const values = permissions
.map(
(_, index) =>
`(nextval('seq_rel_menu_auth'), $${index * 5 + 2}, $1, $${index * 5 + 3}, $${index * 5 + 4}, $${index * 5 + 5}, $${index * 5 + 6}, $${permissions.length * 5 + 2}, NOW())`
)
.join(", ");
await query(sql, [authObjid, ...params, writer]);
}
const params = permissions.flatMap((p) => [
p.menuObjid,
p.createYn,
p.readYn,
p.updateYn,
p.deleteYn,
]);
const sql = `
INSERT INTO rel_menu_auth (objid, menu_objid, auth_objid, create_yn, read_yn, update_yn, delete_yn, writer, regdate)
VALUES ${values}
`;
await client.query(sql, [authObjid, ...params, writer]);
}
});
logger.info("메뉴 권한 설정 성공", {
authObjid,

View File

@ -211,7 +211,8 @@ class TableCategoryValueService {
created_at AS "createdAt",
updated_at AS "updatedAt",
created_by AS "createdBy",
updated_by AS "updatedBy"
updated_by AS "updatedBy",
path
FROM category_values
WHERE table_name = $1
AND column_name = $2
@ -1441,7 +1442,7 @@ class TableCategoryValueService {
// is_active 필터 제거: 비활성화된 카테고리도 라벨로 표시되어야 함
if (companyCode === "*") {
query = `
SELECT DISTINCT value_code, value_label
SELECT DISTINCT value_code, value_label, path
FROM category_values
WHERE value_code IN (${placeholders1})
`;
@ -1449,7 +1450,7 @@ class TableCategoryValueService {
} else {
const companyIdx = n + 1;
query = `
SELECT DISTINCT value_code, value_label
SELECT DISTINCT value_code, value_label, path
FROM category_values
WHERE value_code IN (${placeholders1})
AND (company_code = $${companyIdx} OR company_code = '*')
@ -1460,10 +1461,15 @@ class TableCategoryValueService {
const result = await pool.query(query, params);
// { [code]: label } 형태로 변환 (중복 시 첫 번째 결과 우선)
// path가 있고 '/'를 포함하면(depth>1) 전체 경로를 ' > ' 구분자로 표시
const labels: Record<string, string> = {};
for (const row of result.rows) {
if (!labels[row.value_code]) {
labels[row.value_code] = row.value_label;
if (row.path && row.path.includes('/')) {
labels[row.value_code] = row.path.replace(/\//g, ' > ');
} else {
labels[row.value_code] = row.value_label;
}
}
}

View File

@ -1575,7 +1575,7 @@ export class TableManagementService {
switch (operator) {
case "equals":
return {
whereClause: `${columnName}::text = $${paramIndex}`,
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
values: [actualValue],
paramCount: 1,
};
@ -1859,10 +1859,10 @@ export class TableManagementService {
};
}
// select 필터(equals)인 경우 정확한 코드값 매칭만 수행
// select 필터(equals)인 경우 — 다중 값(콤마 구분) 지원
if (operator === "equals") {
return {
whereClause: `${columnName}::text = $${paramIndex}`,
whereClause: `($${paramIndex} = ANY(string_to_array(${columnName}::text, ',')) OR ${columnName}::text = $${paramIndex})`,
values: [String(value)],
paramCount: 1,
};
@ -2717,6 +2717,43 @@ export class TableManagementService {
logger.warn(`채번 자동 적용 중 오류 (무시됨): ${numErr.message}`);
}
// entity 컬럼의 display_column 자동 채우기 (예: supplier_code → supplier_name)
try {
const companyCode = data.company_code || "*";
const entityColsResult = await query<any>(
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'entity'
AND reference_table IS NOT NULL AND reference_table != ''
AND display_column IS NOT NULL AND display_column != ''
AND company_code IN ($2, '*')
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
[tableName, companyCode]
);
for (const ec of entityColsResult) {
const srcVal = data[ec.column_name];
const displayCol = ec.display_column;
// display_column이 테이블에 존재하고, 값이 비어있거나 없으면 자동 조회
if (srcVal && columnTypeMap.has(displayCol) && (!data[displayCol] || data[displayCol] === "")) {
try {
const refResult = await query<any>(
`SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`,
[srcVal, companyCode]
);
if (refResult.length > 0 && refResult[0][displayCol]) {
data[displayCol] = refResult[0][displayCol];
logger.info(`Entity auto-fill: ${tableName}.${displayCol} = ${data[displayCol]} (from ${ec.reference_table})`);
}
} catch (refErr: any) {
logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`);
}
}
}
} catch (entityErr: any) {
logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`);
}
// 🆕 테이블에 존재하는 컬럼만 필터링 (존재하지 않는 컬럼은 무시)
const skippedColumns: string[] = [];
const existingColumns = Object.keys(data).filter((col) => {
@ -2868,6 +2905,42 @@ export class TableManagementService {
logger.info(`updated_date 자동 추가: ${updatedData.updated_date}`);
}
// entity 컬럼의 display_column 자동 채우기 (수정 시)
try {
const companyCode = updatedData.company_code || originalData.company_code || "*";
const entityColsResult = await query<any>(
`SELECT DISTINCT ON (column_name) column_name, reference_table, reference_column, display_column
FROM table_type_columns
WHERE table_name = $1 AND input_type = 'entity'
AND reference_table IS NOT NULL AND reference_table != ''
AND display_column IS NOT NULL AND display_column != ''
AND company_code IN ($2, '*')
ORDER BY column_name, CASE WHEN company_code = '*' THEN 1 ELSE 0 END`,
[tableName, companyCode]
);
for (const ec of entityColsResult) {
const srcVal = updatedData[ec.column_name];
const displayCol = ec.display_column;
if (srcVal && columnTypeMap.has(displayCol) && (!updatedData[displayCol] || updatedData[displayCol] === "")) {
try {
const refResult = await query<any>(
`SELECT "${displayCol}" FROM "${ec.reference_table}" WHERE "${ec.reference_column}" = $1 AND company_code = $2 LIMIT 1`,
[srcVal, companyCode]
);
if (refResult.length > 0 && refResult[0][displayCol]) {
updatedData[displayCol] = refResult[0][displayCol];
logger.info(`Entity auto-fill (edit): ${tableName}.${displayCol} = ${updatedData[displayCol]}`);
}
} catch (refErr: any) {
logger.warn(`Entity auto-fill 조회 실패 (${ec.reference_table}.${displayCol}): ${refErr.message}`);
}
}
}
} catch (entityErr: any) {
logger.warn(`Entity auto-fill 중 오류 (무시됨): ${entityErr.message}`);
}
// SET 절 생성 (수정할 데이터) - 먼저 생성
// 🔧 테이블에 존재하는 컬럼만 UPDATE (가상 컬럼 제외)
const setConditions: string[] = [];
@ -3357,16 +3430,20 @@ export class TableManagementService {
const safeColumn = `main."${columnName}"`;
switch (operator) {
case "equals":
case "equals": {
const safeVal = String(value).replace(/'/g, "''");
filterConditions.push(
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
`('${safeVal}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal}')`
);
break;
case "not_equals":
}
case "not_equals": {
const safeVal2 = String(value).replace(/'/g, "''");
filterConditions.push(
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
`NOT ('${safeVal2}' = ANY(string_to_array(${safeColumn}::text, ',')) OR ${safeColumn}::text = '${safeVal2}')`
);
break;
}
case "in": {
const inArr = Array.isArray(value) ? value : value != null && value !== "" ? [String(value)] : [];
if (inArr.length > 0) {
@ -3408,6 +3485,31 @@ export class TableManagementService {
case "is_not_null":
filterConditions.push(`${safeColumn} IS NOT NULL`);
break;
case "not_contains":
filterConditions.push(
`${safeColumn}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`
);
break;
case "greater_than":
filterConditions.push(
`(${safeColumn})::numeric > ${parseFloat(String(value))}`
);
break;
case "less_than":
filterConditions.push(
`(${safeColumn})::numeric < ${parseFloat(String(value))}`
);
break;
case "greater_or_equal":
filterConditions.push(
`(${safeColumn})::numeric >= ${parseFloat(String(value))}`
);
break;
case "less_or_equal":
filterConditions.push(
`(${safeColumn})::numeric <= ${parseFloat(String(value))}`
);
break;
}
}
@ -3424,6 +3526,89 @@ export class TableManagementService {
}
}
// 🆕 filterGroups 처리 (런타임 필터 빌더 - 그룹별 AND/OR 지원)
if (
options.dataFilter &&
options.dataFilter.filterGroups &&
options.dataFilter.filterGroups.length > 0
) {
const groupConditions: string[] = [];
for (const group of options.dataFilter.filterGroups) {
if (!group.conditions || group.conditions.length === 0) continue;
const conditions: string[] = [];
for (const condition of group.conditions) {
const { columnName, operator, value } = condition;
if (!columnName) continue;
const safeCol = `main."${columnName}"`;
switch (operator) {
case "equals":
conditions.push(`${safeCol}::text = '${String(value).replace(/'/g, "''")}'`);
break;
case "not_equals":
conditions.push(`${safeCol}::text != '${String(value).replace(/'/g, "''")}'`);
break;
case "contains":
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}%'`);
break;
case "not_contains":
conditions.push(`${safeCol}::text NOT LIKE '%${String(value).replace(/'/g, "''")}%'`);
break;
case "starts_with":
conditions.push(`${safeCol}::text LIKE '${String(value).replace(/'/g, "''")}%'`);
break;
case "ends_with":
conditions.push(`${safeCol}::text LIKE '%${String(value).replace(/'/g, "''")}'`);
break;
case "greater_than":
conditions.push(`(${safeCol})::numeric > ${parseFloat(String(value))}`);
break;
case "less_than":
conditions.push(`(${safeCol})::numeric < ${parseFloat(String(value))}`);
break;
case "greater_or_equal":
conditions.push(`(${safeCol})::numeric >= ${parseFloat(String(value))}`);
break;
case "less_or_equal":
conditions.push(`(${safeCol})::numeric <= ${parseFloat(String(value))}`);
break;
case "is_null":
conditions.push(`(${safeCol} IS NULL OR ${safeCol}::text = '')`);
break;
case "is_not_null":
conditions.push(`(${safeCol} IS NOT NULL AND ${safeCol}::text != '')`);
break;
case "in": {
const inArr = Array.isArray(value) ? value : [String(value)];
if (inArr.length > 0) {
const vals = inArr.map((v) => `'${String(v).replace(/'/g, "''")}'`).join(", ");
conditions.push(`${safeCol}::text IN (${vals})`);
}
break;
}
}
}
if (conditions.length > 0) {
const logic = group.logic === "OR" ? " OR " : " AND ";
groupConditions.push(`(${conditions.join(logic)})`);
}
}
if (groupConditions.length > 0) {
const groupWhere = groupConditions.join(" AND ");
whereClause = whereClause
? `${whereClause} AND ${groupWhere}`
: groupWhere;
logger.info(`🔍 필터 그룹 적용 (Entity 조인): ${groupWhere}`);
}
}
// 🆕 제외 필터 적용 (다른 테이블에 이미 존재하는 데이터 제외)
if (options.excludeFilter && options.excludeFilter.enabled) {
const {
@ -5387,4 +5572,40 @@ export class TableManagementService {
return [];
}
}
/**
* ( )
*/
async getColumnDistinctValues(
tableName: string,
columnName: string,
companyCode?: string
): Promise<{ value: string; label: string }[]> {
try {
// 테이블명/컬럼명 안전성 검증 (영문, 숫자, 언더스코어만 허용)
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(tableName) || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(columnName)) {
logger.warn(`잘못된 테이블/컬럼명: ${tableName}.${columnName}`);
return [];
}
let sql = `SELECT DISTINCT "${columnName}"::text as value FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND "${columnName}"::text != ''`;
const params: any[] = [];
if (companyCode) {
params.push(companyCode);
sql += ` AND "company_code" = $${params.length}`;
}
sql += ` ORDER BY value LIMIT 500`;
const rows = await query<{ value: string }>(sql, params);
return rows.map((row) => ({
value: row.value,
label: row.value,
}));
} catch (error) {
logger.error(`컬럼 고유값 조회 실패: ${tableName}.${columnName}`, error);
return [];
}
}
}

View File

@ -0,0 +1,75 @@
// JWT 토큰 무효화 서비스
// user_info.token_version 기반으로 기존 JWT 토큰을 무효화
import { query } from "../database/db";
import { cache } from "../utils/cache";
import { logger } from "../utils/logger";
const TOKEN_VERSION_CACHE_TTL = 2 * 60 * 1000; // 2분 캐시
export class TokenInvalidationService {
/**
*
*/
static cacheKey(userId: string): string {
return `token_version:${userId}`;
}
/**
* (token_version +1)
*/
static async invalidateUserTokens(userId: string): Promise<void> {
try {
await query(
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id = $1`,
[userId]
);
cache.delete(this.cacheKey(userId));
logger.info(`토큰 무효화: ${userId}`);
} catch (error) {
logger.error(`토큰 무효화 실패: ${userId}`, { error });
}
}
/**
*
*/
static async invalidateMultipleUserTokens(userIds: string[]): Promise<void> {
if (userIds.length === 0) return;
try {
const placeholders = userIds.map((_, i) => `$${i + 1}`).join(", ");
await query(
`UPDATE user_info SET token_version = COALESCE(token_version, 0) + 1 WHERE user_id IN (${placeholders})`,
userIds
);
userIds.forEach((id) => cache.delete(this.cacheKey(id)));
logger.info(`토큰 일괄 무효화: ${userIds.length}`);
} catch (error) {
logger.error(`토큰 일괄 무효화 실패`, { error, userIds });
}
}
/**
* token_version ( )
*/
static async getUserTokenVersion(userId: string): Promise<number> {
const cacheKey = this.cacheKey(userId);
const cached = cache.get<number>(cacheKey);
if (cached !== null) {
return cached;
}
try {
const result = await query<{ token_version: number | null }>(
`SELECT token_version FROM user_info WHERE user_id = $1`,
[userId]
);
const version = result.length > 0 ? (result[0].token_version ?? 0) : 0;
cache.set(cacheKey, version, TOKEN_VERSION_CACHE_TTL);
return version;
} catch (error) {
logger.error(`token_version 조회 실패: ${userId}`, { error });
return 0;
}
}
}

View File

@ -64,6 +64,7 @@ export interface PersonBean {
companyName?: string; // 회사명 추가
photo?: string;
locale?: string;
tokenVersion?: number; // JWT 토큰 무효화용 버전
// 권한 레벨 정보 (3단계 체계)
isSuperAdmin?: boolean; // 최고 관리자 (company_code === '*' && userType === 'SUPER_ADMIN')
isCompanyAdmin?: boolean; // 회사 관리자 (userType === 'COMPANY_ADMIN')
@ -98,6 +99,7 @@ export interface JwtPayload {
companyName?: string; // 회사명 추가
userType?: string;
userTypeName?: string;
tokenVersion?: number; // JWT 토큰 무효화용 버전
iat?: number;
exp?: number;
aud?: string;

View File

@ -140,6 +140,7 @@ export interface GetLangKeysParams {
includeOverrides?: boolean;
page?: number;
limit?: number;
userCompanyCode?: string; // 요청 사용자의 회사 코드 (비관리자 필터링용)
}
export interface GetUserTextParams {

View File

@ -20,6 +20,7 @@ export class JwtUtils {
companyName: userInfo.companyName, // 회사명 추가
userType: userInfo.userType,
userTypeName: userInfo.userTypeName,
tokenVersion: userInfo.tokenVersion ?? 0,
};
return jwt.sign(payload, config.jwt.secret, {

View File

@ -0,0 +1,14 @@
{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a3735bf","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":110167}
{"t":0,"agent":"ad2c89c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":196548}
{"t":0,"agent":"a2e5213","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":253997}
{"t":0,"agent":"a2c140c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":339528}
{"t":0,"agent":"a77742b","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":380641}
{"t":0,"agent":"a32b34c","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":413980}
{"t":0,"agent":"a4eb932","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":401646}

View File

@ -0,0 +1,10 @@
{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"ad233db","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":59735}
{"t":0,"agent":"a31a0f7","agent_type":"Explore","event":"agent_stop","success":true,"duration_ms":93607}
{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a1c1d18","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":136249}
{"t":0,"agent":"a9510b7","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":261624}
{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_start","parent_mode":"none"}
{"t":0,"agent":"a9a231d","agent_type":"executor","event":"agent_stop","success":true,"duration_ms":139427}

View File

@ -0,0 +1,3 @@
{
"lastSentAt": "2026-03-25T05:06:13.529Z"
}

View File

@ -0,0 +1,7 @@
{
"tool_name": "Bash",
"tool_input_preview": "{\"command\":\"wc -l /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx\",\"description\":\"Get total line count of the file\"}",
"error": "Exit code 1\n(eval):1: no matches found: /Users/kimjuseok/ERP-node/frontend/app/(main)/production/plan-management/page.tsx",
"timestamp": "2026-03-25T05:00:38.410Z",
"retry_count": 1
}

View File

@ -0,0 +1,281 @@
{
"updatedAt": "2026-03-25T05:06:35.487Z",
"missions": [
{
"id": "session:8145031e-d7ea-4aa3-94d7-ddaa69383b8a:none",
"source": "session",
"name": "none",
"objective": "Session mission",
"createdAt": "2026-03-25T00:33:45.197Z",
"updatedAt": "2026-03-25T01:37:19.659Z",
"status": "done",
"workerCount": 5,
"taskCounts": {
"total": 5,
"pending": 0,
"blocked": 0,
"inProgress": 0,
"completed": 5,
"failed": 0
},
"agents": [
{
"name": "Explore:ad233db",
"role": "Explore",
"ownership": "ad233db7fa6f059dd",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:34:44.932Z"
},
{
"name": "Explore:a31a0f7",
"role": "Explore",
"ownership": "a31a0f729d328643f",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:35:24.588Z"
},
{
"name": "executor:a9510b7",
"role": "executor",
"ownership": "a9510b7d8ec5a1ce7",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:42:01.730Z"
},
{
"name": "executor:a1c1d18",
"role": "executor",
"ownership": "a1c1d186f0eb6dfc1",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T00:40:12.608Z"
},
{
"name": "executor:a9a231d",
"role": "executor",
"ownership": "a9a231d40fd5a150b",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T01:37:19.659Z"
}
],
"timeline": [
{
"id": "session-stop:a1c1d186f0eb6dfc1:2026-03-25T00:40:12.608Z",
"at": "2026-03-25T00:40:12.608Z",
"kind": "completion",
"agent": "executor:a1c1d18",
"detail": "completed",
"sourceKey": "session-stop:a1c1d186f0eb6dfc1"
},
{
"id": "session-stop:a9510b7d8ec5a1ce7:2026-03-25T00:42:01.730Z",
"at": "2026-03-25T00:42:01.730Z",
"kind": "completion",
"agent": "executor:a9510b7",
"detail": "completed",
"sourceKey": "session-stop:a9510b7d8ec5a1ce7"
},
{
"id": "session-start:a9a231d40fd5a150b:2026-03-25T01:35:00.232Z",
"at": "2026-03-25T01:35:00.232Z",
"kind": "update",
"agent": "executor:a9a231d",
"detail": "started executor:a9a231d",
"sourceKey": "session-start:a9a231d40fd5a150b"
},
{
"id": "session-stop:a9a231d40fd5a150b:2026-03-25T01:37:19.659Z",
"at": "2026-03-25T01:37:19.659Z",
"kind": "completion",
"agent": "executor:a9a231d",
"detail": "completed",
"sourceKey": "session-stop:a9a231d40fd5a150b"
}
]
},
{
"id": "session:037169c7-72ba-4843-8e9a-417ca1423715:none",
"source": "session",
"name": "none",
"objective": "Session mission",
"createdAt": "2026-03-25T04:59:24.101Z",
"updatedAt": "2026-03-25T05:06:35.487Z",
"status": "done",
"workerCount": 7,
"taskCounts": {
"total": 7,
"pending": 0,
"blocked": 0,
"inProgress": 0,
"completed": 7,
"failed": 0
},
"agents": [
{
"name": "executor:a32b34c",
"role": "executor",
"ownership": "a32b34c341b854da5",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:06:18.081Z"
},
{
"name": "executor:ad2c89c",
"role": "executor",
"ownership": "ad2c89cf14936ea42",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:02:45.524Z"
},
{
"name": "executor:a2c140c",
"role": "executor",
"ownership": "a2c140c5a5adb0719",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:05:13.388Z"
},
{
"name": "executor:a2e5213",
"role": "executor",
"ownership": "a2e52136ea8f04385",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:03:53.163Z"
},
{
"name": "executor:a3735bf",
"role": "executor",
"ownership": "a3735bf51a74d6fc8",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:01:33.817Z"
},
{
"name": "executor:a77742b",
"role": "executor",
"ownership": "a77742ba65fd2451c",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:06:09.324Z"
},
{
"name": "executor:a4eb932",
"role": "executor",
"ownership": "a4eb932c438b898c0",
"status": "done",
"currentStep": null,
"latestUpdate": "completed",
"completedSummary": null,
"updatedAt": "2026-03-25T05:06:35.487Z"
}
],
"timeline": [
{
"id": "session-start:a3735bf51a74d6fc8:2026-03-25T04:59:43.650Z",
"at": "2026-03-25T04:59:43.650Z",
"kind": "update",
"agent": "executor:a3735bf",
"detail": "started executor:a3735bf",
"sourceKey": "session-start:a3735bf51a74d6fc8"
},
{
"id": "session-start:a77742ba65fd2451c:2026-03-25T04:59:48.683Z",
"at": "2026-03-25T04:59:48.683Z",
"kind": "update",
"agent": "executor:a77742b",
"detail": "started executor:a77742b",
"sourceKey": "session-start:a77742ba65fd2451c"
},
{
"id": "session-start:a4eb932c438b898c0:2026-03-25T04:59:53.841Z",
"at": "2026-03-25T04:59:53.841Z",
"kind": "update",
"agent": "executor:a4eb932",
"detail": "started executor:a4eb932",
"sourceKey": "session-start:a4eb932c438b898c0"
},
{
"id": "session-stop:a3735bf51a74d6fc8:2026-03-25T05:01:33.817Z",
"at": "2026-03-25T05:01:33.817Z",
"kind": "completion",
"agent": "executor:a3735bf",
"detail": "completed",
"sourceKey": "session-stop:a3735bf51a74d6fc8"
},
{
"id": "session-stop:ad2c89cf14936ea42:2026-03-25T05:02:45.524Z",
"at": "2026-03-25T05:02:45.524Z",
"kind": "completion",
"agent": "executor:ad2c89c",
"detail": "completed",
"sourceKey": "session-stop:ad2c89cf14936ea42"
},
{
"id": "session-stop:a2e52136ea8f04385:2026-03-25T05:03:53.163Z",
"at": "2026-03-25T05:03:53.163Z",
"kind": "completion",
"agent": "executor:a2e5213",
"detail": "completed",
"sourceKey": "session-stop:a2e52136ea8f04385"
},
{
"id": "session-stop:a2c140c5a5adb0719:2026-03-25T05:05:13.388Z",
"at": "2026-03-25T05:05:13.388Z",
"kind": "completion",
"agent": "executor:a2c140c",
"detail": "completed",
"sourceKey": "session-stop:a2c140c5a5adb0719"
},
{
"id": "session-stop:a77742ba65fd2451c:2026-03-25T05:06:09.324Z",
"at": "2026-03-25T05:06:09.324Z",
"kind": "completion",
"agent": "executor:a77742b",
"detail": "completed",
"sourceKey": "session-stop:a77742ba65fd2451c"
},
{
"id": "session-stop:a32b34c341b854da5:2026-03-25T05:06:18.081Z",
"at": "2026-03-25T05:06:18.081Z",
"kind": "completion",
"agent": "executor:a32b34c",
"detail": "completed",
"sourceKey": "session-stop:a32b34c341b854da5"
},
{
"id": "session-stop:a4eb932c438b898c0:2026-03-25T05:06:35.487Z",
"at": "2026-03-25T05:06:35.487Z",
"kind": "completion",
"agent": "executor:a4eb932",
"detail": "completed",
"sourceKey": "session-stop:a4eb932c438b898c0"
}
]
}
]
}

View File

@ -0,0 +1,116 @@
{
"agents": [
{
"agent_id": "ad233db7fa6f059dd",
"agent_type": "Explore",
"started_at": "2026-03-25T00:33:45.197Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:34:44.932Z",
"duration_ms": 59735
},
{
"agent_id": "a31a0f729d328643f",
"agent_type": "Explore",
"started_at": "2026-03-25T00:33:50.981Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:35:24.588Z",
"duration_ms": 93607
},
{
"agent_id": "a9510b7d8ec5a1ce7",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T00:37:40.106Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:42:01.730Z",
"duration_ms": 261624
},
{
"agent_id": "a1c1d186f0eb6dfc1",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T00:37:56.359Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T00:40:12.608Z",
"duration_ms": 136249
},
{
"agent_id": "a9a231d40fd5a150b",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T01:35:00.232Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T01:37:19.659Z",
"duration_ms": 139427
},
{
"agent_id": "a32b34c341b854da5",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:24.101Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:06:18.081Z",
"duration_ms": 413980
},
{
"agent_id": "ad2c89cf14936ea42",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:28.976Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:02:45.524Z",
"duration_ms": 196548
},
{
"agent_id": "a2c140c5a5adb0719",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:33.860Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:05:13.388Z",
"duration_ms": 339528
},
{
"agent_id": "a2e52136ea8f04385",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:39.166Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:03:53.163Z",
"duration_ms": 253997
},
{
"agent_id": "a3735bf51a74d6fc8",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:43.650Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:01:33.817Z",
"duration_ms": 110167
},
{
"agent_id": "a77742ba65fd2451c",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:48.683Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:06:09.324Z",
"duration_ms": 380641
},
{
"agent_id": "a4eb932c438b898c0",
"agent_type": "oh-my-claudecode:executor",
"started_at": "2026-03-25T04:59:53.841Z",
"parent_mode": "none",
"status": "completed",
"completed_at": "2026-03-25T05:06:35.487Z",
"duration_ms": 401646
}
],
"total_spawned": 12,
"total_completed": 12,
"total_failed": 0,
"last_updated": "2026-03-25T05:06:35.589Z"
}

View File

@ -0,0 +1,752 @@
"use client";
/**
*
*
* 좌측: 설비 (equipment_mng)
* 우측: ( / / )
*
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Wrench, ClipboardCheck, Package, Copy, Info, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { ImageUpload } from "@/components/common/ImageUpload";
const EQUIP_TABLE = "equipment_mng";
const INSPECTION_TABLE = "equipment_inspection_item";
const CONSUMABLE_TABLE = "equipment_consumable";
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "equipment_code", label: "설비코드", width: "w-[110px]" },
{ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]" },
{ key: "equipment_type", label: "설비유형", width: "w-[90px]" },
{ key: "manufacturer", label: "제조사", width: "w-[100px]" },
{ key: "installation_location", label: "설치장소", width: "w-[100px]" },
{ key: "operation_status", label: "가동상태", width: "w-[80px]" },
];
const INSPECTION_COLUMNS: DataGridColumn[] = [
{ key: "inspection_item", label: "점검항목", minWidth: "min-w-[120px]", editable: true },
{ key: "inspection_cycle", label: "점검주기", width: "w-[80px]" },
{ key: "inspection_method", label: "점검방법", width: "w-[80px]" },
{ key: "lower_limit", label: "하한치", width: "w-[70px]", editable: true },
{ key: "upper_limit", label: "상한치", width: "w-[70px]", editable: true },
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
{ key: "inspection_content", label: "점검내용", minWidth: "min-w-[150px]", editable: true },
];
const CONSUMABLE_COLUMNS: DataGridColumn[] = [
{ key: "image_path", label: "이미지", width: "w-[50px]", renderType: "image", sortable: false, filterable: false },
{ key: "consumable_name", label: "소모품명", minWidth: "min-w-[120px]", editable: true },
{ key: "replacement_cycle", label: "교체주기", width: "w-[90px]", editable: true },
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
{ key: "specification", label: "규격", width: "w-[100px]", editable: true },
{ key: "manufacturer", label: "제조사", width: "w-[100px]", editable: true },
];
export default function EquipmentInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측
const [equipments, setEquipments] = useState<any[]>([]);
const [equipLoading, setEquipLoading] = useState(false);
const [equipCount, setEquipCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
// 우측 탭
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
const [inspections, setInspections] = useState<any[]>([]);
const [inspectionLoading, setInspectionLoading] = useState(false);
const [consumables, setConsumables] = useState<any[]>([]);
const [consumableLoading, setConsumableLoading] = useState(false);
// 카테고리
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 모달
const [equipModalOpen, setEquipModalOpen] = useState(false);
const [equipEditMode, setEquipEditMode] = useState(false);
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 기본정보 탭 편집 폼
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
const [infoSaving, setInfoSaving] = useState(false);
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySourceEquip, setCopySourceEquip] = useState("");
const [copyItems, setCopyItems] = useState<any[]>([]);
const [copyChecked, setCopyChecked] = useState<Set<string>>(new Set());
const [copyLoading, setCopyLoading] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("equipment-info");
if (saved) applyTableSettings(saved);
}, []);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// equipment_mng 카테고리
for (const col of ["equipment_type", "operation_status"]) {
try {
const res = await apiClient.get(`/table-categories/${EQUIP_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
// inspection 카테고리
for (const col of ["inspection_cycle", "inspection_method"]) {
try {
const res = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCatOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return catOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 설비 조회
const fetchEquipments = useCallback(async () => {
setEquipLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
useEffect(() => { fetchEquipments(); }, [fetchEquipments]);
const selectedEquip = equipments.find((e) => e.id === selectedEquipId);
// 기본정보 탭 폼 초기화 (설비 선택 변경 시)
useEffect(() => {
if (selectedEquip) setInfoForm({ ...selectedEquip });
else setInfoForm({});
}, [selectedEquipId]); // eslint-disable-line react-hooks/exhaustive-deps
// 기본정보 저장
const handleInfoSave = async () => {
if (!infoForm.id) return;
setInfoSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = infoForm;
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("저장되었습니다.");
fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); }
finally { setInfoSaving(false); }
};
// 우측: 점검항목 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetch = async () => {
setInspectionLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setInspections(raw.map((r: any) => ({
...r,
inspection_cycle: resolve("inspection_cycle", r.inspection_cycle),
inspection_method: resolve("inspection_method", r.inspection_method),
})));
} catch { setInspections([]); } finally { setInspectionLoading(false); }
};
fetch();
}, [selectedEquip?.equipment_code, catOptions]);
// 우측: 소모품 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetch = async () => {
setConsumableLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
setConsumables(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setConsumables([]); } finally { setConsumableLoading(false); }
};
fetch();
}, [selectedEquip?.equipment_code]);
// 새로고침 헬퍼
const refreshRight = () => {
const eid = selectedEquipId;
setSelectedEquipId(null);
setTimeout(() => setSelectedEquipId(eid), 50);
};
// 설비 등록/수정
const openEquipRegister = () => { setEquipForm({}); setEquipEditMode(false); setEquipModalOpen(true); };
const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); };
const handleEquipSave = async () => {
if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; }
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = equipForm;
if (equipEditMode && id) {
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, fields);
toast.success("등록되었습니다.");
}
setEquipModalOpen(false); fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
const handleEquipDelete = async () => {
if (!selectedEquipId) return;
const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] });
toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
setSaving(true);
try {
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setInspectionModalOpen(false); refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 소모품 추가
// 소모품 품목 로드 (item_info에서 type 또는 division 라벨이 "소모품"인 것)
const loadConsumableItems = async () => {
try {
const flatten = (vals: any[]): any[] => {
const r: any[] = [];
for (const v of vals) { r.push(v); if (v.children?.length) r.push(...flatten(v.children)); }
return r;
};
// type과 division 카테고리 모두에서 "소모품" 코드 찾기
const [typeRes, divRes] = await Promise.all([
apiClient.get(`/table-categories/item_info/type/values`),
apiClient.get(`/table-categories/item_info/division/values`),
]);
const consumableType = flatten(typeRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
const consumableDiv = flatten(divRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
if (!consumableType && !consumableDiv) { setConsumableItemOptions([]); return; }
// 두 필터 결과를 합산 (중복 제거)
const filters: any[] = [];
if (consumableType) filters.push({ columnName: "type", operator: "equals", value: consumableType.valueCode });
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
const results = await Promise.all(filters.map((f) =>
apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [f] },
autoFilter: true,
})
));
const allItems = new Map<string, any>();
for (const res of results) {
const rows = res.data?.data?.data || res.data?.data?.rows || [];
for (const row of rows) allItems.set(row.id, row);
}
setConsumableItemOptions(Array.from(allItems.values()));
} catch { setConsumableItemOptions([]); }
};
const handleConsumableSave = async () => {
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
setSaving(true);
try {
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
...consumableForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setConsumableModalOpen(false); refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 점검항목 복사: 소스 설비 선택 시 점검항목 로드
const loadCopyItems = async (equipCode: string) => {
setCopySourceEquip(equipCode);
setCopyChecked(new Set());
if (!equipCode) { setCopyItems([]); return; }
setCopyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
autoFilter: true,
});
setCopyItems(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setCopyItems([]); } finally { setCopyLoading(false); }
};
const handleCopyApply = async () => {
const selected = copyItems.filter((i) => copyChecked.has(i.id));
if (selected.length === 0) { toast.error("복사할 항목을 선택해주세요."); return; }
setSaving(true);
try {
for (const item of selected) {
const { id, created_date, updated_date, writer, company_code, equipment_code, ...fields } = item;
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...fields, equipment_code: selectedEquip?.equipment_code,
});
}
toast.success(`${selected.length}개 점검항목이 복사되었습니다.`);
setCopyModalOpen(false); refreshRight();
} catch { toast.error("복사 실패"); } finally { setSaving(false); }
};
// 엑셀
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
// 셀렉트 렌더링 헬퍼
const catSelect = (key: string, value: string, onChange: (v: string) => void, placeholder: string) => (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
{(catOptions[key] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
return (
<div className="flex h-full flex-col gap-3 p-3">
<DynamicSearchFilter tableName={EQUIP_TABLE} filterId="equipment-info" onFilterChange={setSearchFilters} dataCount={equipCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
try {
const r = await autoDetectMultiTableConfig(EQUIP_TABLE);
if (r.success && r.data) { setExcelChainConfig(r.data); setExcelUploadOpen(true); }
else toast.error("테이블 구조 분석 실패");
} catch { toast.error("오류"); } finally { setExcelDetecting(false); }
}}>
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />}
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 설비 목록 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Wrench className="w-4 h-4" /> <Badge variant="secondary" className="font-normal">{equipCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={openEquipRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="destructive" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
</div>
</div>
<DataGrid gridId="equip-left" columns={LEFT_COLUMNS} data={equipments} loading={equipLoading}
selectedId={selectedEquipId} onSelect={setSelectedEquipId} onRowDoubleClick={() => openEquipEdit()}
emptyMessage="등록된 설비가 없습니다" />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 탭 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-2 border-b bg-muted/10 shrink-0">
<div className="flex items-center gap-1">
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
<button key={tab} onClick={() => setRightTab(tab)}
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1",
rightTab === tab ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
<Icon className="w-3.5 h-3.5" />{label}
{tab === "inspection" && inspections.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{inspections.length}</Badge>}
{tab === "consumable" && consumables.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{consumables.length}</Badge>}
</button>
))}
{selectedEquip && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedEquip.equipment_name}</Badge>}
</div>
<div className="flex gap-1.5">
{rightTab === "inspection" && (
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
</div>
{!selectedEquipId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm"> </div>
) : rightTab === "info" ? (
<div className="p-4 overflow-auto">
<div className="flex justify-end mb-3">
<Button size="sm" onClick={handleInfoSave} disabled={infoSaving}>
{infoSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={infoForm.equipment_code || ""} className="h-9 bg-muted/50" disabled />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.equipment_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.installation_location || ""} onChange={(e) => setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.manufacturer || ""} onChange={(e) => setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.model_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={infoForm.introduction_date || ""} onChange={(v) => setInfoForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")}
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<Input value={infoForm.remarks || ""} onChange={(e) => setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<ImageUpload value={infoForm.image_path} onChange={(v) => setInfoForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" />
</div>
</div>
</div>
) : rightTab === "inspection" ? (
<DataGrid gridId="equip-inspection" columns={INSPECTION_COLUMNS} data={inspections} loading={inspectionLoading}
showRowNumber={false} tableName={INSPECTION_TABLE} emptyMessage="점검항목이 없습니다"
onCellEdit={() => refreshRight()} />
) : (
<DataGrid gridId="equip-consumable" columns={CONSUMABLE_COLUMNS} data={consumables} loading={consumableLoading}
showRowNumber={false} tableName={CONSUMABLE_TABLE} emptyMessage="소모품이 없습니다"
onCellEdit={() => refreshRight()} />
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 설비 등록/수정 모달 */}
<FullscreenDialog open={equipModalOpen} onOpenChange={setEquipModalOpen}
title={equipEditMode ? "설비 수정" : "설비 등록"} description={equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}
defaultMaxWidth="max-w-2xl"
footer={<><Button variant="outline" onClick={() => setEquipModalOpen(false)}></Button>
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} </Button></>}>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<FormDatePicker value={equipForm.introduction_date || ""} onChange={(v) => setEquipForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
</div>
</FullscreenDialog>
{/* 점검항목 추가 모달 */}
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle><DialogDescription>{selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" /></div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.lower_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.upper_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setInspectionModalOpen(false)}></Button>
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 소모품 추가 모달 */}
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle><DialogDescription>{selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5 col-span-2"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{consumableItemOptions.length > 0 ? (
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
setConsumableForm((p) => ({
...p,
consumable_name: v,
specification: item?.size || p.specification || "",
unit: item?.unit || p.unit || "",
manufacturer: item?.manufacturer || p.manufacturer || "",
}));
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="소모품 선택" /></SelectTrigger>
<SelectContent>
{consumableItemOptions.map((item) => (
<SelectItem key={item.id} value={item.item_name || item.item_number}>
{item.item_name}{item.size ? ` (${item.size})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div>
<Input value={consumableForm.consumable_name || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))}
placeholder="소모품명 직접 입력" className="h-9" />
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
)}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.replacement_cycle || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.unit || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.specification || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.manufacturer || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setConsumableModalOpen(false)}></Button>
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 점검항목 복사 모달 */}
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader><DialogTitle> </DialogTitle>
<DialogDescription> {selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
<SelectTrigger className="h-9"><SelectValue placeholder="복사할 설비 선택" /></SelectTrigger>
<SelectContent>
{equipments.filter((e) => e.equipment_code !== selectedEquip?.equipment_code).map((e) => (
<SelectItem key={e.equipment_code} value={e.equipment_code}>{e.equipment_name} ({e.equipment_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border rounded-lg overflow-auto max-h-[300px]">
{copyLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : copyItems.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없습니다" : "설비를 선택하세요"}</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
</TableHead>
<TableHead></TableHead><TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead><TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[70px]"></TableHead><TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyItems.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
<TableCell className="text-sm">{item.inspection_item}</TableCell>
<TableCell className="text-xs">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-xs">{resolve("inspection_method", item.inspection_method)}</TableCell>
<TableCell className="text-xs">{item.lower_limit || "-"}</TableCell>
<TableCell className="text-xs">{item.upper_limit || "-"}</TableCell>
<TableCell className="text-xs">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{copyChecked.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCopyModalOpen(false)}></Button>
<Button onClick={handleCopyApply} disabled={saving || copyChecked.size === 0}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Copy className="w-4 h-4 mr-1.5" />}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 (멀티테이블) */}
{excelChainConfig && (
<MultiTableExcelUploadModal open={excelUploadOpen}
onOpenChange={(open) => { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }}
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
)}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={EQUIP_TABLE}
settingsId="equipment-info"
onSave={applyTableSettings}
/>
{ConfirmDialogComponent}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,926 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
ResizableHandle, ResizablePanel, ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandInput, CommandList, CommandEmpty, CommandItem } from "@/components/ui/command";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import {
Search, Plus, Trash2, RotateCcw, Loader2, Package, Box, X, Save, Edit2, Download, ChevronsUpDown, Check,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
getItemsByDivision, getGeneralItems,
type PkgUnit, type PkgUnitItem, type LoadingUnit, type LoadingUnitPkg, type ItemInfoForPkg,
} from "@/lib/api/packaging";
// --- 코드 → 라벨 매핑 ---
const PKG_TYPE_LABEL: Record<string, string> = {
BOX: "박스", PACK: "팩", CANBOARD: "캔보드", AIRCAP: "에어캡",
ZIPCOS: "집코스", CYLINDER: "원통형", POLYCARTON: "포리/카톤",
};
const LOADING_TYPE_LABEL: Record<string, string> = {
PALLET: "파렛트", WOOD_PALLET: "목재파렛트", PLASTIC_PALLET: "플라스틱파렛트",
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
CAGE: "케이지", ETC: "기타",
};
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용" };
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-emerald-100 text-emerald-800" : "bg-gray-100 text-gray-600";
const fmtSize = (w: any, l: any, h: any) => {
const vals = [w, l, h].map(v => Number(v) || 0);
return vals.some(v => v > 0) ? vals.join("×") : "-";
};
// 규격 문자열에서 치수 파싱
function parseSpecDimensions(spec: string | null) {
if (!spec) return { w: 0, l: 0, h: 0 };
const m3 = spec.match(/(\d+)\s*[x×]\s*(\d+)\s*[x×]\s*(\d+)/i);
if (m3) return { w: parseInt(m3[1]), l: parseInt(m3[2]), h: parseInt(m3[3]) };
const m2 = spec.match(/(\d+)\s*[x×]\s*(\d+)/i);
if (m2) return { w: parseInt(m2[1]), l: parseInt(m2[2]), h: 0 };
return { w: 0, l: 0, h: 0 };
}
export default function PackagingPage() {
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [activeTab, setActiveTab] = useState<"packing" | "loading">("packing");
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
// 포장재 데이터
const [pkgUnits, setPkgUnits] = useState<PkgUnit[]>([]);
const [pkgLoading, setPkgLoading] = useState(false);
const [selectedPkg, setSelectedPkg] = useState<PkgUnit | null>(null);
const [pkgItems, setPkgItems] = useState<PkgUnitItem[]>([]);
const [pkgItemsLoading, setPkgItemsLoading] = useState(false);
// 적재함 데이터
const [loadingUnits, setLoadingUnits] = useState<LoadingUnit[]>([]);
const [loadingLoading, setLoadingLoading] = useState(false);
const [selectedLoading, setSelectedLoading] = useState<LoadingUnit | null>(null);
const [loadingPkgs, setLoadingPkgs] = useState<LoadingUnitPkg[]>([]);
const [loadingPkgsLoading, setLoadingPkgsLoading] = useState(false);
// 모달
const [pkgModalOpen, setPkgModalOpen] = useState(false);
const [pkgModalMode, setPkgModalMode] = useState<"create" | "edit">("create");
const [pkgForm, setPkgForm] = useState<Record<string, any>>({});
const [pkgItemOptions, setPkgItemOptions] = useState<ItemInfoForPkg[]>([]);
const [pkgItemPopoverOpen, setPkgItemPopoverOpen] = useState(false);
const [loadModalOpen, setLoadModalOpen] = useState(false);
const [loadModalMode, setLoadModalMode] = useState<"create" | "edit">("create");
const [loadForm, setLoadForm] = useState<Record<string, any>>({});
const [loadItemOptions, setLoadItemOptions] = useState<ItemInfoForPkg[]>([]);
const [loadItemPopoverOpen, setLoadItemPopoverOpen] = useState(false);
const [itemMatchModalOpen, setItemMatchModalOpen] = useState(false);
const [itemMatchKeyword, setItemMatchKeyword] = useState("");
const [itemMatchResults, setItemMatchResults] = useState<ItemInfoForPkg[]>([]);
const [itemMatchSelected, setItemMatchSelected] = useState<ItemInfoForPkg | null>(null);
const [itemMatchQty, setItemMatchQty] = useState(1);
const [pkgMatchModalOpen, setPkgMatchModalOpen] = useState(false);
const [pkgMatchQty, setPkgMatchQty] = useState(1);
const [pkgMatchMethod, setPkgMatchMethod] = useState("");
const [pkgMatchSelected, setPkgMatchSelected] = useState<PkgUnit | null>(null);
const [pkgMatchSearchKw, setPkgMatchSearchKw] = useState("");
const [saving, setSaving] = useState(false);
// --- 데이터 로드 ---
const fetchPkgUnits = useCallback(async () => {
setPkgLoading(true);
try {
const res = await getPkgUnits();
if (res.success) setPkgUnits(res.data);
} catch { /* ignore */ } finally { setPkgLoading(false); }
}, []);
const fetchLoadingUnits = useCallback(async () => {
setLoadingLoading(true);
try {
const res = await getLoadingUnits();
if (res.success) setLoadingUnits(res.data);
} catch { /* ignore */ } finally { setLoadingLoading(false); }
}, []);
useEffect(() => { fetchPkgUnits(); fetchLoadingUnits(); }, [fetchPkgUnits, fetchLoadingUnits]);
// 포장재 선택 시 매칭 품목 로드
const selectPkg = useCallback(async (pkg: PkgUnit) => {
setSelectedPkg(pkg);
setPkgItemsLoading(true);
try {
const res = await getPkgUnitItems(pkg.pkg_code);
if (res.success) setPkgItems(res.data);
} catch { setPkgItems([]); } finally { setPkgItemsLoading(false); }
}, []);
// 적재함 선택 시 포장구성 로드
const selectLoading = useCallback(async (lu: LoadingUnit) => {
setSelectedLoading(lu);
setLoadingPkgsLoading(true);
try {
const res = await getLoadingUnitPkgs(lu.loading_code);
if (res.success) setLoadingPkgs(res.data);
} catch { setLoadingPkgs([]); } finally { setLoadingPkgsLoading(false); }
}, []);
// 검색 필터 적용
const filteredPkgUnits = pkgUnits.filter((p) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw));
});
const filteredLoadingUnits = loadingUnits.filter((l) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (l.loading_code?.toLowerCase().includes(kw) || l.loading_name?.toLowerCase().includes(kw));
});
// --- 포장재 등록/수정 모달 ---
const openPkgModal = async (mode: "create" | "edit") => {
setPkgModalMode(mode);
if (mode === "edit" && selectedPkg) {
setPkgForm({ ...selectedPkg });
} else {
setPkgForm({ pkg_code: "", pkg_name: "", pkg_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", volume_l: "", remarks: "" });
}
setPkgItemPopoverOpen(false);
try {
const res = await getItemsByDivision("포장재");
if (res.success) setPkgItemOptions(res.data);
} catch { setPkgItemOptions([]); }
setPkgModalOpen(true);
};
const onPkgItemSelect = (item: ItemInfoForPkg) => {
setPkgItemPopoverOpen(false);
const dims = parseSpecDimensions(item.size);
setPkgForm((prev) => ({
...prev,
pkg_code: item.item_number,
pkg_name: item.item_name,
width_mm: dims.w || prev.width_mm,
length_mm: dims.l || prev.length_mm,
height_mm: dims.h || prev.height_mm,
}));
};
const savePkgUnit = async () => {
if (!pkgForm.pkg_code || !pkgForm.pkg_name) { toast.error("포장코드와 포장명은 필수입니다."); return; }
if (!pkgForm.pkg_type) { toast.error("포장유형을 선택해주세요."); return; }
setSaving(true);
try {
if (pkgModalMode === "create") {
const res = await createPkgUnit(pkgForm);
if (res.success) { toast.success("포장재 등록 완료"); setPkgModalOpen(false); fetchPkgUnits(); }
} else {
const res = await updatePkgUnit(pkgForm.id, pkgForm);
if (res.success) { toast.success("포장재 수정 완료"); setPkgModalOpen(false); fetchPkgUnits(); setSelectedPkg(res.data); }
}
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
};
const handleDeletePkg = async (pkg: PkgUnit) => {
const ok = await confirm(`"${pkg.pkg_name}" 포장재를 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deletePkgUnit(pkg.id);
toast.success("삭제 완료");
setSelectedPkg(null); setPkgItems([]);
fetchPkgUnits();
} catch { toast.error("삭제 실패"); }
};
// --- 적재함 등록/수정 모달 ---
const openLoadModal = async (mode: "create" | "edit") => {
setLoadModalMode(mode);
if (mode === "edit" && selectedLoading) {
setLoadForm({ ...selectedLoading });
} else {
setLoadForm({ loading_code: "", loading_name: "", loading_type: "", status: "ACTIVE", width_mm: "", length_mm: "", height_mm: "", self_weight_kg: "", max_load_kg: "", max_stack: "", remarks: "" });
}
setLoadItemPopoverOpen(false);
try {
const res = await getItemsByDivision("적재함");
if (res.success) setLoadItemOptions(res.data);
} catch { setLoadItemOptions([]); }
setLoadModalOpen(true);
};
const onLoadItemSelect = (item: ItemInfoForPkg) => {
setLoadItemPopoverOpen(false);
const dims = parseSpecDimensions(item.size);
setLoadForm((prev) => ({
...prev,
loading_code: item.item_number,
loading_name: item.item_name,
width_mm: dims.w || prev.width_mm,
length_mm: dims.l || prev.length_mm,
height_mm: dims.h || prev.height_mm,
}));
};
const saveLoadingUnit = async () => {
if (!loadForm.loading_code || !loadForm.loading_name) { toast.error("적재함코드와 적재함명은 필수입니다."); return; }
if (!loadForm.loading_type) { toast.error("적재유형을 선택해주세요."); return; }
setSaving(true);
try {
if (loadModalMode === "create") {
const res = await createLoadingUnit(loadForm);
if (res.success) { toast.success("적재함 등록 완료"); setLoadModalOpen(false); fetchLoadingUnits(); }
} else {
const res = await updateLoadingUnit(loadForm.id, loadForm);
if (res.success) { toast.success("적재함 수정 완료"); setLoadModalOpen(false); fetchLoadingUnits(); setSelectedLoading(res.data); }
}
} catch { toast.error("저장 실패"); } finally { setSaving(false); }
};
const handleDeleteLoading = async (lu: LoadingUnit) => {
const ok = await confirm(`"${lu.loading_name}" 적재함을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deleteLoadingUnit(lu.id);
toast.success("삭제 완료");
setSelectedLoading(null); setLoadingPkgs([]);
fetchLoadingUnits();
} catch { toast.error("삭제 실패"); }
};
// --- 품목 추가 모달 (포장재 매칭) ---
const openItemMatchModal = async () => {
setItemMatchKeyword(""); setItemMatchSelected(null); setItemMatchQty(1);
setItemMatchModalOpen(true);
try {
const res = await getGeneralItems();
if (res.success) setItemMatchResults(res.data);
} catch { setItemMatchResults([]); }
};
const searchItemsForMatch = async () => {
try {
const res = await getGeneralItems(itemMatchKeyword || undefined);
if (res.success) setItemMatchResults(res.data);
} catch { setItemMatchResults([]); }
};
const saveItemMatch = async () => {
if (!selectedPkg || !itemMatchSelected) { toast.error("품목을 선택해주세요."); return; }
if (itemMatchQty <= 0) { toast.error("포장수량을 입력해주세요."); return; }
setSaving(true);
try {
const res = await createPkgUnitItem({
pkg_code: selectedPkg.pkg_code,
item_number: itemMatchSelected.item_number,
pkg_qty: itemMatchQty,
});
if (res.success) { toast.success("품목 추가 완료"); setItemMatchModalOpen(false); selectPkg(selectedPkg); }
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
};
const handleDeletePkgItem = async (item: PkgUnitItem) => {
const ok = await confirm("매칭 품목을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deletePkgUnitItem(item.id);
toast.success("삭제 완료");
if (selectedPkg) selectPkg(selectedPkg);
} catch { toast.error("삭제 실패"); }
};
// --- 포장단위 추가 모달 (적재함 구성) ---
const openPkgMatchModal = () => {
setPkgMatchSelected(null); setPkgMatchQty(1); setPkgMatchMethod(""); setPkgMatchSearchKw("");
setPkgMatchModalOpen(true);
};
const savePkgMatch = async () => {
if (!selectedLoading || !pkgMatchSelected) { toast.error("포장단위를 선택해주세요."); return; }
if (pkgMatchQty <= 0) { toast.error("최대적재수량을 입력해주세요."); return; }
setSaving(true);
try {
const res = await createLoadingUnitPkg({
loading_code: selectedLoading.loading_code,
pkg_code: pkgMatchSelected.pkg_code,
max_load_qty: pkgMatchQty,
load_method: pkgMatchMethod || undefined,
});
if (res.success) { toast.success("포장단위 추가 완료"); setPkgMatchModalOpen(false); selectLoading(selectedLoading); }
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
};
const handleDeleteLoadPkg = async (lp: LoadingUnitPkg) => {
const ok = await confirm("적재 구성을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await deleteLoadingUnitPkg(lp.id);
toast.success("삭제 완료");
if (selectedLoading) selectLoading(selectedLoading);
} catch { toast.error("삭제 실패"); }
};
return (
<div className="flex h-full flex-col gap-3 p-4">
{/* 검색 바 */}
<div className="flex items-center gap-2 rounded-lg border bg-card p-3">
<Input
placeholder="포장코드 / 포장명 / 적재함명 검색"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="h-9 w-[280px] text-xs"
/>
<Button size="sm" variant="outline" onClick={() => setSearchKeyword("")} className="h-9">
<RotateCcw className="mr-1 h-4 w-4" />
</Button>
</div>
{/* 탭 */}
<div className="flex gap-1 border-b">
{([["packing", "포장재 관리", filteredPkgUnits.length] as const, ["loading", "적재함 관리", filteredLoadingUnits.length] as const]).map(([tab, label, count]) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px",
activeTab === tab ? "border-primary text-primary" : "border-transparent text-muted-foreground hover:text-foreground"
)}
>
{tab === "packing" ? <Package className="h-4 w-4" /> : <Box className="h-4 w-4" />}
{label}
<Badge variant="secondary" className="text-[10px] px-1.5">{count}</Badge>
</button>
))}
</div>
{/* 탭 콘텐츠 */}
<div className="flex-1 overflow-hidden">
{activeTab === "packing" ? (
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
{/* 좌측: 포장재 목록 */}
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-semibold"> <span className="text-muted-foreground font-normal">({filteredPkgUnits.length})</span></span>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("create")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-[11px] bg-muted/50">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[90px]">(mm)</TableHead>
<TableHead className="p-2 w-[70px] text-right"></TableHead>
<TableHead className="p-2 w-[55px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pkgLoading ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
) : filteredPkgUnits.length === 0 ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs"> </TableCell></TableRow>
) : filteredPkgUnits.map((p) => (
<TableRow
key={p.id}
className={cn("cursor-pointer text-xs", selectedPkg?.id === p.id && "bg-primary/5")}
onClick={() => selectPkg(p)}
>
<TableCell className="p-2 font-medium truncate max-w-[100px]">{p.pkg_code}</TableCell>
<TableCell className="p-2 truncate max-w-[120px]">{p.pkg_name}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(p.status))}>{STATUS_LABEL[p.status] || p.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 상세 */}
<ResizablePanel defaultSize={55} minSize={30}>
{!selectedPkg ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Package className="h-12 w-12 opacity-20 mb-2" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex h-full flex-col">
{/* 요약 헤더 */}
<div className="flex items-center justify-between border-b bg-blue-50 dark:bg-blue-950/20 px-4 py-3">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-blue-600" />
<div>
<div className="font-bold text-sm">{selectedPkg.pkg_name}</div>
<div className="text-[11px] text-muted-foreground">{selectedPkg.pkg_code} · {PKG_TYPE_LABEL[selectedPkg.pkg_type] || selectedPkg.pkg_type} · {fmtSize(selectedPkg.width_mm, selectedPkg.length_mm, selectedPkg.height_mm)}mm</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openPkgModal("edit")}>
<Edit2 className="mr-1 h-3 w-3" />
</Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeletePkg(selectedPkg)}>
<Trash2 className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 매칭 품목 */}
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> ({pkgItems.length})</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openItemMatchModal}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
{pkgItemsLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : pkgItems.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs"> </div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[50px]"></TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
<TableHead className="p-2 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{pkgItems.map((item) => (
<TableRow key={item.id} className="text-xs">
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
<TableCell className="p-2">{item.spec || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(item.pkg_qty).toLocaleString()}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeletePkgItem(item)}>
<X className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
) : (
/* 적재함 관리 탭 */
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border">
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b px-4 py-2.5">
<span className="text-sm font-semibold"> <span className="text-muted-foreground font-normal">({filteredLoadingUnits.length})</span></span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("create")}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader>
<TableRow className="text-[11px] bg-muted/50">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[80px]"></TableHead>
<TableHead className="p-2 w-[90px]">(mm)</TableHead>
<TableHead className="p-2 w-[70px] text-right"></TableHead>
<TableHead className="p-2 w-[55px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingLoading ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
) : filteredLoadingUnits.length === 0 ? (
<TableRow><TableCell colSpan={6} className="h-32 text-center text-muted-foreground text-xs"> </TableCell></TableRow>
) : filteredLoadingUnits.map((l) => (
<TableRow
key={l.id}
className={cn("cursor-pointer text-xs", selectedLoading?.id === l.id && "bg-primary/5")}
onClick={() => selectLoading(l)}
>
<TableCell className="p-2 font-medium truncate max-w-[100px]">{l.loading_code}</TableCell>
<TableCell className="p-2 truncate max-w-[120px]">{l.loading_name}</TableCell>
<TableCell className="p-2">{LOADING_TYPE_LABEL[l.loading_type] || l.loading_type || "-"}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
<TableCell className="p-2 text-center">
<Badge variant="outline" className={cn("text-[10px]", getStatusColor(l.status))}>{STATUS_LABEL[l.status] || l.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={55} minSize={30}>
{!selectedLoading ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Box className="h-12 w-12 opacity-20 mb-2" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b bg-green-50 dark:bg-green-950/20 px-4 py-3">
<div className="flex items-center gap-3">
<Box className="h-5 w-5 text-green-600" />
<div>
<div className="font-bold text-sm">{selectedLoading.loading_name}</div>
<div className="text-[11px] text-muted-foreground">{selectedLoading.loading_code} · {LOADING_TYPE_LABEL[selectedLoading.loading_type] || selectedLoading.loading_type} · {fmtSize(selectedLoading.width_mm, selectedLoading.length_mm, selectedLoading.height_mm)}mm</div>
</div>
</div>
<div className="flex gap-1">
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => openLoadModal("edit")}><Edit2 className="mr-1 h-3 w-3" /> </Button>
<Button size="sm" variant="destructive" className="h-7 text-xs" onClick={() => handleDeleteLoading(selectedLoading)}><Trash2 className="mr-1 h-3 w-3" /> </Button>
</div>
</div>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-xs font-semibold text-muted-foreground"> ({loadingPkgs.length})</span>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={openPkgMatchModal}><Plus className="mr-1 h-3 w-3" /> </Button>
</div>
<div className="flex-1 overflow-auto">
{loadingPkgsLoading ? (
<div className="flex h-32 items-center justify-center"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : loadingPkgs.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground text-xs"> </div>
) : (
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{loadingPkgs.map((lp) => (
<TableRow key={lp.id} className="text-xs">
<TableCell className="p-2 font-medium">{lp.pkg_code}</TableCell>
<TableCell className="p-2">{lp.pkg_name || "-"}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[lp.pkg_type || ""] || lp.pkg_type || "-"}</TableCell>
<TableCell className="p-2 text-right font-semibold">{Number(lp.max_load_qty).toLocaleString()}</TableCell>
<TableCell className="p-2">{lp.load_method || "-"}</TableCell>
<TableCell className="p-2 text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleDeleteLoadPkg(lp)}><X className="h-3 w-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
{/* 포장재 등록/수정 모달 */}
<FullscreenDialog open={pkgModalOpen} onOpenChange={setPkgModalOpen}
title={pkgModalMode === "create" ? "포장재 등록" : "포장재 수정"}
description="품목정보에서 포장재를 선택하면 코드와 이름이 자동 연동됩니다."
footer={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setPkgModalOpen(false)}></Button>
<Button onClick={savePkgUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} </Button>
</div>
}
>
<div className="space-y-4 p-6">
{/* 품목정보 연결 */}
{pkgModalMode === "create" && (
<div className="rounded-lg border bg-blue-50 dark:bg-blue-950/20 p-4">
<Label className="text-xs font-semibold mb-2 block"> (구분: 포장재)</Label>
<Popover open={pkgItemPopoverOpen} onOpenChange={setPkgItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
{pkgForm.pkg_code
? `${pkgForm.pkg_name} (${pkgForm.pkg_code})`
: "품목정보에서 포장재를 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = pkgItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{pkgItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onPkgItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", pkgForm.pkg_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs"></Label><Input value={pkgForm.pkg_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div><Label className="text-xs"></Label><Input value={pkgForm.pkg_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div>
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select value={pkgForm.pkg_type || ""} onValueChange={(v) => setPkgForm((p) => ({ ...p, pkg_type: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>{Object.entries(PKG_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={pkgForm.status || "ACTIVE"} onValueChange={(v) => setPkgForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold"></Label>
<div className="grid grid-cols-3 gap-3 mt-2">
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.width_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.length_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={pkgForm.height_mm || ""} onChange={(e) => setPkgForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={pkgForm.self_weight_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={pkgForm.max_load_kg || ""} onChange={(e) => setPkgForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(L)</Label><Input type="number" value={pkgForm.volume_l || ""} onChange={(e) => setPkgForm((p) => ({ ...p, volume_l: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
</div>
</div>
<div><Label className="text-xs"></Label><Input value={pkgForm.remarks || ""} onChange={(e) => setPkgForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
</div>
</FullscreenDialog>
{/* 적재함 등록/수정 모달 */}
<FullscreenDialog open={loadModalOpen} onOpenChange={setLoadModalOpen}
title={loadModalMode === "create" ? "적재함 등록" : "적재함 수정"}
description="품목정보에서 적재함을 선택하면 코드와 이름이 자동 연동됩니다."
footer={
<div className="flex gap-2">
<Button variant="outline" onClick={() => setLoadModalOpen(false)}></Button>
<Button onClick={saveLoadingUnit} disabled={saving}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} </Button>
</div>
}
>
<div className="space-y-4 p-6">
{loadModalMode === "create" && (
<div className="rounded-lg border bg-green-50 dark:bg-green-950/20 p-4">
<Label className="text-xs font-semibold mb-2 block"> (구분: 적재함)</Label>
<Popover open={loadItemPopoverOpen} onOpenChange={setLoadItemPopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between h-9 text-sm font-normal">
{loadForm.loading_code
? `${loadForm.loading_name} (${loadForm.loading_code})`
: "품목정보에서 적재함을 선택하세요"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: "var(--radix-popover-trigger-width)" }}>
<Command filter={(value, search) => {
const item = loadItemOptions.find((i) => i.id === value);
if (!item) return 0;
const target = `${item.item_number} ${item.item_name} ${item.size || ""}`.toLowerCase();
return target.includes(search.toLowerCase()) ? 1 : 0;
}}>
<CommandInput placeholder="품목코드 / 품목명 검색..." />
<CommandList className="max-h-[200px]">
<CommandEmpty> </CommandEmpty>
{loadItemOptions.map((item) => (
<CommandItem key={item.id} value={item.id} onSelect={() => onLoadItemSelect(item)} className="text-xs">
<Check className={cn("mr-2 h-3 w-3", loadForm.loading_code === item.item_number ? "opacity-100" : "opacity-0")} />
<span className="font-medium">{item.item_name}</span>
<span className="ml-2 text-muted-foreground">{item.item_number}</span>
{item.size && <span className="ml-auto text-[10px] text-muted-foreground">{item.size}</span>}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div><Label className="text-xs"></Label><Input value={loadForm.loading_code || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div><Label className="text-xs"></Label><Input value={loadForm.loading_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
<div>
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<Select value={loadForm.loading_type || ""} onValueChange={(v) => setLoadForm((p) => ({ ...p, loading_type: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>{Object.entries(LOADING_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={loadForm.status || "ACTIVE"} onValueChange={(v) => setLoadForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-9 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>{Object.entries(STATUS_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs font-semibold"></Label>
<div className="grid grid-cols-3 gap-3 mt-2">
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.width_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, width_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.length_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, length_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(mm)</Label><Input type="number" value={loadForm.height_mm || ""} onChange={(e) => setLoadForm((p) => ({ ...p, height_mm: e.target.value }))} className="h-8 text-xs" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={loadForm.self_weight_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, self_weight_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]">(kg)</Label><Input type="number" value={loadForm.max_load_kg || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_load_kg: e.target.value }))} className="h-8 text-xs" step="0.1" /></div>
<div><Label className="text-[10px]"></Label><Input type="number" value={loadForm.max_stack || ""} onChange={(e) => setLoadForm((p) => ({ ...p, max_stack: e.target.value }))} className="h-8 text-xs" /></div>
</div>
</div>
<div><Label className="text-xs"></Label><Input value={loadForm.remarks || ""} onChange={(e) => setLoadForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9 text-xs" placeholder="메모" /></div>
</div>
</FullscreenDialog>
{/* 품목 추가 모달 (포장재 매칭) */}
<Dialog open={itemMatchModalOpen} onOpenChange={setItemMatchModalOpen}>
<DialogContent className="max-w-[900px]">
<DialogHeader>
<DialogTitle> {selectedPkg?.pkg_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input placeholder="품목코드 / 품목명 검색 (입력 시 자동 검색)" value={itemMatchKeyword}
onChange={(e) => {
setItemMatchKeyword(e.target.value);
const kw = e.target.value;
clearTimeout((window as any).__itemMatchTimer);
(window as any).__itemMatchTimer = setTimeout(async () => {
try {
const res = await getGeneralItems(kw || undefined);
if (res.success) setItemMatchResults(res.data);
} catch { /* ignore */ }
}, 300);
}}
className="h-9 text-xs" />
<div className="max-h-[300px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2 w-[30px]" />
<TableHead className="p-2 w-[130px]"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[100px]"></TableHead>
<TableHead className="p-2 w-[80px]"></TableHead>
<TableHead className="p-2 w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : itemMatchResults.filter(i => !pkgItems.some(pi => pi.item_number === i.item_number)).map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer text-xs", itemMatchSelected?.id === item.id && "bg-primary/10")}
onClick={() => setItemMatchSelected(item)}>
<TableCell className="p-2 text-center">{itemMatchSelected?.id === item.id ? "✓" : ""}</TableCell>
<TableCell className="p-2 font-medium truncate max-w-[130px]">{item.item_number}</TableCell>
<TableCell className="p-2 truncate max-w-[200px]">{item.item_name}</TableCell>
<TableCell className="p-2 truncate">{item.spec || "-"}</TableCell>
<TableCell className="p-2 truncate">{item.material || "-"}</TableCell>
<TableCell className="p-2">{item.unit || "EA"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-end gap-4">
<div className="flex-1">
<Label className="text-xs"> </Label>
<Input value={itemMatchSelected ? `${itemMatchSelected.item_name} (${itemMatchSelected.item_number})` : ""} readOnly className="h-9 bg-muted text-xs" />
</div>
<div className="w-[120px]">
<Label htmlFor="pkg-item-match-qty" className="text-xs">(EA) <span className="text-destructive">*</span></Label>
<Input id="pkg-item-match-qty" type="number" value={itemMatchQty} onChange={(e) => setItemMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setItemMatchModalOpen(false)}></Button>
<Button type="button" data-action-type="custom" onClick={saveItemMatch} disabled={saving || !itemMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 포장단위 추가 모달 (적재함 구성) */}
<Dialog open={pkgMatchModalOpen} onOpenChange={setPkgMatchModalOpen}>
<DialogContent className="max-w-[800px]">
<DialogHeader>
<DialogTitle> {selectedLoading?.loading_name}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="포장코드 / 포장명 검색"
value={pkgMatchSearchKw}
onChange={(e) => setPkgMatchSearchKw(e.target.value)}
className="h-9 text-xs"
/>
<div className="max-h-[300px] overflow-auto border rounded">
<Table>
<TableHeader>
<TableRow className="text-[11px]">
<TableHead className="p-2 w-[30px]" />
<TableHead className="p-2 w-[120px]"></TableHead>
<TableHead className="p-2"></TableHead>
<TableHead className="p-2 w-[70px]"></TableHead>
<TableHead className="p-2 w-[100px]">(mm)</TableHead>
<TableHead className="p-2 w-[80px] text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(() => {
const kw = pkgMatchSearchKw.toLowerCase();
const filtered = pkgUnits.filter(p =>
p.status === "ACTIVE"
&& !loadingPkgs.some(lp => lp.pkg_code === p.pkg_code)
&& (!kw || p.pkg_code?.toLowerCase().includes(kw) || p.pkg_name?.toLowerCase().includes(kw))
);
return filtered.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground text-xs h-16"> </TableCell></TableRow>
) : filtered.map((p) => (
<TableRow key={p.id} className={cn("cursor-pointer text-xs", pkgMatchSelected?.id === p.id && "bg-primary/10")}
onClick={() => setPkgMatchSelected(p)}>
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
<TableCell className="p-2">{p.pkg_name}</TableCell>
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
</TableRow>
));
})()}
</TableBody>
</Table>
</div>
<div className="flex items-end gap-4">
<div className="w-[150px]">
<Label htmlFor="loading-pkg-match-qty" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="loading-pkg-match-qty" type="number" value={pkgMatchQty} onChange={(e) => setPkgMatchQty(Number(e.target.value) || 0)} min={1} className="h-9 text-xs" />
</div>
<div className="flex-1">
<Label className="text-xs"></Label>
<Input value={pkgMatchMethod} onChange={(e) => setPkgMatchMethod(e.target.value)} placeholder="수직/수평/혼합" className="h-9 text-xs" />
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setPkgMatchModalOpen(false)}></Button>
<Button type="button" data-action-type="custom" onClick={savePkgMatch} disabled={saving || !pkgMatchSelected}>{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : null} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{ConfirmDialogComponent}
</div>
);
}

View File

@ -18,14 +18,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
@ -693,14 +686,51 @@ export default function ReceivingPage() {
</div>
{/* 입고 등록 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex h-[90vh] max-w-[95vw] flex-col p-0 sm:max-w-[1600px]">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle className="text-lg"> </DialogTitle>
<DialogDescription className="text-xs">
, .
</DialogDescription>
</DialogHeader>
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title="입고 등록"
description="입고유형을 선택하고, 좌측에서 근거 데이터를 검색하여 추가하세요."
defaultMaxWidth="sm:max-w-[1600px]"
defaultWidth="w-[95vw]"
className="h-[90vh] p-0"
footer={
<div className="flex w-full items-center justify-between px-6 py-3">
<div className="text-muted-foreground text-xs">
{selectedItems.length > 0 ? (
<>
{totalSummary.count} | :{" "}
{totalSummary.qty.toLocaleString()} | :{" "}
{totalSummary.amount.toLocaleString()}
</>
) : (
"품목을 추가해주세요"
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-9 text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving || selectedItems.length === 0}
className="h-9 text-sm"
>
{saving ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Save className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</div>
}
>
{/* 입고유형 선택 */}
<div className="flex items-center gap-4 border-b px-6 py-3">
@ -974,43 +1004,7 @@ export default function ReceivingPage() {
</ResizablePanelGroup>
</div>
{/* 푸터 */}
<DialogFooter className="flex items-center justify-between border-t px-6 py-3">
<div className="text-muted-foreground text-xs">
{selectedItems.length > 0 ? (
<>
{totalSummary.count} | :{" "}
{totalSummary.qty.toLocaleString()} | :{" "}
{totalSummary.amount.toLocaleString()}
</>
) : (
"품목을 추가해주세요"
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setIsModalOpen(false)}
className="h-9 text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={saving || selectedItems.length === 0}
className="h-9 text-sm"
>
{saving ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Save className="mr-1 h-4 w-4" />
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</FullscreenDialog>
</div>
);
}

View File

@ -0,0 +1,498 @@
"use client";
/**
*
*
* 좌측: 부서 (dept_info)
* 우측: 선택한 (user_info)
*
* 모달: 부서 (dept_info), (user_info)
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Building2, Users, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
const DEPT_TABLE = "dept_info";
const USER_TABLE = "user_info";
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
{ key: "dept_name", label: "부서명", minWidth: "min-w-[150px]" },
{ key: "parent_dept_code", label: "상위부서", width: "w-[100px]" },
{ key: "status", label: "상태", width: "w-[70px]" },
];
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "sabun", label: "사번", width: "w-[80px]" },
{ key: "user_name", label: "이름", width: "w-[90px]" },
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
{ key: "position_name", label: "직급", width: "w-[80px]" },
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]" },
{ key: "email", label: "이메일", minWidth: "min-w-[150px]" },
];
export default function DepartmentPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 부서
const [depts, setDepts] = useState<any[]>([]);
const [deptLoading, setDeptLoading] = useState(false);
const [deptCount, setDeptCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedDeptId, setSelectedDeptId] = useState<string | null>(null);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 우측: 사원
const [members, setMembers] = useState<any[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
// 부서 모달
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [deptEditMode, setDeptEditMode] = useState(false);
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userEditMode, setUserEditMode] = useState(false);
const [userForm, setUserForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("department");
if (saved) applyTableSettings(saved);
}, []);
// 부서 조회
const fetchDepts = useCallback(async () => {
setDeptLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// dept_info에 id 컬럼이 없으므로 dept_code를 id로 매핑
const data = raw.map((d: any) => ({ ...d, id: d.id || d.dept_code }));
setDepts(data);
setDeptCount(res.data?.data?.total || data.length);
} catch (err) {
console.error("부서 조회 실패:", err);
toast.error("부서 목록을 불러오는데 실패했습니다.");
} finally {
setDeptLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchDepts(); }, [fetchDepts]);
// 선택된 부서
const selectedDept = depts.find((d) => d.id === selectedDeptId);
const selectedDeptCode = selectedDept?.dept_code || null;
// 우측: 사원 조회 (부서 미선택 → 전체, 선택 → 해당 부서)
const fetchMembers = useCallback(async () => {
setMemberLoading(true);
try {
const filters = selectedDeptCode
? [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }]
: [];
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
}, [selectedDeptCode]);
useEffect(() => { fetchMembers(); }, [fetchMembers]);
// 부서 등록
const openDeptRegister = () => {
setDeptForm({});
setDeptEditMode(false);
setDeptModalOpen(true);
};
const openDeptEdit = () => {
if (!selectedDept) return;
setDeptForm({ ...selectedDept });
setDeptEditMode(true);
setDeptModalOpen(true);
};
const handleDeptSave = async () => {
if (!deptForm.dept_name) { toast.error("부서명은 필수입니다."); return; }
setSaving(true);
try {
if (deptEditMode && deptForm.dept_code) {
await apiClient.put(`/table-management/tables/${DEPT_TABLE}/edit`, {
originalData: { dept_code: deptForm.dept_code },
updatedData: { dept_name: deptForm.dept_name, parent_dept_code: deptForm.parent_dept_code || null },
});
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${DEPT_TABLE}/add`, {
dept_code: deptForm.dept_code || "",
dept_name: deptForm.dept_name,
parent_dept_code: deptForm.parent_dept_code || null,
});
toast.success("등록되었습니다.");
}
setDeptModalOpen(false);
fetchDepts();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 부서 삭제
const handleDeptDelete = async () => {
if (!selectedDeptCode) return;
const ok = await confirm("부서를 삭제하시겠습니까?", {
description: "해당 부서에 소속된 사원 정보는 유지됩니다.",
variant: "destructive", confirmText: "삭제",
});
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${DEPT_TABLE}/delete`, {
data: [{ dept_code: selectedDeptCode }],
});
toast.success("삭제되었습니다.");
setSelectedDeptId(null);
fetchDepts();
} catch { toast.error("삭제에 실패했습니다."); }
};
// 사원 추가
const openUserModal = (editData?: any) => {
if (editData) {
setUserEditMode(true);
setUserForm({ ...editData, user_password: "" });
} else {
setUserEditMode(false);
setUserForm({ dept_code: selectedDeptCode || "", user_password: "" });
}
setFormErrors({});
setUserModalOpen(true);
};
const handleUserFormChange = (field: string, value: string) => {
const formatted = formatField(field, value);
setUserForm((prev) => ({ ...prev, [field]: formatted }));
const error = validateField(field, formatted);
setFormErrors((prev) => { const n = { ...prev }; if (error) n[field] = error; else delete n[field]; return n; });
};
const handleUserSave = async () => {
if (!userForm.user_id) { toast.error("사용자 ID는 필수입니다."); return; }
if (!userForm.user_name) { toast.error("사용자 이름은 필수입니다."); return; }
const errors = validateForm(userForm, ["cell_phone", "email"]);
setFormErrors(errors);
if (Object.keys(errors).length > 0) { toast.error("입력 형식을 확인해주세요."); return; }
setSaving(true);
try {
// 비밀번호 미입력 시 기본값 (신규만)
const password = userForm.user_password || (!userEditMode ? "qlalfqjsgh11" : undefined);
await apiClient.post("/admin/users/with-dept", {
userInfo: {
user_id: userForm.user_id,
user_name: userForm.user_name,
user_name_eng: userForm.user_name_eng || undefined,
user_password: password || undefined,
email: userForm.email || undefined,
tel: userForm.tel || undefined,
cell_phone: userForm.cell_phone || undefined,
sabun: userForm.sabun || undefined,
position_name: userForm.position_name || undefined,
dept_code: userForm.dept_code || undefined,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name || undefined,
status: userForm.status || "active",
},
mainDept: userForm.dept_code ? {
dept_code: userForm.dept_code,
dept_name: depts.find((d) => d.dept_code === userForm.dept_code)?.dept_name,
position_name: userForm.position_name || undefined,
} : undefined,
isUpdate: userEditMode,
});
toast.success(userEditMode ? "사원 정보가 수정되었습니다." : "사원이 추가되었습니다.");
setUserModalOpen(false);
fetchMembers();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (depts.length === 0) return;
const data = depts.map((d) => ({
부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status,
}));
await exportToExcel(data, "부서관리.xlsx", "부서");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={DEPT_TABLE}
filterId="department"
onFilterChange={setSearchFilters}
dataCount={deptCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{deptCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={openDeptRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={handleDeptDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
</div>
</div>
<DataGrid
gridId="dept-left"
columns={LEFT_COLUMNS}
data={depts}
loading={deptLoading}
selectedId={selectedDeptId}
onSelect={(id) => {
setSelectedDeptId((prev) => (prev === id ? null : id));
}}
onRowDoubleClick={() => openDeptEdit()}
emptyMessage="등록된 부서가 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 사원 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedDept ? "부서 인원" : "전체 사원"}
{selectedDept && <Badge variant="outline" className="font-normal">{selectedDept.dept_name}</Badge>}
{members.length > 0 && <Badge variant="secondary" className="font-normal">{members.length}</Badge>}
</div>
<Button variant="outline" size="sm" onClick={() => openUserModal()}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
<DataGrid
gridId="dept-right"
columns={RIGHT_COLUMNS}
data={members}
loading={memberLoading}
showRowNumber={false}
tableName={USER_TABLE}
emptyMessage={selectedDeptCode ? "소속 사원이 없습니다" : "등록된 사원이 없습니다"}
onRowDoubleClick={(row) => openUserModal(row)}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 부서 등록/수정 모달 */}
<Dialog open={deptModalOpen} onOpenChange={setDeptModalOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{deptEditMode ? "부서 수정" : "부서 등록"}</DialogTitle>
<DialogDescription>{deptEditMode ? "부서 정보를 수정합니다." : "새로운 부서를 등록합니다."}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={deptForm.dept_code || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_code: e.target.value }))}
placeholder="부서코드" className="h-9" disabled={deptEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={deptForm.dept_name || ""} onChange={(e) => setDeptForm((p) => ({ ...p, dept_name: e.target.value }))}
placeholder="부서명" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={deptForm.parent_dept_code || ""} onValueChange={(v) => setDeptForm((p) => ({ ...p, parent_dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="상위부서 선택 (선택사항)" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{depts.filter((d) => d.dept_code !== deptForm.dept_code).map((d) => (
<SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name} ({d.dept_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeptModalOpen(false)}></Button>
<Button onClick={handleDeptSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 사원 추가 모달 */}
<Dialog open={userModalOpen} onOpenChange={setUserModalOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{userEditMode ? "사원 정보 수정" : "사원 추가"}</DialogTitle>
<DialogDescription>{userEditMode ? `${userForm.user_name} (${userForm.user_id}) 사원 정보를 수정합니다.` : selectedDept ? `${selectedDept.dept_name} 부서에 사원을 추가합니다.` : "사원을 추가합니다."}</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5">
<Label className="text-sm"> ID <span className="text-destructive">*</span></Label>
<Input value={userForm.user_id || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_id: e.target.value }))}
placeholder="사용자 ID" className="h-9" disabled={userEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={userForm.user_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_name: e.target.value }))}
placeholder="이름" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.sabun || ""} onChange={(e) => setUserForm((p) => ({ ...p, sabun: e.target.value }))}
placeholder="사번" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.user_password || ""} onChange={(e) => setUserForm((p) => ({ ...p, user_password: e.target.value }))}
placeholder={userEditMode ? "변경 시에만 입력" : "미입력 시 qlalfqjsgh11"} className="h-9" type="password" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
placeholder="직급" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
<SelectContent>
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={userForm.regdate || ""} onChange={(v) => setUserForm((p) => ({ ...p, regdate: v }))} placeholder="입사일" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={userForm.end_date || ""} onChange={(v) => setUserForm((p) => ({ ...p, end_date: v }))} placeholder="퇴사일" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserModalOpen(false)}></Button>
<Button onClick={handleUserSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DEPT_TABLE}
userId={user?.userId}
onSuccess={() => fetchDepts()}
/>
{ConfirmDialogComponent}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={DEPT_TABLE}
settingsId="department"
onSave={applyTableSettings}
/>
</div>
);
}

View File

@ -0,0 +1,517 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Plus, Trash2, RotateCcw, Save, Search, Loader2, FileSpreadsheet, Download,
Package, Pencil, Copy,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
// 테이블 컬럼 정의
const TABLE_COLUMNS = [
{ key: "item_number", label: "품목코드", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "division", label: "관리품목", width: "w-[100px]" },
{ key: "type", label: "품목구분", width: "w-[100px]" },
{ key: "size", label: "규격", width: "w-[100px]" },
{ key: "unit", label: "단위", width: "w-[80px]" },
{ key: "material", label: "재질", width: "w-[100px]" },
{ key: "status", label: "상태", width: "w-[80px]" },
{ key: "selling_price", label: "판매가격", width: "w-[100px]" },
{ key: "standard_price", label: "기준단가", width: "w-[100px]" },
{ key: "weight", label: "중량", width: "w-[80px]" },
{ key: "inventory_unit", label: "재고단위", width: "w-[80px]" },
{ key: "user_type01", label: "대분류", width: "w-[100px]" },
{ key: "user_type02", label: "중분류", width: "w-[100px]" },
];
// 등록 모달 필드 정의
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
{ key: "weight", label: "중량", type: "text" },
{ key: "volum", label: "부피", type: "text" },
{ key: "specific_gravity", label: "비중", type: "text" },
{ key: "inventory_unit", label: "재고단위", type: "category" },
{ key: "selling_price", label: "판매가격", type: "text" },
{ key: "standard_price", label: "기준단가", type: "text" },
{ key: "currency_code", label: "통화", type: "category" },
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "meno", label: "메모", type: "textarea" },
];
const TABLE_NAME = "item_info";
export default function ItemInfoPage() {
const { user } = useAuth();
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
const [searchDivision, setSearchDivision] = useState("all");
const [searchType, setSearchType] = useState("all");
const [searchStatus, setSearchStatus] = useState("all");
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<Record<string, any>>({});
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 카테고리 옵션 (API에서 로드)
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 카테고리 컬럼 목록
const CATEGORY_COLUMNS = ["division", "type", "unit", "material", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
// 카테고리 옵션 로드 (table_name + column_name 기반)
useEffect(() => {
const loadCategories = async () => {
try {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
await Promise.all(
CATEGORY_COLUMNS.map(async (colName) => {
try {
const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[colName] = flatten(res.data.data);
}
} catch { /* skip */ }
})
);
setCategoryOptions(optMap);
} catch (err) {
console.error("카테고리 로드 실패:", err);
}
};
loadCategories();
}, []);
// 데이터 조회
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = [];
if (searchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
}
if (searchDivision !== "all") {
filters.push({ columnName: "division", operator: "equals", value: searchDivision });
}
if (searchType !== "all") {
filters.push({ columnName: "type", operator: "equals", value: searchType });
}
if (searchStatus !== "all") {
filters.push({ columnName: "status", operator: "equals", value: searchStatus });
}
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// 카테고리 코드→라벨 변환
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setTotalCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchKeyword, searchDivision, searchType, searchStatus, categoryOptions]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
// 카테고리 코드 → 라벨 변환
const getCategoryLabel = (columnName: string, code: string) => {
if (!code) return "";
const opts = categoryOptions[columnName];
if (!opts) return code;
const found = opts.find((o) => o.code === code);
return found?.label || code;
};
// 등록 모달 열기
const openRegisterModal = () => {
setFormData({});
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
};
// 수정 모달 열기
const openEditModal = (item: any) => {
setFormData({ ...item });
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
};
// 복사 모달 열기
const openCopyModal = (item: any) => {
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
setFormData(rest);
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.item_name) {
toast.error("품명은 필수 입력입니다.");
return;
}
setSaving(true);
try {
if (isEditMode && editId) {
// 수정
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
updatedData: updateFields,
});
toast.success("수정되었습니다.");
} else {
// 등록
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, insertFields);
toast.success("등록되었습니다.");
}
setIsModalOpen(false);
fetchItems();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 삭제
const handleDelete = async () => {
if (!selectedId) {
toast.error("삭제할 품목을 선택해주세요.");
return;
}
if (!confirm("선택한 품목을 삭제하시겠습니까?")) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: [{ id: selectedId }],
});
toast.success("삭제되었습니다.");
setSelectedId(null);
fetchItems();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) {
toast.error("다운로드할 데이터가 없습니다.");
return;
}
const exportData = items.map((item) => {
const row: Record<string, any> = {};
for (const col of TABLE_COLUMNS) {
row[col.label] = getCategoryLabel(col.key, item[col.key]) || item[col.key] || "";
}
return row;
});
await exportToExcel(exportData, "품목정보.xlsx", "품목정보");
toast.success("엑셀 다운로드 완료");
};
// 검색 초기화
const handleResetSearch = () => {
setSearchKeyword("");
setSearchDivision("all");
setSearchType("all");
setSearchStatus("all");
};
// 카테고리 셀렉트 렌더링
const renderCategorySelect = (field: typeof FORM_FIELDS[0]) => {
const options = categoryOptions[field.key] || [];
return (
<Select
value={formData[field.key] || ""}
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={`${field.label} 선택`} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.code} value={opt.code}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<Card className="shrink-0">
<CardContent className="p-4 flex flex-wrap items-end gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">/</Label>
<Input
placeholder="검색"
className="w-[180px] h-9"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchItems()}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchDivision} onValueChange={setSearchDivision}>
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchType} onValueChange={setSearchType}>
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["status"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{totalCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openCopyModal(item);
}}>
<Copy className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openEditModal(item);
}}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground gap-2">
<Package className="w-8 h-8 opacity-50" />
<span> </span>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[40px] text-center">No</TableHead>
{TABLE_COLUMNS.map((col) => (
<TableHead key={col.key} className={col.width}>{col.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow
key={item.id}
className={cn("cursor-pointer", selectedId === item.id && "bg-primary/5")}
onClick={() => setSelectedId(item.id)}
onDoubleClick={() => openEditModal(item)}
>
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
{TABLE_COLUMNS.map((col) => (
<TableCell key={col.key} className="text-sm">
{["division", "type", "unit", "material", "status", "inventory_unit", "user_type01", "user_type02", "currency_code"].includes(col.key)
? getCategoryLabel(col.key, item[col.key])
: item[col.key] || ""}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-auto">
<DialogHeader>
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
<DialogDescription>
{isEditMode ? "품목 정보를 수정합니다." : "새로운 품목을 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
{FORM_FIELDS.map((field) => (
<div key={field.key} className={cn("space-y-1.5", field.type === "textarea" && "col-span-2")}>
<Label className="text-sm">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "category" ? (
renderCategorySelect(field)
) : field.type === "textarea" ? (
<Textarea
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.label}
rows={3}
/>
) : (
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.disabled ? field.placeholder : field.label}
disabled={field.disabled && !isEditMode}
className="h-9"
/>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={TABLE_NAME}
userId={user?.userId}
onSuccess={() => {
fetchItems();
}}
/>
</div>
);
}

View File

@ -0,0 +1,534 @@
"use client";
/**
*
*
* 좌측: 품목 (subcontractor_item_mapping , item_info )
* 우측: 선택한 (subcontractor_item_mapping subcontractor_mng )
*
* ( subcontractor_item_mapping )
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, Settings2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "subcontractor_item_mapping";
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
// 좌측: 품목 컬럼
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[90px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// 우측: 외주업체 정보 컬럼
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "subcontractor_code", label: "외주업체코드", width: "w-[110px]" },
{ key: "subcontractor_name", label: "외주업체명", minWidth: "min-w-[120px]" },
{ key: "subcontractor_item_code", label: "외주품번", width: "w-[100px]" },
{ key: "subcontractor_item_name", label: "외주품명", width: "w-[100px]" },
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
];
export default function SubcontractorItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 우측: 외주업체
const [subcontractorItems, setSubcontractorItems] = useState<any[]>([]);
const [subcontractorLoading, setSubcontractorLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 외주업체 추가 모달
const [subSelectOpen, setSubSelectOpen] = useState(false);
const [subSearchKeyword, setSubSearchKeyword] = useState("");
const [subSearchResults, setSubSearchResults] = useState<any[]>([]);
const [subSearchLoading, setSubSearchLoading] = useState(false);
const [subCheckedIds, setSubCheckedIds] = useState<Set<string>>(new Set());
// 품목 수정 모달
const [editItemOpen, setEditItemOpen] = useState(false);
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
useEffect(() => {
const saved = loadTableSettings("subcontractor-item");
if (saved) applyTableSettings(saved);
}, []);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
const outsourcingDivisionCode = categoryOptions["division"]?.find(
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
)?.code;
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters: any[] = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
// division = 외주관리 필터 추가
if (outsourcingDivisionCode) {
filters.push({ columnName: "division", operator: "equals", value: outsourcingDivisionCode });
}
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setItemCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setItemLoading(false);
}
}, [searchFilters, categoryOptions, outsourcingDivisionCode]);
useEffect(() => { fetchItems(); }, [fetchItems]);
// 선택된 품목
const selectedItem = items.find((i) => i.id === selectedItemId);
// 우측: 외주업체 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) { setSubcontractorItems([]); return; }
const itemKey = selectedItem.item_number;
const fetchSubcontractorItems = async () => {
setSubcontractorLoading(true);
try {
// subcontractor_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
// subcontractor_id → subcontractor_mng 조인 (외주업체명)
const subIds = [...new Set(mappings.map((m: any) => m.subcontractor_id).filter(Boolean))];
let subMap: Record<string, any> = {};
if (subIds.length > 0) {
try {
const subRes = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: subIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "subcontractor_code", operator: "in", value: subIds }] },
autoFilter: true,
});
for (const s of (subRes.data?.data?.data || subRes.data?.data?.rows || [])) {
subMap[s.subcontractor_code] = s;
}
} catch { /* skip */ }
}
setSubcontractorItems(mappings.map((m: any) => ({
...m,
subcontractor_code: m.subcontractor_id,
subcontractor_name: subMap[m.subcontractor_id]?.subcontractor_name || "",
})));
} catch (err) {
console.error("외주업체 조회 실패:", err);
} finally {
setSubcontractorLoading(false);
}
};
fetchSubcontractorItems();
}, [selectedItem?.item_number]);
// 외주업체 검색
const searchSubcontractors = async () => {
setSubSearchLoading(true);
try {
const filters: any[] = [];
if (subSearchKeyword) filters.push({ columnName: "subcontractor_name", operator: "contains", value: subSearchKeyword });
const res = await apiClient.post(`/table-management/tables/${SUBCONTRACTOR_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const all = res.data?.data?.data || res.data?.data?.rows || [];
// 이미 등록된 외주업체 제외
const existing = new Set(subcontractorItems.map((s: any) => s.subcontractor_id || s.subcontractor_code));
setSubSearchResults(all.filter((s: any) => !existing.has(s.subcontractor_code)));
} catch { /* skip */ } finally { setSubSearchLoading(false); }
};
// 외주업체 추가 저장
const addSelectedSubcontractors = async () => {
const selected = subSearchResults.filter((s) => subCheckedIds.has(s.id));
if (selected.length === 0 || !selectedItem) return;
try {
for (const sub of selected) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
subcontractor_id: sub.subcontractor_code,
item_id: selectedItem.item_number,
});
}
toast.success(`${selected.length}개 외주업체가 추가되었습니다.`);
setSubCheckedIds(new Set());
setSubSelectOpen(false);
// 우측 새로고침
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "외주업체 추가에 실패했습니다.");
}
};
// 품목 수정
const openEditItem = () => {
if (!selectedItem) return;
setEditItemForm({ ...selectedItem });
setEditItemOpen(true);
};
const handleEditSave = async () => {
if (!editItemForm.id) return;
setSaving(true);
try {
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editItemForm.id },
updatedData: {
selling_price: editItemForm.selling_price || null,
standard_price: editItemForm.standard_price || null,
currency_code: editItemForm.currency_code || null,
},
});
toast.success("수정되었습니다.");
setEditItemOpen(false);
fetchItems();
} catch (err: any) {
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
const data = items.map((i) => ({
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
}));
await exportToExcel(data, "외주품목정보.xlsx", "외주품목");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={ITEM_TABLE}
filterId="subcontractor-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 외주품목 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{itemCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<DataGrid
gridId="subcontractor-item-left"
columns={LEFT_COLUMNS}
data={items}
loading={itemLoading}
selectedId={selectedItemId}
onSelect={setSelectedItemId}
onRowDoubleClick={() => openEditItem()}
emptyMessage="등록된 외주품목이 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 외주업체 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setSubCheckedIds(new Set()); setSubSelectOpen(true); searchSubcontractors(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedItemId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="subcontractor-item-right"
columns={RIGHT_COLUMNS}
data={subcontractorItems}
loading={subcontractorLoading}
showRowNumber={false}
emptyMessage="등록된 외주업체가 없습니다"
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 품목 수정 모달 */}
<FullscreenDialog
open={editItemOpen}
onOpenChange={setEditItemOpen}
title="외주품목 수정"
description={`${editItemForm.item_number || ""}${editItemForm.item_name || ""}`}
defaultMaxWidth="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setEditItemOpen(false)}></Button>
<Button onClick={handleEditSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-4 py-4">
{[
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
].map((f) => (
<div key={f.key} className="space-y-1.5">
<Label className="text-sm text-muted-foreground">{f.label}</Label>
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
</div>
))}
<div className="col-span-2 border-t my-2" />
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
placeholder="판매가격" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
placeholder="기준단가" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</FullscreenDialog>
{/* 외주업체 추가 모달 */}
<Dialog open={subSelectOpen} onOpenChange={setSubSelectOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="외주업체명 검색" value={subSearchKeyword}
onChange={(e) => setSubSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchSubcontractors()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchSubcontractors} disabled={subSearchLoading} className="h-9">
{subSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={subSearchResults.length > 0 && subCheckedIds.size === subSearchResults.length}
onChange={(e) => {
if (e.target.checked) setSubCheckedIds(new Set(subSearchResults.map((s) => s.id)));
else setSubCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : subSearchResults.map((s) => (
<TableRow key={s.id} className={cn("cursor-pointer", subCheckedIds.has(s.id) && "bg-primary/5")}
onClick={() => setSubCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(s.id)) next.delete(s.id); else next.add(s.id);
return next;
})}>
<TableCell className="text-center"><input type="checkbox" checked={subCheckedIds.has(s.id)} readOnly /></TableCell>
<TableCell className="text-xs">{s.subcontractor_code}</TableCell>
<TableCell className="text-sm">{s.subcontractor_name}</TableCell>
<TableCell className="text-xs">{s.division}</TableCell>
<TableCell className="text-xs">{s.contact_person}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{subCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setSubSelectOpen(false)}></Button>
<Button onClick={addSelectedSubcontractors} disabled={subCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {subCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={ITEM_TABLE}
userId={user?.userId}
onSuccess={() => fetchItems()}
/>
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={ITEM_TABLE}
settingsId="subcontractor-item"
onSave={applyTableSettings}
/>
{ConfirmDialogComponent}
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -733,15 +733,17 @@ export default function WorkInstructionPage() {
<div className="max-h-[280px] overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead className="w-[100px] text-right"></TableHead><TableHead></TableHead><TableHead className="w-[60px]" /></TableRow>
<TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[100px] text-right"></TableHead><TableHead></TableHead><TableHead className="w-[60px]" /></TableRow>
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow>
<TableRow><TableCell colSpan={7} className="text-center py-8 text-muted-foreground text-sm"> </TableCell></TableRow>
) : editItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-xs text-center">{idx + 1}</TableCell>
<TableCell className="text-xs font-medium">{item.itemCode}</TableCell>
<TableCell className="text-xs max-w-[180px] truncate" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-xs max-w-[100px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>
<TableCell className="text-right"><Input type="number" className="h-7 text-xs w-20 ml-auto" value={item.qty} onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} /></TableCell>
<TableCell><Input className="h-7 text-xs" value={item.remark} placeholder="비고" onChange={e => setEditItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setEditItems(prev => prev.filter((_, i) => i !== idx))}><X className="w-3 h-3 text-destructive" /></Button></TableCell>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,947 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
// Card, CardContent 제거 — DynamicSearchFilter가 대체
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
const DETAIL_TABLE = "sales_order_detail";
// 천단위 구분자 표시용 (입력 중에는 콤마 포함 표시, 저장 시 숫자만)
const formatNumber = (val: string) => {
const num = val.replace(/[^\d.-]/g, "");
if (!num) return "";
const parts = num.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
};
const parseNumber = (val: string) => val.replace(/,/g, "");
const MASTER_TABLE = "sales_order_mng";
// 메인 목록 테이블 컬럼 (sales_order_detail 기준)
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "order_no", label: "수주번호", width: "w-[120px]" },
{ key: "part_code", label: "품번", width: "w-[120px]", editable: true },
{ key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true },
{ key: "spec", label: "규격", width: "w-[120px]", editable: true },
{ key: "unit", label: "단위", width: "w-[70px]", editable: true },
{ key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
{ key: "ship_qty", label: "출하수량", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
{ key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" },
{ key: "due_date", label: "납기일", width: "w-[110px]" },
{ key: "memo", label: "메모", width: "w-[100px]", editable: true },
];
// 조건부 레이어 설정 (input_mode, sell_mode에 따라 표시 필드가 달라짐)
// Zone 10: input_mode → 공급업체우선(CAT_MLZWPH5R_983R) / 품목우선(CAT_MLZWPUQC_PB8Z)
// Zone 17: sell_mode → 해외판매(CAT_MLZWFF2Z_BQCV)
export default function SalesOrderPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
// isModalFullscreen 제거됨 — FullscreenDialog 사용
const [isEditMode, setIsEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
const [detailRows, setDetailRows] = useState<any[]>([]);
// 품목 선택 모달 (리피터에서 품목 추가용)
const [itemSelectOpen, setItemSelectOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 출하계획 모달
const [shippingPlanOpen, setShippingPlanOpen] = useState(false);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 체크된 행 (다중선택)
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 테이블 설정
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [gridColumns, setGridColumns] = useState<DataGridColumn[]>(GRID_COLUMNS);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 테이블 설정 적용 (컬럼 + 필터)
const applyTableSettings = useCallback((settings: TableSettings) => {
// 컬럼 표시/숨김/순서/너비
const colMap = new Map(GRID_COLUMNS.map((c) => [c.key, c]));
const applied: DataGridColumn[] = [];
for (const cs of settings.columns) {
if (!cs.visible) continue;
const orig = colMap.get(cs.columnName);
if (orig) {
applied.push({ ...orig, width: `w-[${cs.width}px]`, minWidth: undefined });
}
}
const settingKeys = new Set(settings.columns.map((c) => c.columnName));
for (const col of GRID_COLUMNS) {
if (!settingKeys.has(col.key)) applied.push(col);
}
setGridColumns(applied.length > 0 ? applied : GRID_COLUMNS);
// 필터 설정 → DynamicSearchFilter에 전달
setFilterConfig(settings.filters);
}, []);
// 마운트 시 저장된 설정 복원
useEffect(() => {
const saved = loadTableSettings("sales-order");
if (saved) applyTableSettings(saved);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 카테고리 로드
useEffect(() => {
const loadCategories = async () => {
const catColumns = ["sell_mode", "input_mode", "price_mode", "incoterms", "payment_term"];
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// 라벨 치환 + 중복 제거 (같은 label이면 첫 번째만 유지)
const LABEL_REPLACE: Record<string, string> = {
"공급업체 우선": "거래처 우선",
"공급업체우선": "거래처 우선",
};
const dedup = (items: { code: string; label: string }[]) => {
const seen = new Set<string>();
return items
.map((item) => ({ ...item, label: LABEL_REPLACE[item.label] || item.label }))
.filter((item) => {
const key = item.label.replace(/\s/g, "");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
await Promise.all(
catColumns.map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[col] = dedup(flatten(res.data.data));
}
} catch { /* skip */ }
})
);
// 거래처 목록도 로드
try {
const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 500, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
} catch { /* skip */ }
// 사용자 목록 로드 (담당자 선택용)
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager_id"] = users.map((u: any) => ({
code: u.user_id || u.id,
label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`,
}));
} catch { /* skip */ }
// item_info 카테고리도 로드 (unit, material 등 코드→라벨 변환용)
for (const col of ["unit", "material", "division", "type"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[`item_${col}`] = flatten(res.data.data);
}
} catch { /* skip */ }
}
setCategoryOptions(optMap);
};
loadCategories();
}, []);
// 데이터 조회
const fetchOrders = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = searchFilters.map((f) => ({
columnName: f.columnName,
operator: f.operator,
value: f.value,
}));
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "order_no", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// part_code → item_info 조인 (품명/규격이 비어있는 경우 보강)
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
let itemMap: Record<string, any> = {};
if (partCodes.length > 0) {
try {
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: partCodes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: partCodes }] },
autoFilter: true,
});
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
for (const item of items) {
itemMap[item.item_number] = item;
}
} catch { /* skip */ }
}
// 조인 적용 + 카테고리 코드→라벨 변환
const resolveLabel = (key: string, code: string) => {
if (!code) return "";
const opts = categoryOptions[key];
if (!opts) return code;
return opts.find((o) => o.code === code)?.label || code;
};
const data = rows.map((row: any) => {
const item = itemMap[row.part_code];
const rawUnit = row.unit || item?.unit || "";
return {
...row,
part_name: row.part_name || item?.item_name || "",
spec: row.spec || item?.size || "",
unit: resolveLabel("item_unit", rawUnit) || rawUnit,
};
});
setOrders(data);
setTotalCount(res.data?.data?.total || data.length);
} catch (err) {
console.error("수주 조회 실패:", err);
toast.error("수주 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
const found = categoryOptions[col]?.find((o) => o.code === code);
return found?.label || code;
};
// 등록 모달 열기
// 납품처 목록 (거래처 선택 시 조회)
const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]);
const loadDeliveryOptions = async (customerCode: string) => {
if (!customerCode) { setDeliveryOptions([]); return; }
try {
const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] },
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setDeliveryOptions(rows.map((r: any) => ({
code: r.destination_code || r.id,
label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`,
})));
} catch { setDeliveryOptions([]); }
};
const openRegisterModal = () => {
// 기본값: 각 카테고리의 첫 번째 옵션
const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || "";
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || "";
setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "" });
setDetailRows([]);
setDeliveryOptions([]);
setIsEditMode(false);
setIsModalOpen(true);
};
// 수정 모달 열기 (order_no로 마스터 + 디테일 조회)
const openEditModal = async (orderNo: string) => {
try {
// 마스터 조회
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masterData = (masterRes.data?.data?.data || masterRes.data?.data?.rows || [])[0];
// 디테일 조회
const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
setMasterForm(masterData || {});
setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` })));
setIsEditMode(true);
setIsModalOpen(true);
} catch (err) {
console.error("수주 상세 조회 실패:", err);
toast.error("수주 정보를 불러오는데 실패했습니다.");
}
};
// 삭제 (다중 선택)
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
const ok = await confirm(`${checkedIds.length}건의 수주 데이터를 삭제하시겠습니까?`, {
description: "삭제된 데이터는 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
// 선택된 디테일 행 삭제
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: checkedIds.map((id) => ({ id })),
});
// 해당 수주번호의 남은 디테일이 없으면 마스터도 삭제
for (const orderNo of orderNos) {
const remaining = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const rows = remaining.data?.data?.data || remaining.data?.data?.rows || [];
if (rows.length === 0) {
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
});
}
}
}
toast.success("삭제되었습니다.");
setCheckedIds([]);
fetchOrders();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
// 저장 (마스터 + 디테일)
const handleSave = async () => {
if (!masterForm.order_no && !isEditMode) {
toast.error("수주번호는 필수입니다.");
return;
}
if (detailRows.length === 0) {
toast.error("품목을 1개 이상 추가해주세요.");
return;
}
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm;
if (isEditMode && id) {
// 마스터 수정
await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, {
originalData: { id },
updatedData: masterFields,
});
// 기존 디테일 삭제 후 재삽입
const existingDetails = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: masterForm.order_no }] },
autoFilter: true,
});
const existings = existingDetails.data?.data?.data || existingDetails.data?.data?.rows || [];
if (existings.length > 0) {
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: existings.map((d: any) => ({ id: d.id })),
});
}
} else {
// 마스터 등록
await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields);
}
// 디테일 등록
for (const row of detailRows) {
const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
...detailFields,
order_no: masterForm.order_no,
});
}
toast.success(isEditMode ? "수정되었습니다." : "등록되었습니다.");
setIsModalOpen(false);
fetchOrders();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 품목 검색 (리피터에서 추가)
const searchItems = async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [];
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
};
const addSelectedItemsToDetail = async () => {
const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id));
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
// 단가방식에 따라 단가 조회
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
const today = new Date().toISOString().split("T")[0];
// 거래처별 단가 조회 (선택된 품목들에 대해)
let customerPriceMap: Record<string, string> = {};
if (isCustomerPrice && partnerId) {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
dataFilter: {
enabled: true,
filters: [
{ columnName: "customer_id", operator: "equals", value: partnerId },
{ columnName: "item_id", operator: "in", value: itemIds },
],
},
autoFilter: true,
});
const mappings = res.data?.data?.data || res.data?.data?.rows || [];
for (const m of mappings) {
// calculated_price 우선, 없으면 current_unit_price
const price = m.calculated_price || m.current_unit_price || "";
if (price) customerPriceMap[m.item_id] = String(price);
}
} catch (err) {
console.error("거래처별 단가 조회 실패:", err);
}
}
const newRows = selected.map((item) => {
const itemCode = item.item_number || item.id;
let unitPrice = "";
if (isStandardPrice) {
// 기준단가: item_info의 standard_price 또는 selling_price
unitPrice = item.standard_price || item.selling_price || "";
} else if (isCustomerPrice && partnerId) {
// 거래처별 단가
unitPrice = customerPriceMap[itemCode] || "";
}
return {
_id: `new_${Date.now()}_${Math.random()}`,
part_code: itemCode,
part_name: item.item_name,
spec: item.size || "",
material: getCategoryLabel("item_material", item.material) || item.material || "",
unit: getCategoryLabel("item_unit", item.unit) || item.unit || "",
qty: "",
unit_price: unitPrice,
amount: "",
due_date: "",
};
});
setDetailRows((prev) => [...prev, ...newRows]);
toast.success(`${selected.length}개 품목이 추가되었습니다.`);
setItemCheckedIds(new Set());
setItemSelectOpen(false);
};
const updateDetailRow = (idx: number, field: string, value: string) => {
setDetailRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value };
// 수량 × 단가 = 금액 자동 계산
if (field === "qty" || field === "unit_price") {
const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0;
const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0;
next[idx].amount = (qty * price).toString();
}
return next;
});
};
const removeDetailRow = (idx: number) => {
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
// input_mode 값으로 레이어 판단
// 거래처 우선 (구: 공급업체 우선) - 두 코드 모두 지원
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
const isItemFirst = masterForm.input_mode === "CAT_MLZWPUQC_PB8Z" || masterForm.input_mode === "CAT_MLKG5FZO_HS1B";
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
const data = orders.map((o) => {
const row: Record<string, any> = {};
for (const col of GRID_COLUMNS) row[col.label] = o[col.key] || "";
return row;
});
await exportToExcel(data, "수주관리.xlsx", "수주목록");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 필터 (사용자 설정 가능) */}
<DynamicSearchFilter
tableName={DETAIL_TABLE}
filterId="sales-order"
onFilterChange={setSearchFilters}
dataCount={totalCount}
externalFilterConfig={filterConfig}
/>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<ClipboardList className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{totalCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={checkedIds.length !== 1} onClick={() => {
const item = orders.find((o) => o.id === checkedIds[0]);
if (item) openEditModal(item.order_no);
}}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
<Button variant="outline" size="sm" disabled={checkedIds.length === 0} onClick={() => setShippingPlanOpen(true)}>
<Truck className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
<Button variant="outline" size="sm" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<DataGrid
gridId="sales-order"
columns={gridColumns}
data={orders}
loading={loading}
showCheckbox
showRowNumber={false}
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row.order_no)}
tableName={DETAIL_TABLE}
emptyMessage="등록된 수주가 없습니다"
onCellEdit={() => fetchOrders()}
/>
</div>
{/* 수주 등록/수정 모달 */}
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={isEditMode ? "수주 수정" : "수주 등록"}
description={isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."}
footer={
<>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="space-y-4 py-2">
{/* 기본 레이어 (항상 표시) */}
<div className="grid grid-cols-4 gap-4">
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={masterForm.order_no || ""} onChange={(e) => setMasterForm((p) => ({ ...p, order_no: e.target.value }))}
placeholder="수주번호" className="h-9" disabled={isEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={masterForm.order_date || ""} onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} placeholder="수주일" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={masterForm.sell_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, sell_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["sell_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["input_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["price_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{/* 레이어 2: 거래처 우선 (거래처, 담당자, 납품처, 납품장소) */}
{isSupplierFirst && (
<div className="grid grid-cols-4 gap-4 border-t pt-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.partner_id || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }}>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["manager_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{deliveryOptions.length > 0 ? (
<Select value={masterForm.delivery_partner_id || ""} onValueChange={(v) => {
setMasterForm((p) => ({ ...p, delivery_partner_id: v }));
// 선택한 납품처의 주소를 자동 입력
const found = deliveryOptions.find((o) => o.code === v);
if (found) {
const addr = found.label.match(/\((.+)\)$/)?.[1] || "";
if (addr) setMasterForm((p) => ({ ...p, delivery_partner_id: v, delivery_address: addr }));
}
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="납품처 선택" /></SelectTrigger>
<SelectContent>
{deliveryOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input value={masterForm.delivery_partner_id || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))}
placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} />
)}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.delivery_address || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))}
placeholder="납품장소" className="h-9" />
</div>
</div>
)}
{/* 레이어 4: 해외판매 (인코텀즈, 결제조건, 통화, 선적항, 도착항, HS코드) */}
{isOverseas && (
<div className="grid grid-cols-3 gap-4 border-t pt-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.incoterms || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, incoterms: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["incoterms"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.payment_term || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, payment_term: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["payment_term"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.currency || ""} onChange={(e) => setMasterForm((p) => ({ ...p, currency: e.target.value }))}
placeholder="KRW" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.port_of_loading || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_loading: e.target.value }))}
className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.port_of_discharge || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_discharge: e.target.value }))}
className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm">HS Code</Label>
<Input value={masterForm.hs_code || ""} onChange={(e) => setMasterForm((p) => ({ ...p, hs_code: e.target.value }))}
className="h-9" />
</div>
</div>
)}
{/* 리피터 그리드 (품목 목록) — 레이어 2,3 공통 */}
<div className="border rounded-lg">
<div className="flex items-center justify-between p-3 border-b bg-muted/10">
<span className="text-sm font-semibold"> </span>
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="overflow-auto max-h-[300px]">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_code}>{row.part_code}</span></TableCell>
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-xs">{row.spec}</TableCell>
<TableCell className="text-xs">{row.unit}</TableCell>
<TableCell>
<Input value={formatNumber(row.qty || "")} onChange={(e) => updateDetailRow(idx, "qty", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
</TableCell>
<TableCell>
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
</TableCell>
<TableCell className="text-sm text-right font-medium">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell>
<FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* 메모 */}
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.memo || ""} onChange={(e) => setMasterForm((p) => ({ ...p, memo: e.target.value }))}
placeholder="메모" className="h-9" />
</div>
</div>
{/* 품목 선택 모달 (등록 모달 내부에 중첩) */}
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
<DialogContent className="max-w-3xl max-h-[70vh]" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="품명/품목코드 검색" value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={itemSearchResults.length > 0 && itemCheckedIds.size === itemSearchResults.length}
onChange={(e) => {
if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id)));
else setItemCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[130px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : itemSearchResults.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
onClick={() => setItemCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
})}>
<TableCell className="text-center">
<input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly />
</TableCell>
<TableCell className="text-xs max-w-[130px]"><span className="block truncate" title={item.item_number}>{item.item_number}</span></TableCell>
<TableCell className="text-sm max-w-[150px]"><span className="block truncate" title={item.item_name}>{item.item_name}</span></TableCell>
<TableCell className="text-xs">{item.size}</TableCell>
<TableCell className="text-xs">{item.material}</TableCell>
<TableCell className="text-xs">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{itemCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(false); }}></Button>
<Button onClick={addSelectedItemsToDetail} disabled={itemCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {itemCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</FullscreenDialog>
{/* 출하계획 동시 등록 모달 */}
<ShippingPlanBatchModal
open={shippingPlanOpen}
onOpenChange={setShippingPlanOpen}
selectedDetailIds={checkedIds}
onSuccess={fetchOrders}
/>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DETAIL_TABLE}
userId={user?.userId}
onSuccess={() => fetchOrders()}
/>
{/* 테이블 설정 모달 */}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={DETAIL_TABLE}
settingsId="sales-order"
onSave={applyTableSettings}
/>
{/* 공통 확인 다이얼로그 */}
{ConfirmDialogComponent}
</div>
);
}

View File

@ -0,0 +1,917 @@
"use client";
/**
*
*
* 좌측: 판매품목 (item_info, )
* 우측: 선택한 (customer_item_mapping customer_mng )
*
* ( customer_item_mapping )
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil, Package, Users, Search, X, Settings2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "customer_item_mapping";
const CUSTOMER_TABLE = "customer_mng";
// 좌측: 판매품목 컬럼
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[90px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// 우측: 거래처 정보 컬럼
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "customer_code", label: "거래처코드", width: "w-[110px]" },
{ key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" },
{ key: "customer_item_code", label: "거래처품번", width: "w-[100px]" },
{ key: "customer_item_name", label: "거래처품명", width: "w-[100px]" },
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
];
export default function SalesItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 테이블 설정
const [tableSettingsOpen, setTableSettingsOpen] = useState(false);
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"] | undefined>();
// 우측: 거래처
const [customerItems, setCustomerItems] = useState<any[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 거래처 추가 모달
const [custSelectOpen, setCustSelectOpen] = useState(false);
const [custSearchKeyword, setCustSearchKeyword] = useState("");
const [custSearchResults, setCustSearchResults] = useState<any[]>([]);
const [custSearchLoading, setCustSearchLoading] = useState(false);
const [custCheckedIds, setCustCheckedIds] = useState<Set<string>>(new Set());
// 품목 수정 모달
const [editItemOpen, setEditItemOpen] = useState(false);
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 거래처 상세 입력 모달 (거래처 품번/품명 + 단가)
const [custDetailOpen, setCustDetailOpen] = useState(false);
const [selectedCustsForDetail, setSelectedCustsForDetail] = useState<any[]>([]);
const [custMappings, setCustMappings] = useState<Record<string, Array<{ _id: string; customer_item_code: string; customer_item_name: string }>>>({});
const [custPrices, setCustPrices] = useState<Record<string, Array<{
_id: string; start_date: string; end_date: string; currency_code: string;
base_price_type: string; base_price: string; discount_type: string;
discount_value: string; rounding_type: string; rounding_unit_value: string;
calculated_price: string;
}>>>({});
const [priceCategoryOptions, setPriceCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
const [editCustData, setEditCustData] = useState<any>(null);
// 테이블 설정 적용 (필터)
const applyTableSettings = useCallback((settings: TableSettings) => {
setFilterConfig(settings.filters);
}, []);
// 마운트 시 저장된 설정 복원
useEffect(() => {
const saved = loadTableSettings("sales-item");
if (saved) applyTableSettings(saved);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
for (const col of ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"]) {
try {
const res = await apiClient.get(`/table-categories/${ITEM_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCategoryOptions(optMap);
// 단가 카테고리
const priceOpts: Record<string, { code: string; label: string }[]> = {};
for (const col of ["base_price_type", "currency_code", "discount_type", "rounding_type", "rounding_unit_value"]) {
try {
const res = await apiClient.get(`/table-categories/customer_item_prices/${col}/values`);
if (res.data?.success) priceOpts[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setPriceCategoryOptions(priceOpts);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 좌측: 품목 조회
const fetchItems = useCallback(async () => {
setItemLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
const CATS = ["unit", "material", "division", "type", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setItemCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setItemLoading(false);
}
}, [searchFilters, categoryOptions]);
useEffect(() => { fetchItems(); }, [fetchItems]);
// 선택된 품목
const selectedItem = items.find((i) => i.id === selectedItemId);
// 우측: 거래처 목록 조회
useEffect(() => {
if (!selectedItem?.item_number) { setCustomerItems([]); return; }
const itemKey = selectedItem.item_number;
const fetchCustomerItems = async () => {
setCustomerLoading(true);
try {
// customer_item_mapping에서 해당 품목의 매핑 조회
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "item_id", operator: "equals", value: itemKey }] },
autoFilter: true,
});
const mappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
// customer_id → customer_mng 조인 (거래처명)
const custIds = [...new Set(mappings.map((m: any) => m.customer_id).filter(Boolean))];
let custMap: Record<string, any> = {};
if (custIds.length > 0) {
try {
const custRes = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
page: 1, size: custIds.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "in", value: custIds }] },
autoFilter: true,
});
for (const c of (custRes.data?.data?.data || custRes.data?.data?.rows || [])) {
custMap[c.customer_code] = c;
}
} catch { /* skip */ }
}
setCustomerItems(mappings.map((m: any) => ({
...m,
customer_code: m.customer_id,
customer_name: custMap[m.customer_id]?.customer_name || "",
})));
} catch (err) {
console.error("거래처 조회 실패:", err);
} finally {
setCustomerLoading(false);
}
};
fetchCustomerItems();
}, [selectedItem?.item_number]);
// 거래처 검색
const searchCustomers = async () => {
setCustSearchLoading(true);
try {
const filters: any[] = [];
if (custSearchKeyword) filters.push({ columnName: "customer_name", operator: "contains", value: custSearchKeyword });
const res = await apiClient.post(`/table-management/tables/${CUSTOMER_TABLE}/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const all = res.data?.data?.data || res.data?.data?.rows || [];
// 이미 등록된 거래처 제외
const existing = new Set(customerItems.map((c: any) => c.customer_id || c.customer_code));
setCustSearchResults(all.filter((c: any) => !existing.has(c.customer_code)));
} catch { /* skip */ } finally { setCustSearchLoading(false); }
};
// 거래처 선택 → 상세 모달로 이동
const goToCustDetail = () => {
const selected = custSearchResults.filter((c) => custCheckedIds.has(c.id));
if (selected.length === 0) { toast.error("거래처를 선택해주세요."); return; }
setSelectedCustsForDetail(selected);
const mappings: typeof custMappings = {};
const prices: typeof custPrices = {};
for (const cust of selected) {
const key = cust.customer_code || cust.id;
mappings[key] = [];
prices[key] = [{
_id: `p_${Date.now()}_${Math.random()}`,
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
calculated_price: selectedItem?.standard_price || selectedItem?.selling_price || "",
}];
}
setCustMappings(mappings);
setCustPrices(prices);
setCustSelectOpen(false);
setCustDetailOpen(true);
};
const addMappingRow = (custKey: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: [...(prev[custKey] || []), { _id: `m_${Date.now()}_${Math.random()}`, customer_item_code: "", customer_item_name: "" }],
}));
};
const removeMappingRow = (custKey: string, rowId: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
}));
};
const updateMappingRow = (custKey: string, rowId: string, field: string, value: string) => {
setCustMappings((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r),
}));
};
const addPriceRow = (custKey: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: [...(prev[custKey] || []), {
_id: `p_${Date.now()}_${Math.random()}`,
start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "",
discount_type: "", discount_value: "", rounding_type: "", rounding_unit_value: "",
calculated_price: "",
}],
}));
};
const removePriceRow = (custKey: string, rowId: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).filter((r) => r._id !== rowId),
}));
};
const updatePriceRow = (custKey: string, rowId: string, field: string, value: string) => {
setCustPrices((prev) => ({
...prev,
[custKey]: (prev[custKey] || []).map((r) => {
if (r._id !== rowId) return r;
const updated = { ...r, [field]: value };
if (["base_price", "discount_type", "discount_value"].includes(field)) {
const bp = Number(updated.base_price) || 0;
const dv = Number(updated.discount_value) || 0;
const dt = updated.discount_type;
let calc = bp;
if (dt === "CAT_MLAMBEC8_URQA") calc = bp * (1 - dv / 100);
else if (dt === "CAT_MLAMBLFM_JTLO") calc = bp - dv;
updated.calculated_price = String(Math.round(calc));
}
return updated;
}),
}));
};
const openEditCust = async (row: any) => {
const custKey = row.customer_code || row.customer_id;
// customer_mng에서 거래처 정보 조회
let custInfo: any = { customer_code: custKey, customer_name: row.customer_name || "" };
try {
const res = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: custKey }] },
autoFilter: true,
});
const found = (res.data?.data?.data || res.data?.data?.rows || [])[0];
if (found) custInfo = found;
} catch { /* skip */ }
const mappingRows = [{
_id: `m_existing_${row.id}`,
customer_item_code: row.customer_item_code || "",
customer_item_name: row.customer_item_name || "",
}].filter((m) => m.customer_item_code || m.customer_item_name);
const priceRows = [{
_id: `p_existing_${row.id}`,
start_date: row.start_date || "",
end_date: row.end_date || "",
currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: row.base_price ? String(row.base_price) : "",
discount_type: row.discount_type || "",
discount_value: row.discount_value ? String(row.discount_value) : "",
rounding_type: row.rounding_type || "",
rounding_unit_value: row.rounding_unit_value || "",
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
}].filter((p) => p.base_price || p.start_date);
if (priceRows.length === 0) {
priceRows.push({
_id: `p_${Date.now()}`, start_date: "", end_date: "", currency_code: "CAT_MLAMDKVN_PZJI",
base_price_type: "CAT_MLAMFGFT_4RZW", base_price: "", discount_type: "", discount_value: "",
rounding_type: "", rounding_unit_value: "", calculated_price: "",
});
}
setSelectedCustsForDetail([custInfo]);
setCustMappings({ [custKey]: mappingRows });
setCustPrices({ [custKey]: priceRows });
setEditCustData(row);
setCustDetailOpen(true);
};
const handleCustDetailSave = async () => {
if (!selectedItem) return;
const isEditingExisting = !!editCustData;
setSaving(true);
try {
for (const cust of selectedCustsForDetail) {
const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || [];
if (isEditingExisting && editCustData?.id) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: editCustData.id },
updatedData: {
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_item_name || "",
},
});
// 기존 prices 삭제 후 재등록
try {
const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "mapping_id", operator: "equals", value: editCustData.id },
]}, autoFilter: true,
});
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
data: existing.map((p: any) => ({ id: p.id })),
});
}
} catch { /* skip */ }
const priceRows = (custPrices[custKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
mapping_id: editCustData.id,
customer_id: custKey,
item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
} else {
// 신규 등록
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_item_name || "",
});
const mappingId = mappingRes.data?.data?.id || null;
for (let mi = 1; mi < mappingRows.length; mi++) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: mappingRows[mi].customer_item_code || "",
customer_item_name: mappingRows[mi].customer_item_name || "",
});
}
const priceRows = (custPrices[custKey] || []).filter((p) =>
(p.base_price && Number(p.base_price) > 0) || p.start_date
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
mapping_id: mappingId || "", customer_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
}
}
toast.success(isEditingExisting ? "수정되었습니다." : `${selectedCustsForDetail.length}개 거래처가 추가되었습니다.`);
setCustDetailOpen(false);
setEditCustData(null);
setCustCheckedIds(new Set());
// 우측 새로고침
const sid = selectedItemId;
setSelectedItemId(null);
setTimeout(() => setSelectedItemId(sid), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 품목 수정
const openEditItem = () => {
if (!selectedItem) return;
setEditItemForm({ ...selectedItem });
setEditItemOpen(true);
};
const handleEditSave = async () => {
if (!editItemForm.id) return;
setSaving(true);
try {
await apiClient.put(`/table-management/tables/${ITEM_TABLE}/edit`, {
originalData: { id: editItemForm.id },
updatedData: {
selling_price: editItemForm.selling_price || null,
standard_price: editItemForm.standard_price || null,
currency_code: editItemForm.currency_code || null,
},
});
toast.success("수정되었습니다.");
setEditItemOpen(false);
fetchItems();
} catch (err: any) {
toast.error(err.response?.data?.message || "수정에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) return;
const data = items.map((i) => ({
품번: i.item_number, 품명: i.item_name, 규격: i.size, 단위: i.unit,
기준단가: i.standard_price, 판매가격: i.selling_price, 통화: i.currency_code, 상태: i.status,
}));
await exportToExcel(data, "판매품목정보.xlsx", "판매품목");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={ITEM_TABLE}
filterId="sales-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
externalFilterConfig={filterConfig}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setTableSettingsOpen(true)}>
<Settings2 className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 판매품목 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{itemCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<DataGrid
gridId="sales-item-left"
columns={LEFT_COLUMNS}
data={items}
loading={itemLoading}
selectedId={selectedItemId}
onSelect={setSelectedItemId}
onRowDoubleClick={() => openEditItem()}
emptyMessage="등록된 판매품목이 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 거래처 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setCustCheckedIds(new Set()); setCustSelectOpen(true); searchCustomers(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedItemId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="sales-item-right"
columns={RIGHT_COLUMNS}
data={customerItems}
loading={customerLoading}
showRowNumber={false}
emptyMessage="등록된 거래처가 없습니다"
onRowDoubleClick={(row) => openEditCust(row)}
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 품목 수정 모달 */}
<FullscreenDialog
open={editItemOpen}
onOpenChange={setEditItemOpen}
title="판매품목 수정"
description={`${editItemForm.item_number || ""}${editItemForm.item_name || ""}`}
defaultMaxWidth="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => setEditItemOpen(false)}></Button>
<Button onClick={handleEditSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="grid grid-cols-2 gap-4 py-4">
{/* 품목 기본정보 (읽기 전용) */}
{[
{ key: "item_number", label: "품목코드" },
{ key: "item_name", label: "품명" },
{ key: "size", label: "규격" },
{ key: "unit", label: "단위" },
{ key: "material", label: "재질" },
{ key: "status", label: "상태" },
].map((f) => (
<div key={f.key} className="space-y-1.5">
<Label className="text-sm text-muted-foreground">{f.label}</Label>
<Input value={editItemForm[f.key] || ""} className="h-9 bg-muted/50" disabled />
</div>
))}
<div className="col-span-2 border-t my-2" />
{/* 판매 설정 (수정 가능) */}
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.selling_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, selling_price: e.target.value }))}
placeholder="판매가격" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={editItemForm.standard_price || ""} onChange={(e) => setEditItemForm((p) => ({ ...p, standard_price: e.target.value }))}
placeholder="기준단가" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={editItemForm.currency_code || ""} onValueChange={(v) => setEditItemForm((p) => ({ ...p, currency_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(categoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</FullscreenDialog>
{/* 거래처 추가 모달 */}
<Dialog open={custSelectOpen} onOpenChange={setCustSelectOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="거래처명 검색" value={custSearchKeyword}
onChange={(e) => setCustSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchCustomers()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchCustomers} disabled={custSearchLoading} className="h-9">
{custSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={custSearchResults.length > 0 && custCheckedIds.size === custSearchResults.length}
onChange={(e) => {
if (e.target.checked) setCustCheckedIds(new Set(custSearchResults.map((c) => c.id)));
else setCustCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{custSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : custSearchResults.map((c) => (
<TableRow key={c.id} className={cn("cursor-pointer", custCheckedIds.has(c.id) && "bg-primary/5")}
onClick={() => setCustCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(c.id)) next.delete(c.id); else next.add(c.id);
return next;
})}>
<TableCell className="text-center"><input type="checkbox" checked={custCheckedIds.has(c.id)} readOnly /></TableCell>
<TableCell className="text-xs">{c.customer_code}</TableCell>
<TableCell className="text-sm">{c.customer_name}</TableCell>
<TableCell className="text-xs">{c.division}</TableCell>
<TableCell className="text-xs">{c.contact_person}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{custCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCustSelectOpen(false)}></Button>
<Button onClick={goToCustDetail} disabled={custCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {custCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 거래처 상세 입력/수정 모달 */}
<FullscreenDialog
open={custDetailOpen}
onOpenChange={setCustDetailOpen}
title={`📋 거래처 상세정보 ${editCustData ? "수정" : "입력"}${selectedItem?.item_name || ""}`}
description={editCustData ? "거래처 품번/품명과 기간별 단가를 수정합니다." : "선택한 거래처의 품번/품명과 기간별 단가를 설정합니다."}
defaultMaxWidth="max-w-[1100px]"
footer={
<>
<Button variant="outline" onClick={() => {
setCustDetailOpen(false);
if (!editCustData) setCustSelectOpen(true);
setEditCustData(null);
}}>{editCustData ? "취소" : "← 이전"}</Button>
<Button onClick={handleCustDetailSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="space-y-6 py-2">
{selectedCustsForDetail.map((cust, idx) => {
const custKey = cust.customer_code || cust.id;
const mappingRows = custMappings[custKey] || [];
const prices = custPrices[custKey] || [];
return (
<div key={custKey} className="border rounded-xl overflow-hidden bg-card">
<div className="px-5 py-3 bg-muted/30 border-b">
<div className="font-bold">{idx + 1}. {cust.customer_name || custKey}</div>
<div className="text-xs text-muted-foreground">{custKey}</div>
</div>
<div className="flex gap-4 p-4">
{/* 좌: 거래처 품번/품명 */}
<div className="flex-1 border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/10">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold"> / </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addMappingRow(custKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{mappingRows.length === 0 ? (
<div className="text-xs text-muted-foreground py-2"> </div>
) : mappingRows.map((mRow, mIdx) => (
<div key={mRow._id} className="flex gap-2 items-center">
<span className="text-xs text-muted-foreground w-4 shrink-0">{mIdx + 1}</span>
<Input value={mRow.customer_item_code}
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_code", e.target.value)}
placeholder="거래처 품번" className="h-8 text-sm flex-1" />
<Input value={mRow.customer_item_name}
onChange={(e) => updateMappingRow(custKey, mRow._id, "customer_item_name", e.target.value)}
placeholder="거래처 품명" className="h-8 text-sm flex-1" />
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive shrink-0"
onClick={() => removeMappingRow(custKey, mRow._id)}>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
</div>
{/* 우: 기간별 단가 */}
<div className="flex-1 border rounded-lg p-4 bg-amber-50/30 dark:bg-amber-950/10">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-semibold"> </span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => addPriceRow(custKey)}>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-3">
{prices.map((price, pIdx) => (
<div key={price._id} className="border rounded-lg p-3 bg-background space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> {pIdx + 1}</span>
{prices.length > 1 && (
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive"
onClick={() => removePriceRow(custKey, price._id)}>
<X className="h-3 w-3" />
</Button>
)}
</div>
<div className="flex gap-2 items-center">
<div className="flex-1">
<FormDatePicker value={price.start_date}
onChange={(v) => updatePriceRow(custKey, price._id, "start_date", v)} placeholder="시작일" />
</div>
<span className="text-xs text-muted-foreground">~</span>
<div className="flex-1">
<FormDatePicker value={price.end_date}
onChange={(v) => updatePriceRow(custKey, price._id, "end_date", v)} placeholder="종료일" />
</div>
<div className="w-[80px]">
<Select value={price.currency_code} onValueChange={(v) => updatePriceRow(custKey, price._id, "currency_code", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="통화" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["currency_code"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-2 items-center">
<div className="w-[90px]">
<Select value={price.base_price_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "base_price_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="기준" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["base_price_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input value={price.base_price}
onChange={(e) => updatePriceRow(custKey, price._id, "base_price", e.target.value)}
className="h-8 text-xs text-right flex-1" placeholder="기준가" />
<div className="w-[90px]">
<Select value={price.discount_type} onValueChange={(v) => updatePriceRow(custKey, price._id, "discount_type", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="할인" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{(priceCategoryOptions["discount_type"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Input value={price.discount_value}
onChange={(e) => updatePriceRow(custKey, price._id, "discount_value", e.target.value)}
className="h-8 text-xs text-right w-[60px]" placeholder="0" />
<div className="w-[90px]">
<Select value={price.rounding_unit_value} onValueChange={(v) => updatePriceRow(custKey, price._id, "rounding_unit_value", v)}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="반올림" /></SelectTrigger>
<SelectContent>
{(priceCategoryOptions["rounding_unit_value"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1 border-t">
<span className="text-xs text-muted-foreground"> :</span>
<span className="font-bold text-sm">{price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"}</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
})}
</div>
</FullscreenDialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={ITEM_TABLE}
userId={user?.userId}
onSuccess={() => fetchItems()}
/>
{ConfirmDialogComponent}
<TableSettingsModal
open={tableSettingsOpen}
onOpenChange={setTableSettingsOpen}
tableName={ITEM_TABLE}
settingsId="sales-item"
onSave={applyTableSettings}
/>
</div>
);
}

View File

@ -12,7 +12,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogD
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2 } from "lucide-react";
import { Plus, Trash2, RotateCcw, Save, X, ChevronDown, ChevronRight, ChevronLeft, Truck, Search, Loader2, FileSpreadsheet } from "lucide-react";
import { cn } from "@/lib/utils";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
@ -24,6 +24,8 @@ import {
getSalesOrderSource,
getItemSource,
} from "@/lib/api/shipping";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
@ -84,6 +86,9 @@ export default function ShippingOrderPage() {
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
@ -467,6 +472,9 @@ export default function ShippingOrderPage() {
<Badge variant="secondary" className="font-normal">{orders.length}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={() => openModal()}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
@ -577,14 +585,22 @@ export default function ShippingOrderPage() {
</div>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-[90vw] max-h-[90vh] w-[1400px] p-0 flex flex-col overflow-hidden">
<DialogHeader className="p-5 pb-4 border-b bg-primary text-primary-foreground shrink-0">
<DialogTitle className="text-lg">{isEditMode ? "출하지시 수정" : "출하지시 등록"}</DialogTitle>
<DialogDescription className="text-primary-foreground/70">
{isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
</DialogDescription>
</DialogHeader>
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={isEditMode ? "출하지시 수정" : "출하지시 등록"}
description={isEditMode ? "출하지시 정보를 수정합니다." : "왼쪽에서 데이터를 선택하고 오른쪽에서 출하지시 정보를 입력하세요."}
defaultMaxWidth="max-w-[90vw]"
defaultWidth="w-[1400px]"
footer={
<>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="flex-1 overflow-hidden">
<ResizablePanelGroup direction="horizontal">
@ -813,14 +829,17 @@ export default function ShippingOrderPage() {
</ResizablePanelGroup>
</div>
<DialogFooter className="p-4 border-t bg-muted/30 shrink-0">
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</FullscreenDialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName="shipment_instruction"
onSuccess={() => {
fetchOrders();
}}
/>
</div>
);
}

View File

@ -315,15 +315,15 @@ export default function ShippingPlanPage() {
onCheckedChange={handleCheckAll}
/>
</TableHead>
<TableHead className="w-[160px]"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[8%] text-center"></TableHead>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[20%]"></TableHead>
<TableHead className="w-[7%] text-right"></TableHead>
<TableHead className="w-[7%] text-right"></TableHead>
<TableHead className="w-[8%] text-center"></TableHead>
<TableHead className="w-[6%] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@ -26,6 +26,9 @@ import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/servi
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
import { FileSpreadsheet, Loader2 as ExcelLoader } from "lucide-react";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
export interface ScreenViewPageProps {
screenIdProp?: number;
@ -96,6 +99,11 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
// 엑셀 업로드 모달 상태
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
// 편집 모달 상태
const [editModalOpen, setEditModalOpen] = useState(false);
const [editModalConfig, setEditModalConfig] = useState<{
@ -650,8 +658,46 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
<TableOptionsProvider>
<div
ref={containerRef}
className={`bg-background h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}
className={`bg-background relative h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}
>
{/* 엑셀 업로드 버튼 (테이블이 있는 화면에서만 표시) */}
{!isPreviewMode && screen?.tableName && (
<div className="absolute top-2 right-3 z-10">
<Button
variant="outline"
size="sm"
className="gap-1.5 text-xs"
disabled={excelDetecting}
onClick={async () => {
if (!screen?.tableName) return;
setExcelDetecting(true);
try {
const result = await autoDetectMultiTableConfig(screen.tableName, screenId);
if (result.success && result.data) {
setExcelChainConfig(result.data);
setExcelUploadOpen(true);
} else {
const { toast } = await import("sonner");
toast.error(result.message || "테이블 구조를 분석할 수 없습니다.");
}
} catch {
const { toast } = await import("sonner");
toast.error("테이블 구조 분석 중 오류가 발생했습니다.");
} finally {
setExcelDetecting(false);
}
}}
>
{excelDetecting ? (
<ExcelLoader className="h-3.5 w-3.5 animate-spin" />
) : (
<FileSpreadsheet className="h-3.5 w-3.5" />
)}
</Button>
</div>
)}
{/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && (
<div className="bg-muted/30 flex h-full w-full items-center justify-center">
@ -801,6 +847,22 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
}}
/>
{/* 엑셀 업로드 모달 (멀티테이블 자동감지) */}
{excelChainConfig && (
<MultiTableExcelUploadModal
open={excelUploadOpen}
onOpenChange={(open) => {
setExcelUploadOpen(open);
if (!open) setExcelChainConfig(null);
}}
config={excelChainConfig}
onSuccess={() => {
window.dispatchEvent(new CustomEvent("refreshTable"));
setTableRefreshKey((prev) => prev + 1);
}}
/>
)}
{/* 스케줄 생성 확인 다이얼로그 */}
<ScheduleConfirmDialog
open={showConfirmDialog}

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,560 @@
"use client";
/**
* DataGrid
*
* :
* - (@dnd-kit)
* - (asc/desc)
* - 필터: 필터 Popover
* - (text/number/date/select)
* - (number )
* - truncate + tooltip
*/
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext, horizontalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import { Filter, Check, Search, ImageIcon, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
// --- 타입 ---
export interface DataGridColumn {
key: string;
label: string;
width?: string;
minWidth?: string;
editable?: boolean;
inputType?: "text" | "number" | "date" | "select";
sortable?: boolean;
filterable?: boolean;
truncate?: boolean;
align?: "left" | "center" | "right";
formatNumber?: boolean;
/** 이미지 타입 — 값을 /api/files/preview/{objid} 이미지로 렌더링 */
renderType?: "image";
selectOptions?: { value: string; label: string }[];
}
export interface DataGridProps {
columns: DataGridColumn[];
data: any[];
onRowClick?: (row: any, index: number) => void;
onRowDoubleClick?: (row: any, index: number) => void;
selectedId?: string | null;
onSelect?: (id: string | null) => void;
onCellEdit?: (rowId: string, columnKey: string, newValue: any, row: any) => void;
tableName?: string;
showRowNumber?: boolean;
/** 체크박스 다중선택 모드 */
showCheckbox?: boolean;
/** 체크된 행 ID 배열 */
checkedIds?: string[];
/** 체크 변경 콜백 */
onCheckedChange?: (ids: string[]) => void;
emptyMessage?: string;
loading?: boolean;
onColumnOrderChange?: (columns: DataGridColumn[]) => void;
gridId?: string;
}
const fmtNum = (val: any) => {
if (val == null || val === "") return "";
const n = Number(String(val).replace(/,/g, ""));
if (isNaN(n)) return String(val);
return n.toLocaleString();
};
// --- Sortable Header Cell ---
function SortableHeaderCell({
col, sortKey, sortDir, onSort,
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
}: {
col: DataGridColumn;
sortKey: string | null;
sortDir: "asc" | "desc";
onSort: (key: string) => void;
headerFilterValues: Set<string>;
uniqueValues: string[];
onToggleFilter: (colKey: string, value: string) => void;
onClearFilter: (colKey: string) => void;
}) {
const [filterSearch, setFilterSearch] = useState("");
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: "grab",
};
const isSorted = sortKey === col.key;
const hasFilter = headerFilterValues.size > 0;
return (
<TableHead
ref={setNodeRef}
style={style}
className={cn(col.width, col.minWidth, "select-none relative")}
>
<div className="flex items-center gap-0.5">
<div
{...attributes}
{...listeners}
className="flex items-center gap-0.5 cursor-pointer flex-1 min-w-0"
onClick={(e) => {
e.stopPropagation();
if (col.sortable !== false) onSort(col.key);
}}
>
<span className="text-xs font-medium truncate">{col.label}</span>
{isSorted && (
<span className="text-primary text-xs shrink-0">{sortDir === "asc" ? "↑" : "↓"}</span>
)}
</div>
{/* 컬럼 필터 아이콘 → Popover */}
{col.filterable !== false && uniqueValues.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className={cn(
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
hasFilter && "text-primary bg-primary/10",
)}
title="필터"
>
<Filter className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-2" align="start" onClick={(e) => e.stopPropagation()}>
<div className="space-y-2">
<div className="flex items-center justify-between border-b pb-2">
<span className="text-xs font-medium">: {col.label}</span>
{hasFilter && (
<button onClick={() => onClearFilter(col.key)} className="text-destructive text-xs hover:underline">
</button>
)}
</div>
{/* 검색 */}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
value={filterSearch}
onChange={(e) => setFilterSearch(e.target.value)}
placeholder="검색..."
className="h-7 text-xs pl-7"
/>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{uniqueValues
.filter((v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase()))
.slice(0, 50).map((val) => {
const isSelected = headerFilterValues.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => onToggleFilter(col.key, val)}
>
<div className={cn(
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
isSelected ? "bg-primary border-primary" : "border-input",
)}>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{uniqueValues.filter((v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())).length > 50 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {uniqueValues.filter((v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())).length - 50}
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</TableHead>
);
}
// --- DataGrid ---
export function DataGrid({
columns: initialColumns,
data,
onRowClick,
onRowDoubleClick,
selectedId,
onSelect,
onCellEdit,
tableName,
showRowNumber = true,
showCheckbox = false,
checkedIds = [],
onCheckedChange,
emptyMessage = "데이터가 없습니다",
loading = false,
onColumnOrderChange,
gridId,
}: DataGridProps) {
const [columns, setColumns] = useState(initialColumns);
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
// 정렬
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
// 헤더 필터 (컬럼별 선택된 값 Set)
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
// 인라인 편집
const [editingCell, setEditingCell] = useState<{ rowIdx: number; colKey: string } | null>(null);
// 이미지 확대 모달
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const editRef = useRef<HTMLInputElement>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
);
// localStorage에서 컬럼 순서 복원
useEffect(() => {
if (!gridId) return;
const saved = localStorage.getItem(`datagrid_col_order_${gridId}`);
if (saved) {
try {
const order = JSON.parse(saved) as string[];
const reordered = order
.map((key) => initialColumns.find((c) => c.key === key))
.filter(Boolean) as DataGridColumn[];
const remaining = initialColumns.filter((c) => !order.includes(c.key));
setColumns([...reordered, ...remaining]);
} catch { /* skip */ }
}
}, [gridId]); // eslint-disable-line react-hooks/exhaustive-deps
// 컬럼별 고유값 계산 (필터 팝오버용)
const columnUniqueValues = useMemo(() => {
const result: Record<string, string[]> = {};
for (const col of columns) {
const values = new Set<string>();
data.forEach((row) => {
const val = row[col.key];
if (val !== null && val !== undefined && val !== "") {
values.add(String(val));
}
});
result[col.key] = Array.from(values).sort();
}
return result;
}, [data, columns]);
// 드래그 완료
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setColumns((prev) => {
const oldIndex = prev.findIndex((c) => c.key === active.id);
const newIndex = prev.findIndex((c) => c.key === over.id);
const next = arrayMove(prev, oldIndex, newIndex);
if (gridId) localStorage.setItem(`datagrid_col_order_${gridId}`, JSON.stringify(next.map((c) => c.key)));
onColumnOrderChange?.(next);
return next;
});
};
// 정렬
const handleSort = (key: string) => {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else { setSortKey(key); setSortDir("asc"); }
};
// 헤더 필터 토글
const toggleHeaderFilter = (colKey: string, value: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
const set = new Set(next[colKey] || []);
if (set.has(value)) set.delete(value); else set.add(value);
if (set.size === 0) delete next[colKey]; else next[colKey] = set;
return next;
});
};
const clearHeaderFilter = (colKey: string) => {
setHeaderFilters((prev) => {
const next = { ...prev };
delete next[colKey];
return next;
});
};
// 필터 + 정렬 적용
const processedData = useMemo(() => {
let result = [...data];
// 헤더 필터 적용
if (Object.keys(headerFilters).length > 0) {
result = result.filter((row) => {
return Object.entries(headerFilters).every(([colKey, values]) => {
if (values.size === 0) return true;
const cellVal = row[colKey] != null ? String(row[colKey]) : "";
return values.has(cellVal);
});
});
}
// 정렬
if (sortKey) {
result.sort((a, b) => {
const av = a[sortKey] ?? "";
const bv = b[sortKey] ?? "";
const na = Number(av);
const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return sortDir === "asc" ? na - nb : nb - na;
return sortDir === "asc" ? String(av).localeCompare(String(bv)) : String(bv).localeCompare(String(av));
});
}
return result;
}, [data, headerFilters, sortKey, sortDir]);
// 인라인 편집
const startEdit = (rowIdx: number, colKey: string, currentVal: any) => {
const col = columns.find((c) => c.key === colKey);
if (!col?.editable) return;
setEditingCell({ rowIdx, colKey });
setEditValue(currentVal != null ? String(currentVal) : "");
};
const saveEdit = useCallback(async () => {
if (!editingCell) return;
const { rowIdx, colKey } = editingCell;
const row = processedData[rowIdx];
if (!row) { setEditingCell(null); return; }
const originalVal = String(row[colKey] ?? "");
if (originalVal === editValue) { setEditingCell(null); return; }
if (tableName && row.id) {
try {
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
originalData: { id: row.id },
updatedData: { [colKey]: editValue || null },
});
row[colKey] = editValue;
toast.success("저장됨");
} catch (err) {
console.error("셀 저장 실패:", err);
toast.error("저장에 실패했습니다.");
setEditingCell(null);
return;
}
}
onCellEdit?.(row.id, colKey, editValue, row);
setEditingCell(null);
}, [editingCell, editValue, processedData, tableName, onCellEdit]);
const cancelEdit = () => setEditingCell(null);
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") { e.preventDefault(); saveEdit(); }
else if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }
else if (e.key === "Tab") { e.preventDefault(); saveEdit(); }
};
useEffect(() => {
if (editingCell && editRef.current) {
editRef.current.focus();
editRef.current.select();
}
}, [editingCell]);
// 셀 렌더링
const renderCell = (row: any, col: DataGridColumn, rowIdx: number) => {
const isEditing = editingCell?.rowIdx === rowIdx && editingCell?.colKey === col.key;
const val = row[col.key];
if (isEditing) {
if (col.inputType === "select" && col.selectOptions) {
return (
<select ref={editRef as any} value={editValue} onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown} onBlur={saveEdit}
className="h-7 w-full rounded border border-primary bg-background px-1.5 text-xs focus:ring-1">
<option value=""></option>
{col.selectOptions.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
);
}
return (
<input ref={editRef} type={col.inputType === "date" ? "date" : "text"}
value={editValue} onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleEditKeyDown} onBlur={saveEdit}
className={cn("h-7 w-full rounded border border-primary bg-background px-1.5 text-xs focus:ring-1",
col.align === "right" && "text-right")} />
);
}
// 이미지 타입
if (col.renderType === "image" && val) {
const src = (val.startsWith("http") || val.startsWith("/")) ? val : `/api/files/preview/${val}`;
return (
<img src={src} alt="" className="h-10 w-10 rounded object-cover cursor-pointer hover:ring-2 hover:ring-primary transition-all"
onClick={(e) => { e.stopPropagation(); setPreviewImage(src); }} />
);
}
if (col.renderType === "image" && !val) {
return <div className="h-10 w-10 rounded bg-muted flex items-center justify-center"><ImageIcon className="h-4 w-4 text-muted-foreground/30" /></div>;
}
let display = val ?? "";
if (col.formatNumber || col.inputType === "number") display = fmtNum(val);
const truncateClass = col.truncate !== false ? "block truncate" : "";
return (
<span className={cn("text-xs", truncateClass, col.align === "right" && "text-right w-full inline-block")}
title={String(val ?? "")}>
{display}
</span>
);
};
return (
<div className="flex flex-col h-full overflow-auto">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
<TableRow>
{showCheckbox && (
<TableHead className="w-[40px] text-center">
<Checkbox
checked={processedData.length > 0 && checkedIds.length === processedData.length}
onCheckedChange={(checked) => {
onCheckedChange?.(checked ? processedData.map((r) => r.id) : []);
}}
/>
</TableHead>
)}
{showRowNumber && !showCheckbox && <TableHead className="w-[40px] text-center text-xs">No</TableHead>}
{columns.map((col) => (
<SortableHeaderCell
key={col.key}
col={col}
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
headerFilterValues={headerFilters[col.key] || new Set()}
uniqueValues={columnUniqueValues[col.key] || []}
onToggleFilter={toggleHeaderFilter}
onClearFilter={clearHeaderFilter}
/>
))}
</TableRow>
</SortableContext>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
...
</TableCell>
</TableRow>
) : processedData.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
{emptyMessage}
</TableCell>
</TableRow>
) : processedData.map((row, rowIdx) => (
<TableRow
key={row.id || rowIdx}
className={cn("cursor-pointer",
selectedId === row.id && "bg-primary/5",
showCheckbox && checkedIds.includes(row.id) && "bg-primary/5",
)}
onClick={() => {
onSelect?.(row.id);
onRowClick?.(row, rowIdx);
// 체크박스 모드에서는 행 클릭으로 체크 토글
if (showCheckbox && onCheckedChange) {
const next = checkedIds.includes(row.id)
? checkedIds.filter((id) => id !== row.id)
: [...checkedIds, row.id];
onCheckedChange(next);
}
}}
onDoubleClick={() => onRowDoubleClick?.(row, rowIdx)}
>
{showCheckbox && (
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={checkedIds.includes(row.id)}
onCheckedChange={(checked) => {
const next = checked
? [...checkedIds, row.id]
: checkedIds.filter((id) => id !== row.id);
onCheckedChange?.(next);
}}
/>
</TableCell>
)}
{showRowNumber && !showCheckbox && <TableCell className="text-center text-[10px] text-muted-foreground">{rowIdx + 1}</TableCell>}
{columns.map((col) => (
<TableCell
key={col.key}
className={cn(col.width, col.minWidth, "py-2.5", col.editable && "cursor-text")}
onDoubleClick={(e) => {
if (col.editable) {
e.stopPropagation();
startEdit(rowIdx, col.key, row[col.key]);
}
}}
>
{renderCell(row, col, rowIdx)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</DndContext>
{/* 이미지 확대 모달 */}
{previewImage && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70"
onClick={() => setPreviewImage(null)}>
<div className="relative max-w-[90vw] max-h-[90vh]">
<img src={previewImage} alt="" className="max-w-full max-h-[85vh] rounded-lg object-contain" />
<button className="absolute -top-3 -right-3 bg-background rounded-full p-1 shadow-lg hover:bg-muted"
onClick={() => setPreviewImage(null)}>
<X className="h-5 w-5" />
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,530 @@
"use client";
/**
* DynamicSearchFilter
*
* :
* - / ( )
* - //
* - (%)
* - placeholder로만 ( )
* - select ()
* - (FormDatePicker)
* - + localStorage에 ( )
* - ( )
* - table_type_columns의 column_label
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Settings, ChevronsUpDown, RotateCcw, Search, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
// --- 타입 ---
export type FilterType = "text" | "select" | "date";
export interface FilterColumn {
columnName: string;
columnLabel: string;
/** 원본 inputType (DB에서 가져온 값, 타입 변경 시 기본값 복원용) */
originalType: FilterType;
/** 사용자가 선택한 필터 타입 */
filterType: FilterType;
enabled: boolean;
/** 필터 너비 (%, 10~100, 기본 25) */
width: number;
}
export interface FilterValue {
columnName: string;
operator: "contains" | "equals" | "in" | "between";
value: string;
}
export interface ExternalFilterConfig {
columnName: string;
displayName: string;
enabled: boolean;
filterType: FilterType;
width: number;
}
export interface DynamicSearchFilterProps {
/** 테이블명 (컬럼 목록 + 카테고리 옵션 로드에 사용) */
tableName: string;
/** 고유 ID (localStorage 키 분리용, 예: "item-info", "sales-order") */
filterId: string;
/** 필터 변경 시 콜백 — API 필터 배열 형태로 전달 */
onFilterChange: (filters: FilterValue[]) => void;
/** 데이터 건수 표시 (optional) */
dataCount?: number;
/** 추가 액션 버튼 영역 */
extraActions?: React.ReactNode;
/** TableSettingsModal에서 전달된 외부 필터 설정 (제공 시 자체 설정 모달 숨김) */
externalFilterConfig?: ExternalFilterConfig[];
}
const FILTER_TYPE_OPTIONS: { value: FilterType; label: string }[] = [
{ value: "text", label: "텍스트" },
{ value: "select", label: "선택" },
{ value: "date", label: "날짜" },
];
const WIDTH_OPTIONS = [
{ value: 15, label: "15%" },
{ value: 20, label: "20%" },
{ value: 25, label: "25%" },
{ value: 30, label: "30%" },
{ value: 40, label: "40%" },
{ value: 50, label: "50%" },
];
// --- 컴포넌트 ---
export function DynamicSearchFilter({
tableName,
filterId,
onFilterChange,
dataCount,
extraActions,
externalFilterConfig,
}: DynamicSearchFilterProps) {
const [allColumns, setAllColumns] = useState<FilterColumn[]>([]);
const [activeFilters, setActiveFilters] = useState<FilterColumn[]>([]);
const [filterValues, setFilterValues] = useState<Record<string, any>>({});
const [selectOptions, setSelectOptions] = useState<Record<string, { label: string; value: string }[]>>({});
const [settingsOpen, setSettingsOpen] = useState(false);
const [selectSearchTerms, setSelectSearchTerms] = useState<Record<string, string>>({});
const [tempColumns, setTempColumns] = useState<FilterColumn[]>([]);
const STORAGE_KEY_FILTERS = `dynamic_filter_config_${filterId}`;
const STORAGE_KEY_VALUES = `dynamic_filter_values_${filterId}`;
// 컬럼 정보 로드 (회사별 table_type_columns의 column_label 사용)
useEffect(() => {
const loadColumns = async () => {
try {
const res = await apiClient.get(`/table-management/tables/${tableName}/web-types`);
const types = res.data?.data || [];
const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"];
const cols: FilterColumn[] = types
.filter((t: any) => !AUTO_COLS.includes(t.columnName))
.map((t: any) => {
let filterType: FilterType = "text";
if (t.inputType === "category" || t.inputType === "select") filterType = "select";
else if (t.inputType === "date" || t.inputType === "datetime") filterType = "date";
return {
columnName: t.columnName,
columnLabel: t.displayName || t.columnLabel || t.columnName,
originalType: filterType,
filterType,
enabled: false,
width: 25,
};
});
// localStorage에서 저장된 설정 복원 (enabled, filterType, width)
const saved = localStorage.getItem(STORAGE_KEY_FILTERS);
let merged = cols;
if (saved) {
try {
const parsed = JSON.parse(saved) as FilterColumn[];
merged = cols.map((col) => {
const s = parsed.find((p) => p.columnName === col.columnName);
return s ? { ...col, enabled: s.enabled, filterType: s.filterType, width: s.width || 25 } : col;
});
} catch { /* skip */ }
}
setAllColumns(merged);
setActiveFilters(merged.filter((c) => c.enabled));
// 저장된 필터 값 복원
const savedValues = localStorage.getItem(STORAGE_KEY_VALUES);
if (savedValues) {
try { setFilterValues(JSON.parse(savedValues)); } catch { /* skip */ }
}
} catch (err) {
console.error("필터 컬럼 로드 실패:", err);
}
};
loadColumns();
}, [tableName, STORAGE_KEY_FILTERS, STORAGE_KEY_VALUES]);
// 외부 필터 설정 적용 (TableSettingsModal에서 전달)
useEffect(() => {
if (!externalFilterConfig) return;
const active: FilterColumn[] = externalFilterConfig
.filter((f) => f.enabled)
.map((f) => ({
columnName: f.columnName,
columnLabel: f.displayName,
originalType: f.filterType,
filterType: f.filterType,
enabled: true,
width: f.width,
}));
setActiveFilters(active);
}, [externalFilterConfig]);
// select 타입 필터의 옵션 로드
useEffect(() => {
const loadOptions = async () => {
const selectCols = activeFilters.filter((f) => f.filterType === "select");
if (selectCols.length === 0) return;
const opts: Record<string, { label: string; value: string }[]> = {};
const flatten = (vals: any[]): { label: string; value: string }[] => {
const result: { label: string; value: string }[] = [];
for (const v of vals) {
result.push({ value: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
await Promise.all(
selectCols.map(async (col) => {
if (selectOptions[col.columnName]?.length) return; // 이미 로드됨
try {
const res = await apiClient.get(`/table-categories/${tableName}/${col.columnName}/values`);
if (res.data?.success && res.data.data?.length > 0) {
opts[col.columnName] = flatten(res.data.data);
}
} catch { /* skip */ }
})
);
if (Object.keys(opts).length > 0) {
setSelectOptions((prev) => ({ ...prev, ...opts }));
}
};
loadOptions();
}, [activeFilters, tableName]); // eslint-disable-line react-hooks/exhaustive-deps
// 필터 값 → API 필터 형태로 변환
const emitFilters = useCallback((values: Record<string, any>) => {
const filters: FilterValue[] = [];
for (const f of activeFilters) {
const val = values[f.columnName];
if (!val || (typeof val === "string" && val === "")) continue;
if (f.filterType === "date" && typeof val === "object" && (val.from || val.to)) {
const from = val.from || "";
const to = val.to || "";
if (from || to) filters.push({ columnName: f.columnName, operator: "between", value: `${from}|${to}` });
} else if (Array.isArray(val)) {
if (val.length > 0) filters.push({ columnName: f.columnName, operator: "in", value: val.join("|") });
} else {
filters.push({
columnName: f.columnName,
operator: f.filterType === "select" ? "equals" : "contains",
value: String(val),
});
}
}
onFilterChange(filters);
}, [activeFilters, onFilterChange]);
const handleValueChange = (columnName: string, value: any) => {
const newValues = { ...filterValues, [columnName]: value };
setFilterValues(newValues);
localStorage.setItem(STORAGE_KEY_VALUES, JSON.stringify(newValues));
emitFilters(newValues);
};
// 초기 로드 시 필터 적용
useEffect(() => {
if (activeFilters.length > 0 && Object.keys(filterValues).length > 0) {
emitFilters(filterValues);
}
}, [activeFilters.length]); // eslint-disable-line react-hooks/exhaustive-deps
const handleReset = () => {
setFilterValues({});
localStorage.removeItem(STORAGE_KEY_VALUES);
onFilterChange([]);
};
// 설정 모달
const openSettings = () => {
setTempColumns(allColumns.map((c) => ({ ...c })));
setSettingsOpen(true);
};
const saveSettings = () => {
setAllColumns(tempColumns);
const active = tempColumns.filter((c) => c.enabled);
setActiveFilters(active);
localStorage.setItem(STORAGE_KEY_FILTERS, JSON.stringify(tempColumns));
// 비활성화된 필터 값 + 타입 변경된 필터 값 제거
const activeNames = new Set(active.map((a) => a.columnName));
const cleaned = { ...filterValues };
for (const key of Object.keys(cleaned)) {
if (!activeNames.has(key)) delete cleaned[key];
}
// 타입이 변경된 필터의 값도 초기화
for (const col of active) {
const prev = allColumns.find((c) => c.columnName === col.columnName);
if (prev && prev.filterType !== col.filterType) {
delete cleaned[col.columnName];
}
}
setFilterValues(cleaned);
localStorage.setItem(STORAGE_KEY_VALUES, JSON.stringify(cleaned));
setSettingsOpen(false);
emitFilters(cleaned);
};
// --- 필터 렌더링 (라벨은 placeholder로만) ---
const renderFilterInput = (filter: FilterColumn) => {
const value = filterValues[filter.columnName] || "";
const widthStyle = { flex: `0 0 ${filter.width}%`, minWidth: "120px" };
switch (filter.filterType) {
case "date":
return (
<div style={widthStyle} className="flex items-center gap-1">
<div className="flex-1">
<FormDatePicker
value={typeof value === "object" ? value.from || "" : ""}
onChange={(v) => handleValueChange(filter.columnName, { ...((typeof value === "object" && value) || {}), from: v })}
placeholder={`${filter.columnLabel} 시작`}
/>
</div>
<span className="text-muted-foreground text-xs shrink-0">~</span>
<div className="flex-1">
<FormDatePicker
value={typeof value === "object" ? value.to || "" : ""}
onChange={(v) => handleValueChange(filter.columnName, { ...((typeof value === "object" && value) || {}), to: v })}
placeholder={`${filter.columnLabel} 종료`}
/>
</div>
</div>
);
case "select": {
const options = selectOptions[filter.columnName] || [];
const selectedValues: string[] = Array.isArray(value) ? value : value ? [value] : [];
const getDisplayText = () => {
if (selectedValues.length === 0) return filter.columnLabel;
if (selectedValues.length === 1) {
const opt = options.find((o) => o.value === selectedValues[0]);
return opt?.label || selectedValues[0];
}
return `${filter.columnLabel} (${selectedValues.length})`;
};
const toggleOption = (optValue: string, checked: boolean) => {
const next = checked ? [...selectedValues, optValue] : selectedValues.filter((v) => v !== optValue);
handleValueChange(filter.columnName, next.length > 0 ? next : "");
};
const searchTerm = (selectSearchTerms[filter.columnName] || "").toLowerCase();
const filteredOptions = searchTerm
? options.filter((opt) => opt.label.toLowerCase().includes(searchTerm))
: options;
return (
<div style={widthStyle}>
<Popover onOpenChange={(open) => {
if (!open) {
setSelectSearchTerms((prev) => {
const next = { ...prev };
delete next[filter.columnName];
return next;
});
}
}}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox"
className={cn("h-9 w-full justify-between text-sm font-normal", selectedValues.length === 0 && "text-muted-foreground")}>
<span className="truncate">{getDisplayText()}</span>
<ChevronsUpDown className="ml-1 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
{options.length > 5 && (
<div className="border-b p-1">
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2" />
<Input
value={selectSearchTerms[filter.columnName] || ""}
onChange={(e) =>
setSelectSearchTerms((prev) => ({ ...prev, [filter.columnName]: e.target.value }))
}
placeholder="검색..."
className="h-7 pl-7 pr-7 text-xs"
/>
{selectSearchTerms[filter.columnName] && (
<button
onClick={() =>
setSelectSearchTerms((prev) => ({ ...prev, [filter.columnName]: "" }))
}
className="absolute right-2 top-1/2 -translate-y-1/2"
>
<X className="text-muted-foreground hover:text-foreground h-3 w-3" />
</button>
)}
</div>
</div>
)}
<div className="max-h-60 overflow-auto p-1">
{options.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : filteredOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
) : filteredOptions.map((opt, i) => (
<div key={`${opt.value}-${i}`}
className="hover:bg-accent flex cursor-pointer items-center space-x-2 rounded-sm px-2 py-1.5"
onClick={() => toggleOption(opt.value, !selectedValues.includes(opt.value))}>
<Checkbox checked={selectedValues.includes(opt.value)} />
<span className="truncate text-sm">{opt.label}</span>
</div>
))}
</div>
{selectedValues.length > 0 && (
<div className="border-t p-1">
<Button variant="ghost" size="sm" className="h-7 w-full text-xs"
onClick={() => handleValueChange(filter.columnName, "")}> </Button>
</div>
)}
</PopoverContent>
</Popover>
</div>
);
}
default: // text
return (
<div style={widthStyle}>
<Input type="text" value={value}
onChange={(e) => handleValueChange(filter.columnName, e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") emitFilters(filterValues); }}
className="h-9 w-full text-sm" placeholder={filter.columnLabel} />
</div>
);
}
};
return (
<div className="bg-card flex w-full flex-wrap items-center gap-2 rounded-lg border p-3 shadow-sm">
{/* 활성 필터들 — 라벨 없이 placeholder만 */}
{activeFilters.length > 0 ? (
<div className="flex flex-1 flex-wrap items-center gap-2">
{activeFilters.map((filter) => renderFilterInput(filter))}
<Button variant="outline" size="sm" onClick={handleReset} className="h-9 shrink-0">
<RotateCcw className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="flex-1 text-sm text-muted-foreground">
</div>
)}
{/* 우측 */}
<div className="flex items-center gap-2 shrink-0">
{dataCount !== undefined && (
<div className="bg-muted text-muted-foreground rounded-md px-3 py-1.5 text-sm font-medium">
{dataCount.toLocaleString()}
</div>
)}
{extraActions}
{!externalFilterConfig && (
<Button variant="outline" size="sm" onClick={openSettings} className="h-9">
<Settings className="mr-1 h-3.5 w-3.5" />
</Button>
)}
</div>
{/* 필터 설정 모달 */}
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent className="max-w-2xl max-h-[70vh] overflow-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<div className="py-2">
{/* 헤더 */}
<div className="flex items-center gap-3 pb-2 border-b mb-1 text-xs font-medium text-muted-foreground">
<div className="w-8 text-center"></div>
<div className="flex-1"></div>
<div className="w-[120px]"> </div>
<div className="w-[100px]"></div>
</div>
{tempColumns.map((col, idx) => (
<div key={col.columnName} className="flex items-center gap-3 py-1.5 px-1 hover:bg-muted/50 rounded">
<div className="w-8 text-center">
<Checkbox
checked={col.enabled}
onCheckedChange={(checked) => {
const next = [...tempColumns];
next[idx] = { ...next[idx], enabled: !!checked };
setTempColumns(next);
}}
/>
</div>
<div className="flex-1 text-sm">{col.columnLabel}</div>
<div className="w-[120px]">
<Select
value={col.filterType}
onValueChange={(v) => {
const next = [...tempColumns];
next[idx] = { ...next[idx], filterType: v as FilterType };
setTempColumns(next);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-[100px]">
<Select
value={String(col.width)}
onValueChange={(v) => {
const next = [...tempColumns];
next[idx] = { ...next[idx], width: Number(v) };
setTempColumns(next);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WIDTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSettingsOpen(false)}></Button>
<Button onClick={saveSettings}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -822,6 +822,37 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
return true;
};
// 템플릿 다운로드: 테이블 스키마 기반으로 빈 엑셀 파일 생성
const handleDownloadTemplate = async () => {
try {
const { exportToExcel } = await import("@/lib/utils/excelExport");
const response = await getTableSchema(tableName);
if (!response.success || !response.data) {
toast.error("테이블 정보를 가져올 수 없습니다.");
return;
}
const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"];
const columns = response.data.columns.filter(
(col) => !AUTO_COLS.includes(col.name.toLowerCase())
);
// 필수 컬럼에 * 표시
const headerRow: Record<string, any> = {};
for (const col of columns) {
const label = col.label || col.name;
const isRequired = !col.nullable;
headerRow[isRequired ? `${label} *` : label] = "";
}
await exportToExcel([headerRow], `${tableName}_템플릿.xlsx`, "Sheet1");
toast.success("템플릿 파일이 다운로드되었습니다.");
} catch (error) {
console.error("템플릿 다운로드 실패:", error);
toast.error("템플릿 다운로드에 실패했습니다.");
}
};
// 다음 단계
const handleNext = async () => {
if (currentStep === 1 && !file) {
@ -1607,11 +1638,23 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
</div>
)}
{/* 파일 선택 영역 */}
{/* 템플릿 다운로드 + 파일 선택 영역 */}
<div>
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
*
</Label>
<div className="flex items-center justify-between">
<Label htmlFor="file-upload" className="text-xs sm:text-sm">
*
</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={handleDownloadTemplate}
>
<ArrowRight className="h-3 w-3 rotate-90" />
릿
</Button>
</div>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}

View File

@ -0,0 +1,88 @@
"use client";
/**
* FullscreenDialog Dialog
*
* :
* <FullscreenDialog open={open} onOpenChange={setOpen} title="제목" description="설명">
* {children}
* </FullscreenDialog>
*
* footer prop으로
*/
import React, { useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Maximize2, Minimize2 } from "lucide-react";
import { cn } from "@/lib/utils";
interface FullscreenDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
description?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
/** 기본 모달 최대 너비 (기본: "max-w-5xl") */
defaultMaxWidth?: string;
/** 기본 모달 너비 (기본: "w-[95vw]") */
defaultWidth?: string;
className?: string;
}
export function FullscreenDialog({
open, onOpenChange, title, description, children, footer,
defaultMaxWidth = "max-w-5xl",
defaultWidth = "w-[95vw]",
className,
}: FullscreenDialogProps) {
const [isFullscreen, setIsFullscreen] = useState(false);
const handleOpenChange = (v: boolean) => {
if (!v) setIsFullscreen(false);
onOpenChange(v);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className={cn(
"overflow-auto flex flex-col transition-all duration-200",
isFullscreen
? "max-w-screen max-h-screen w-screen h-screen rounded-none"
: `${defaultMaxWidth} ${defaultWidth} max-h-[90vh]`,
className,
)}>
<DialogHeader className="shrink-0">
<div className="flex items-center justify-between pr-8">
<div>
{typeof title === "string" ? <DialogTitle>{title}</DialogTitle> : title}
{description && (
typeof description === "string"
? <DialogDescription>{description}</DialogDescription>
: description
)}
</div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
onClick={() => setIsFullscreen((p) => !p)}
title={isFullscreen ? "기본 크기" : "전체 화면"}>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Button>
</div>
</DialogHeader>
<div className="flex-1 overflow-auto">
{children}
</div>
{footer && (
<DialogFooter className="shrink-0">
{footer}
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,164 @@
"use client";
/**
* ImageUpload
*
* :
* - ( or )
* -
* - /api/files/upload API
* - objid
* - (objid URL)
*
* :
* <ImageUpload
* value={form.image_path} // 기존 파일 objid 또는 URL
* onChange={(objid) => setForm(...)} // 업로드 완료 시 objid 반환
* tableName="equipment_mng" // 연결 테이블
* recordId="xxx" // 연결 레코드 ID
* columnName="image_path" // 연결 컬럼명
* />
*/
import React, { useState, useRef, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Upload, X, Loader2, ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner";
interface ImageUploadProps {
/** 현재 이미지 값 (파일 objid, URL, 또는 빈 문자열) */
value?: string;
/** 업로드 완료 시 콜백 (파일 objid) */
onChange?: (value: string) => void;
/** 연결할 테이블명 */
tableName?: string;
/** 연결할 레코드 ID */
recordId?: string;
/** 연결할 컬럼명 */
columnName?: string;
/** 높이 (기본 160px) */
height?: string;
/** 비활성화 */
disabled?: boolean;
className?: string;
}
export function ImageUpload({
value, onChange, tableName, recordId, columnName,
height = "h-40", disabled = false, className,
}: ImageUploadProps) {
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
// 이미지 URL 결정
const imageUrl = value
? (value.startsWith("http") || value.startsWith("/"))
? value
: `/api/files/preview/${value}`
: null;
const handleUpload = useCallback(async (file: File) => {
if (!file.type.startsWith("image/")) {
toast.error("이미지 파일만 업로드 가능합니다.");
return;
}
if (file.size > 10 * 1024 * 1024) {
toast.error("파일 크기는 10MB 이하만 가능합니다.");
return;
}
setUploading(true);
try {
const formData = new FormData();
formData.append("files", file);
formData.append("docType", "IMAGE");
formData.append("docTypeName", "이미지");
if (tableName) formData.append("linkedTable", tableName);
if (recordId) formData.append("recordId", recordId);
if (columnName) formData.append("columnName", columnName);
if (tableName && recordId) {
formData.append("autoLink", "true");
if (columnName) formData.append("isVirtualFileColumn", "true");
}
const res = await apiClient.post("/files/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
if (res.data?.success && (res.data.files?.length > 0 || res.data.data?.length > 0)) {
const file = res.data.files?.[0] || res.data.data?.[0];
const objid = file.objid;
onChange?.(objid);
toast.success("이미지가 업로드되었습니다.");
} else {
toast.error(res.data?.message || "업로드에 실패했습니다.");
}
} catch (err: any) {
console.error("이미지 업로드 실패:", err);
toast.error(err.response?.data?.message || "업로드에 실패했습니다.");
} finally {
setUploading(false);
}
}, [tableName, recordId, columnName, onChange]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.target.value = "";
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) handleUpload(file);
};
const handleRemove = () => {
onChange?.("");
};
return (
<div className={cn("relative", className)}>
<div
className={cn(
"border-2 border-dashed rounded-lg flex flex-col items-center justify-center cursor-pointer transition-colors overflow-hidden",
height,
dragOver ? "border-primary bg-primary/5" : imageUrl ? "border-transparent" : "border-muted-foreground/25 hover:border-primary hover:bg-muted/50",
disabled && "opacity-50 cursor-not-allowed",
)}
onClick={() => !disabled && !uploading && fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={!disabled ? handleDrop : undefined}
>
{uploading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground"> ...</span>
</div>
) : imageUrl ? (
<img src={imageUrl} alt="이미지" className="w-full h-full object-contain" />
) : (
<div className="flex flex-col items-center gap-2">
<ImageIcon className="h-8 w-8 text-muted-foreground/50" />
<span className="text-xs text-muted-foreground"> </span>
</div>
)}
</div>
{/* 삭제 버튼 */}
{imageUrl && !disabled && (
<Button variant="destructive" size="sm" className="absolute top-1 right-1 h-6 w-6 p-0 rounded-full"
onClick={(e) => { e.stopPropagation(); handleRemove(); }}>
<X className="h-3 w-3" />
</Button>
)}
<input ref={fileRef} type="file" accept="image/*" onChange={handleFileChange} className="hidden" />
</div>
);
}

View File

@ -0,0 +1,382 @@
"use client";
/**
* ShippingPlanBatchModal
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Plus, X, Loader2, Package, Truck, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import {
getShippingPlanAggregate,
batchSaveShippingPlans,
type AggregateResponse,
type BatchSavePlan,
} from "@/lib/api/shipping";
// --- 시간 선택 컴포넌트 (오전/오후 + 시 + 분) ---
function TimePicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const [open, setOpen] = useState(false);
// value: "HH:MM" or ""
const parsed = value ? value.split(":") : ["", ""];
const hour24 = parsed[0] ? parseInt(parsed[0]) : -1;
const minute = parsed[1] ? parseInt(parsed[1]) : -1;
const isAM = hour24 >= 0 && hour24 < 12;
const [period, setPeriod] = useState<"오전" | "오후">(isAM ? "오전" : "오후");
const hours = Array.from({ length: 12 }, (_, i) => i); // 0-11
const minutes = Array.from({ length: 12 }, (_, i) => i * 5); // 0,5,10...55
const displayHour = hour24 >= 0 ? (hour24 % 12 || 12) : null;
const displayMinute = minute >= 0 ? minute : null;
const select = (p: "오전" | "오후", h: number, m: number) => {
const h24 = p === "오전" ? (h === 12 ? 0 : h) : (h === 12 ? 12 : h + 12);
onChange(`${String(h24).padStart(2, "0")}:${String(m).padStart(2, "0")}`);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className="h-7 w-full justify-start text-xs font-normal gap-1 px-2">
<Clock className="h-3 w-3 shrink-0 opacity-50" />
{value || "--:--"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="flex">
{/* 오전/오후 */}
<div className="border-r p-1 flex flex-col gap-0.5">
{(["오전", "오후"] as const).map((p) => (
<button key={p} onClick={() => setPeriod(p)}
className={cn("px-3 py-1.5 text-xs rounded", period === p ? "bg-primary text-primary-foreground" : "hover:bg-muted")}>
{p}
</button>
))}
</div>
{/* 시 */}
<div className="border-r p-1 max-h-48 overflow-auto flex flex-col gap-0.5">
{[12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((h) => (
<button key={h} onClick={() => select(period, h, displayMinute ?? 0)}
className={cn("px-3 py-1 text-xs rounded min-w-[32px]",
displayHour === h ? "bg-primary text-primary-foreground" : "hover:bg-muted")}>
{String(h).padStart(2, "0")}
</button>
))}
</div>
{/* 분 */}
<div className="p-1 max-h-48 overflow-auto flex flex-col gap-0.5">
{minutes.map((m) => (
<button key={m} onClick={() => select(period, displayHour ?? 12, m)}
className={cn("px-3 py-1 text-xs rounded min-w-[32px]",
displayMinute === m ? "bg-primary text-primary-foreground" : "hover:bg-muted")}>
{String(m).padStart(2, "0")}
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
);
}
// --- 타입 ---
interface NewPlanRow {
_id: string;
sourceId: string;
partCode: string;
planQty: string;
planDate: string;
planTime: string;
shipInfo: string;
}
interface ShippingPlanBatchModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selectedDetailIds: string[];
onSuccess?: () => void;
}
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div className={cn("rounded-lg px-3 py-2.5 flex-1 min-w-0", color)}>
<div className="text-[10px] opacity-80 whitespace-nowrap">{label}</div>
<div className="text-lg font-bold">{value.toLocaleString()}</div>
</div>
);
}
// --- 메인 ---
export function ShippingPlanBatchModal({
open, onOpenChange, selectedDetailIds, onSuccess,
}: ShippingPlanBatchModalProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [aggregate, setAggregate] = useState<AggregateResponse>({});
const [newPlans, setNewPlans] = useState<Record<string, NewPlanRow[]>>({});
useEffect(() => {
if (!open || selectedDetailIds.length === 0) return;
const load = async () => {
setLoading(true);
try {
const result = await getShippingPlanAggregate(selectedDetailIds);
if (result.success && result.data) {
setAggregate(result.data);
const plans: Record<string, NewPlanRow[]> = {};
for (const partCode of Object.keys(result.data)) {
plans[partCode] = [makeNewRow(partCode, result.data[partCode].orders[0]?.sourceId || "")];
}
setNewPlans(plans);
}
} catch (err) {
console.error("출하계획 집계 조회 실패:", err);
toast.error("출하계획 정보를 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
};
load();
}, [open, selectedDetailIds]);
const makeNewRow = (partCode: string, sourceId: string): NewPlanRow => ({
_id: `new_${Date.now()}_${Math.random()}`,
sourceId, partCode, planQty: "", planDate: "", planTime: "", shipInfo: "",
});
const addRow = (partCode: string) => {
const sourceId = aggregate[partCode]?.orders[0]?.sourceId || "";
setNewPlans((prev) => ({ ...prev, [partCode]: [...(prev[partCode] || []), makeNewRow(partCode, sourceId)] }));
};
const removeRow = (partCode: string, rowId: string) => {
setNewPlans((prev) => ({ ...prev, [partCode]: (prev[partCode] || []).filter((r) => r._id !== rowId) }));
};
const updateRow = (partCode: string, rowId: string, field: keyof NewPlanRow, value: string) => {
// planQty 변경 시 총수주잔량 초과 검증
if (field === "planQty" && value) {
const agg = aggregate[partCode];
if (agg) {
const maxQty = agg.totalBalance - agg.totalPlanQty; // 기존 계획 제외한 잔여 가능량
const otherSum = (newPlans[partCode] || [])
.filter((r) => r._id !== rowId)
.reduce((sum, r) => sum + (Number(r.planQty) || 0), 0);
const remaining = maxQty - otherSum;
if (Number(value) > remaining) {
toast.error(`출하계획량이 잔여 가능량(${remaining.toLocaleString()})을 초과할 수 없습니다.`);
value = String(Math.max(0, remaining));
}
}
}
setNewPlans((prev) => ({
...prev,
[partCode]: (prev[partCode] || []).map((r) => r._id === rowId ? { ...r, [field]: value } : r),
}));
};
const totalNewPlans = Object.values(newPlans).flat().filter((r) => r.planQty && Number(r.planQty) > 0).length;
const handleSave = async () => {
const plans: BatchSavePlan[] = [];
for (const rows of Object.values(newPlans)) {
for (const row of rows) {
const qty = Number(row.planQty);
if (qty <= 0) continue;
plans.push({ sourceId: row.sourceId, planQty: qty, planDate: row.planDate || undefined });
}
}
if (plans.length === 0) { toast.error("출하계획 수량을 입력해주세요."); return; }
setSaving(true);
try {
const result = await batchSaveShippingPlans(plans, "detail");
if (result.success) {
toast.success(`출하계획 ${plans.length}건이 등록되었습니다.`);
onSuccess?.();
onOpenChange(false);
} else {
toast.error(result.message || "등록 실패");
}
} catch { toast.error("등록 실패"); } finally { setSaving(false); }
};
const partCodes = Object.keys(aggregate);
return (
<FullscreenDialog
open={open}
onOpenChange={onOpenChange}
title={<><Truck className="h-5 w-5 inline mr-2" /> </>}
description={<> : <strong>{totalNewPlans}</strong></>}
defaultMaxWidth="max-w-[1200px]"
footer={
<div className="flex items-center justify-between w-full">
<span className="text-sm text-muted-foreground">💡 </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={handleSave} disabled={saving || totalNewPlans === 0}>
{saving && <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />}
</Button>
</div>
</div>
}
>
<div className="flex-1 overflow-auto space-y-6 py-2 px-1">
{loading ? (
<div className="flex items-center justify-center h-40"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
) : partCodes.length === 0 ? (
<div className="text-center text-muted-foreground py-10"> .</div>
) : partCodes.map((partCode) => {
const agg = aggregate[partCode];
const orders = agg.orders || [];
const existingPlans = agg.existingPlans || [];
const rows = newPlans[partCode] || [];
const firstOrder = orders[0];
return (
<div key={partCode} className="border rounded-xl overflow-hidden bg-card">
{/* 품목 헤더 */}
<div className="flex items-center justify-between px-5 py-3 bg-muted/30 border-b">
<div className="flex items-center gap-3">
<Package className="h-7 w-7 text-muted-foreground shrink-0" />
<div>
<div className="text-[10px] text-muted-foreground"></div>
<div className="font-bold">{partCode}</div>
</div>
</div>
<div className="text-right">
<div className="text-[10px] text-muted-foreground"></div>
<div className="font-bold">{firstOrder?.partName || "-"}</div>
</div>
</div>
{/* 통계 카드 (신규 입력량 반영) */}
{(() => {
const newQtySum = (newPlans[partCode] || []).reduce((sum, r) => sum + (Number(r.planQty) || 0), 0);
const totalPlanQty = agg.totalPlanQty + newQtySum;
const availableStock = agg.currentStock - totalPlanQty;
return (
<div className="flex gap-2 px-4 py-2.5">
<StatCard label="총수주잔량" value={agg.totalBalance} color="bg-violet-100 text-violet-900 dark:bg-violet-900/30 dark:text-violet-200" />
<StatCard label="총출하계획량" value={totalPlanQty} color="bg-blue-100 text-blue-900 dark:bg-blue-900/30 dark:text-blue-200" />
<StatCard label="현재고" value={agg.currentStock} color="bg-amber-100 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200" />
<StatCard label="가용재고" value={availableStock} color="bg-emerald-100 text-emerald-900 dark:bg-emerald-900/30 dark:text-emerald-200" />
<StatCard label="생산중수량" value={agg.inProductionQty} color="bg-cyan-100 text-cyan-900 dark:bg-cyan-900/30 dark:text-cyan-200" />
</div>
);
})()}
{/* 테이블 — overflow-x-auto로 겹침 방지 */}
<div className="px-4 pb-3">
<table className="w-full text-sm table-fixed">
<colgroup>
<col style={{ width: 50 }} />
<col style={{ width: "16%" }} />
<col style={{ width: "9%" }} />
<col style={{ width: "7%" }} />
<col style={{ width: "6%" }} />
<col style={{ width: "7%" }} />
<col style={{ width: "17%" }} />
<col style={{ width: "10%" }} />
<col style={{ width: "14%" }} />
<col style={{ width: 42 }} />
</colgroup>
<thead>
<tr className="border-b text-xs text-muted-foreground">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-center"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-center"></th>
</tr>
</thead>
<tbody>
{/* 기존 계획 */}
{existingPlans.map((plan) => {
const order = orders.find((o) => o.sourceId === plan.sourceId);
return (
<tr key={`ex-${plan.id}`} className="border-b border-dashed">
<td className="py-2"><Badge variant="secondary" className="text-[10px]"></Badge></td>
<td className="py-2 text-xs truncate max-w-[150px]">{order?.orderNo || "-"}</td>
<td className="py-2 text-xs">{order?.partnerName || "-"}</td>
<td className="py-2 text-xs">{order?.dueDate?.split("T")[0] || "-"}</td>
<td className="py-2 text-right text-xs">{order?.balanceQty?.toLocaleString() || "-"}</td>
<td className="py-2 text-center text-xs">{plan.planQty.toLocaleString()}</td>
<td className="py-2 text-center text-xs">{plan.planDate?.split("T")[0] || "-"}</td>
<td className="py-2 text-center text-xs">-</td>
<td className="py-2 text-xs">{plan.shipmentPlanNo || "-"}</td>
<td></td>
</tr>
);
})}
{/* 신규 입력 행 */}
{rows.map((row, rowIdx) => {
const order = orders.find((o) => o.sourceId === row.sourceId) || orders[0];
return (
<tr key={row._id} className="border-b">
<td className="py-2.5"><Badge className="text-[10px] bg-primary"></Badge></td>
<td className="py-2.5 text-xs truncate">{order?.orderNo || "-"}</td>
<td className="py-2.5 text-xs">{order?.partnerName || "-"}</td>
<td className="py-2.5 text-xs">{order?.dueDate?.split("T")[0] || "-"}</td>
<td className="py-2.5 text-right text-xs">{order?.balanceQty?.toLocaleString() || "-"}</td>
<td className="py-2 px-1">
<Input value={row.planQty}
onChange={(e) => {
const raw = e.target.value.replace(/[^0-9]/g, "");
updateRow(partCode, row._id, "planQty", raw ? String(Number(raw)) : "");
}}
className="h-8 text-xs text-center" placeholder="0" />
</td>
<td className="py-2 px-1">
<FormDatePicker value={row.planDate}
onChange={(v) => updateRow(partCode, row._id, "planDate", v)} placeholder="계획일" />
</td>
<td className="py-2 px-1">
<TimePicker value={row.planTime}
onChange={(v) => updateRow(partCode, row._id, "planTime", v)} />
</td>
<td className="py-2 px-1">
<Input value={row.shipInfo}
onChange={(e) => updateRow(partCode, row._id, "shipInfo", e.target.value)}
className="h-8 text-xs" placeholder="출하정보 입력" />
</td>
<td className="py-2 text-center">
{rowIdx === rows.length - 1 ? (
<Button variant="outline" size="sm" className="h-7 w-7 p-0 rounded-full"
onClick={() => addRow(partCode)}><Plus className="h-3.5 w-3.5" /></Button>
) : (
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive"
onClick={() => removeRow(partCode, row._id)}><X className="h-3.5 w-3.5" /></Button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
})}
</div>
</FullscreenDialog>
);
}

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

View File

@ -0,0 +1,569 @@
"use client";
/**
* TableSettingsModal -- (3)
*
* 1: 컬럼 -- /, , (px) ,
* 2: 필터 -- /, (//), (%) ,
* 3: 그룹 --
*
* localStorage에 , onSave
* DynamicSearchFilter, DataGrid와
*/
import React, { useState, useEffect } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { GripVertical, Settings2, SlidersHorizontal, Layers, RotateCcw, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext, verticalListSortingStrategy, useSortable, arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// ===== 타입 =====
export interface ColumnSetting {
columnName: string;
displayName: string;
visible: boolean;
width: number;
}
export interface FilterSetting {
columnName: string;
displayName: string;
enabled: boolean;
filterType: "text" | "select" | "date";
width: number;
}
export interface GroupSetting {
columnName: string;
displayName: string;
enabled: boolean;
}
export interface TableSettings {
columns: ColumnSetting[];
filters: FilterSetting[];
groups: GroupSetting[];
frozenCount: number;
groupSumEnabled: boolean;
}
export interface TableSettingsModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 테이블명 (web-types API 호출용) */
tableName: string;
/** localStorage 키 분리용 고유 ID */
settingsId: string;
/** 저장 시 콜백 */
onSave?: (settings: TableSettings) => void;
/** 초기 탭 */
initialTab?: "columns" | "filters" | "groups";
}
// ===== 상수 =====
const FILTER_TYPE_OPTIONS = [
{ value: "text", label: "텍스트" },
{ value: "select", label: "선택" },
{ value: "date", label: "날짜" },
];
const AUTO_COLS = ["id", "created_date", "updated_date", "writer", "company_code"];
// ===== 유틸 =====
function getStorageKey(settingsId: string) {
return `table_settings_${settingsId}`;
}
/** localStorage에서 저장된 설정 로드 (외부에서도 사용 가능) */
export function loadTableSettings(settingsId: string): TableSettings | null {
try {
const raw = localStorage.getItem(getStorageKey(settingsId));
if (!raw) return null;
return JSON.parse(raw);
} catch {
return null;
}
}
/** 저장된 컬럼 순서/설정을 API 컬럼과 병합 */
function mergeColumns(fresh: ColumnSetting[], saved: ColumnSetting[]): ColumnSetting[] {
const savedMap = new Map(saved.map((s) => [s.columnName, s]));
const ordered: ColumnSetting[] = [];
// 저장된 순서대로
for (const s of saved) {
const f = fresh.find((c) => c.columnName === s.columnName);
if (f) ordered.push({ ...f, visible: s.visible, width: s.width });
}
// 새로 추가된 컬럼은 맨 뒤에
for (const f of fresh) {
if (!savedMap.has(f.columnName)) ordered.push(f);
}
return ordered;
}
// ===== Sortable Column Row (탭 1) =====
function SortableColumnRow({
col,
onToggleVisible,
onWidthChange,
}: {
col: ColumnSetting & { _idx: number };
onToggleVisible: (idx: number) => void;
onWidthChange: (idx: number, width: number) => void;
}) {
const {
attributes, listeners, setNodeRef, transform, transition, isDragging,
} = useSortable({ id: col.columnName });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center gap-3 py-2 px-2 rounded hover:bg-muted/50",
isDragging && "bg-muted/50 shadow-md",
)}
>
{/* 드래그 핸들 */}
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground"
>
<GripVertical className="h-4 w-4" />
</button>
{/* 표시 체크박스 */}
<Checkbox
checked={col.visible}
onCheckedChange={() => onToggleVisible(col._idx)}
/>
{/* 표시 토글 (Switch) */}
<Switch
checked={col.visible}
onCheckedChange={() => onToggleVisible(col._idx)}
className="shrink-0"
/>
{/* 컬럼명 + 기술명 */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{col.displayName}</div>
<div className="text-xs text-muted-foreground truncate">{col.columnName}</div>
</div>
{/* 너비 입력 */}
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-xs text-muted-foreground">:</span>
<Input
type="number"
value={col.width}
onChange={(e) => onWidthChange(col._idx, Number(e.target.value) || 100)}
className="h-8 w-[70px] text-xs text-center"
min={50}
max={500}
/>
</div>
</div>
);
}
// ===== TableSettingsModal =====
export function TableSettingsModal({
open,
onOpenChange,
tableName,
settingsId,
onSave,
initialTab = "columns",
}: TableSettingsModalProps) {
const [activeTab, setActiveTab] = useState(initialTab);
const [loading, setLoading] = useState(false);
// 임시 설정 (모달 내에서만 수정, 저장 시 반영)
const [tempColumns, setTempColumns] = useState<ColumnSetting[]>([]);
const [tempFilters, setTempFilters] = useState<FilterSetting[]>([]);
const [tempGroups, setTempGroups] = useState<GroupSetting[]>([]);
const [tempFrozenCount, setTempFrozenCount] = useState(0);
const [tempGroupSum, setTempGroupSum] = useState(false);
// 원본 컬럼 (초기화용)
const [defaultColumns, setDefaultColumns] = useState<ColumnSetting[]>([]);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
// 모달 열릴 때 데이터 로드
useEffect(() => {
if (!open) return;
setActiveTab(initialTab);
loadData();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const loadData = async () => {
setLoading(true);
try {
const res = await apiClient.get(`/table-management/tables/${tableName}/web-types`);
const types: any[] = res.data?.data || [];
// 기본 컬럼 설정 생성
const freshColumns: ColumnSetting[] = types
.filter((t) => !AUTO_COLS.includes(t.columnName))
.map((t) => ({
columnName: t.columnName,
displayName: t.displayName || t.columnLabel || t.columnName,
visible: true,
width: 120,
}));
// 기본 필터 설정 생성
const freshFilters: FilterSetting[] = freshColumns.map((c) => {
const wt = types.find((t) => t.columnName === c.columnName);
let filterType: "text" | "select" | "date" = "text";
if (wt?.inputType === "category" || wt?.inputType === "select") filterType = "select";
else if (wt?.inputType === "date" || wt?.inputType === "datetime") filterType = "date";
return {
columnName: c.columnName,
displayName: c.displayName,
enabled: false,
filterType,
width: 25,
};
});
// 기본 그룹 설정 생성
const freshGroups: GroupSetting[] = freshColumns.map((c) => ({
columnName: c.columnName,
displayName: c.displayName,
enabled: false,
}));
setDefaultColumns(freshColumns);
// localStorage에서 저장된 설정 복원
const saved = loadTableSettings(settingsId);
if (saved) {
setTempColumns(mergeColumns(freshColumns, saved.columns));
setTempFilters(freshFilters.map((f) => {
const s = saved.filters?.find((sf) => sf.columnName === f.columnName);
return s ? { ...f, enabled: s.enabled, filterType: s.filterType, width: s.width } : f;
}));
setTempGroups(freshGroups.map((g) => {
const s = saved.groups?.find((sg) => sg.columnName === g.columnName);
return s ? { ...g, enabled: s.enabled } : g;
}));
setTempFrozenCount(saved.frozenCount || 0);
setTempGroupSum(saved.groupSumEnabled || false);
} else {
setTempColumns(freshColumns);
setTempFilters(freshFilters);
setTempGroups(freshGroups);
setTempFrozenCount(0);
setTempGroupSum(false);
}
} catch (err) {
console.error("테이블 설정 로드 실패:", err);
} finally {
setLoading(false);
}
};
// 저장
const handleSave = () => {
const settings: TableSettings = {
columns: tempColumns,
filters: tempFilters,
groups: tempGroups,
frozenCount: tempFrozenCount,
groupSumEnabled: tempGroupSum,
};
localStorage.setItem(getStorageKey(settingsId), JSON.stringify(settings));
onSave?.(settings);
onOpenChange(false);
};
// 컬럼 설정 초기화
const handleResetColumns = () => {
setTempColumns(defaultColumns.map((c) => ({ ...c })));
setTempFrozenCount(0);
};
// ===== 컬럼 설정 핸들러 =====
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setTempColumns((prev) => {
const oldIdx = prev.findIndex((c) => c.columnName === active.id);
const newIdx = prev.findIndex((c) => c.columnName === over.id);
return arrayMove(prev, oldIdx, newIdx);
});
};
const toggleColumnVisible = (idx: number) => {
setTempColumns((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], visible: !next[idx].visible };
return next;
});
};
const changeColumnWidth = (idx: number, width: number) => {
setTempColumns((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], width };
return next;
});
};
// ===== 필터 설정 핸들러 =====
const allFiltersEnabled = tempFilters.length > 0 && tempFilters.every((f) => f.enabled);
const toggleFilterAll = (checked: boolean) => {
setTempFilters((prev) => prev.map((f) => ({ ...f, enabled: checked })));
};
const toggleFilter = (idx: number) => {
setTempFilters((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], enabled: !next[idx].enabled };
return next;
});
};
const changeFilterType = (idx: number, filterType: "text" | "select" | "date") => {
setTempFilters((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], filterType };
return next;
});
};
const changeFilterWidth = (idx: number, width: number) => {
setTempFilters((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], width };
return next;
});
};
// ===== 그룹 설정 핸들러 =====
const toggleGroup = (idx: number) => {
setTempGroups((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], enabled: !next[idx].enabled };
return next;
});
};
const visibleCount = tempColumns.filter((c) => c.visible).length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> , , </DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center py-12 text-muted-foreground">
...
</div>
) : (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as typeof activeTab)} className="flex-1 flex flex-col min-h-0">
<TabsList className="grid w-full grid-cols-3 shrink-0">
<TabsTrigger value="columns" className="flex items-center gap-1.5">
<Settings2 className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="filters" className="flex items-center gap-1.5">
<SlidersHorizontal className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger value="groups" className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
</TabsTrigger>
</TabsList>
{/* ===== 탭 1: 컬럼 설정 ===== */}
<TabsContent value="columns" className="flex-1 overflow-auto mt-0 pt-3">
{/* 헤더: 표시 수 / 틀고정 / 초기화 */}
<div className="flex items-center justify-between px-2 pb-3 border-b mb-2">
<div className="flex items-center gap-3 text-sm">
<span>
{visibleCount}/{tempColumns.length}
</span>
<div className="flex items-center gap-1.5">
<Lock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">:</span>
<Input
type="number"
value={tempFrozenCount}
onChange={(e) =>
setTempFrozenCount(
Math.min(Math.max(0, Number(e.target.value) || 0), tempColumns.length)
)
}
className="h-7 w-[50px] text-xs text-center"
min={0}
max={tempColumns.length}
/>
<span className="text-muted-foreground text-sm"> </span>
</div>
</div>
<Button variant="ghost" size="sm" onClick={handleResetColumns} className="text-xs">
</Button>
</div>
{/* 컬럼 목록 (드래그 순서 변경 가능) */}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={tempColumns.map((c) => c.columnName)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-0.5">
{tempColumns.map((col, idx) => (
<SortableColumnRow
key={col.columnName}
col={{ ...col, _idx: idx }}
onToggleVisible={toggleColumnVisible}
onWidthChange={changeColumnWidth}
/>
))}
</div>
</SortableContext>
</DndContext>
</TabsContent>
{/* ===== 탭 2: 필터 설정 ===== */}
<TabsContent value="filters" className="flex-1 overflow-auto mt-0 pt-3">
{/* 전체 선택 */}
<div
className="flex items-center gap-2 px-2 pb-3 border-b mb-2 cursor-pointer"
onClick={() => toggleFilterAll(!allFiltersEnabled)}
>
<Checkbox checked={allFiltersEnabled} />
<span className="text-sm"> </span>
</div>
{/* 필터 목록 */}
<div className="space-y-1">
{tempFilters.map((filter, idx) => (
<div
key={filter.columnName}
className="flex items-center gap-3 py-1.5 px-2 hover:bg-muted/50 rounded"
>
<Checkbox
checked={filter.enabled}
onCheckedChange={() => toggleFilter(idx)}
/>
<div className="flex-1 text-sm min-w-0 truncate">{filter.displayName}</div>
<Select
value={filter.filterType}
onValueChange={(v) => changeFilterType(idx, v as any)}
>
<SelectTrigger className="h-8 w-[90px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1 shrink-0">
<Input
type="number"
value={filter.width}
onChange={(e) => changeFilterWidth(idx, Number(e.target.value) || 25)}
className="h-8 w-[55px] text-xs text-center"
min={10}
max={100}
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
</div>
))}
</div>
{/* 그룹별 합산 토글 */}
<div className="mt-4 flex items-center justify-between rounded-lg border p-3">
<div>
<div className="text-sm font-medium"> </div>
<div className="text-xs text-muted-foreground"> </div>
</div>
<Switch checked={tempGroupSum} onCheckedChange={setTempGroupSum} />
</div>
</TabsContent>
{/* ===== 탭 3: 그룹 설정 ===== */}
<TabsContent value="groups" className="flex-1 overflow-auto mt-0 pt-3">
<div className="px-2 pb-3 border-b mb-2">
<span className="text-sm font-medium"> </span>
</div>
<div className="space-y-0.5">
{tempGroups.map((group, idx) => (
<div
key={group.columnName}
className={cn(
"flex items-center gap-3 py-2.5 px-3 rounded cursor-pointer hover:bg-muted/50",
group.enabled && "bg-primary/5",
)}
onClick={() => toggleGroup(idx)}
>
<Checkbox checked={group.enabled} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{group.displayName}</div>
<div className="text-xs text-muted-foreground truncate">{group.columnName}</div>
</div>
</div>
))}
</div>
</TabsContent>
</Tabs>
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,825 @@
"use client";
/**
* TimelineScheduler
*
* :
* - (/) Y축, X축
* - (//)
* - (//)
* -
* - (/ )
* -
* -
* - ()
* - +
* - ( )
*/
import React, { useState, useRef, useCallback, useMemo, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
ChevronLeft,
ChevronRight,
CalendarDays,
Loader2,
Diamond,
} from "lucide-react";
import { cn } from "@/lib/utils";
// ─── 타입 정의 ───
export interface TimelineResource {
id: string;
label: string;
subLabel?: string;
}
export interface TimelineEvent {
id: string | number;
resourceId: string;
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
label?: string;
status?: string;
progress?: number; // 0~100
isMilestone?: boolean;
data?: any;
}
export type ZoomLevel = "day" | "week" | "month";
export interface StatusColor {
key: string;
label: string;
bgClass: string; // tailwind gradient class e.g. "from-blue-500 to-blue-600"
}
export interface TimelineSchedulerProps {
resources: TimelineResource[];
events: TimelineEvent[];
/** 타임라인 시작 기준일 (기본: 오늘) */
startDate?: Date;
/** 줌 레벨 (기본: week) */
zoomLevel?: ZoomLevel;
onZoomChange?: (zoom: ZoomLevel) => void;
/** 이벤트 바 클릭 */
onEventClick?: (event: TimelineEvent) => void;
/** 드래그 이동 완료 */
onEventMove?: (eventId: string | number, newStartDate: string, newEndDate: string) => void;
/** 리사이즈 완료 */
onEventResize?: (eventId: string | number, newStartDate: string, newEndDate: string) => void;
/** 상태별 색상 배열 */
statusColors?: StatusColor[];
/** 진행률 바 표시 여부 */
showProgress?: boolean;
/** 마일스톤 표시 여부 */
showMilestones?: boolean;
/** 오늘 세로선 표시 */
showTodayLine?: boolean;
/** 범례 표시 */
showLegend?: boolean;
/** 충돌 감지 */
conflictDetection?: boolean;
/** 로딩 상태 */
loading?: boolean;
/** 데이터 없을 때 메시지 */
emptyMessage?: string;
/** 데이터 없을 때 아이콘 */
emptyIcon?: React.ReactNode;
/** 리소스 열 너비 (px) */
resourceWidth?: number;
/** 행 높이 (px) */
rowHeight?: number;
}
// ─── 기본값 ───
const DEFAULT_STATUS_COLORS: StatusColor[] = [
{ key: "planned", label: "계획", bgClass: "from-blue-500 to-blue-600" },
{ key: "work-order", label: "지시", bgClass: "from-amber-500 to-amber-600" },
{ key: "in-progress", label: "진행", bgClass: "from-emerald-500 to-emerald-600" },
{ key: "completed", label: "완료", bgClass: "from-gray-400 to-gray-500" },
];
const ZOOM_CONFIG: Record<ZoomLevel, { cellWidth: number; spanDays: number; navStep: number }> = {
day: { cellWidth: 60, spanDays: 28, navStep: 7 },
week: { cellWidth: 36, spanDays: 56, navStep: 14 },
month: { cellWidth: 16, spanDays: 90, navStep: 30 },
};
// ─── 유틸리티 함수 ───
/** YYYY-MM-DD 문자열로 변환 */
function toDateStr(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** 날짜 문자열을 Date로 (시간 0시) */
function parseDate(s: string): Date {
const [y, m, d] = s.split("T")[0].split("-").map(Number);
return new Date(y, m - 1, d);
}
/** 두 날짜 사이의 일 수 차이 */
function diffDays(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24));
}
/** 날짜에 일 수 더하기 */
function addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
function isWeekend(d: Date): boolean {
return d.getDay() === 0 || d.getDay() === 6;
}
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
const DAY_NAMES = ["일", "월", "화", "수", "목", "금", "토"];
const MONTH_NAMES = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"];
// ─── 충돌 감지 ───
function detectConflicts(events: TimelineEvent[]): Set<string | number> {
const conflictIds = new Set<string | number>();
const byResource = new Map<string, TimelineEvent[]>();
for (const ev of events) {
if (ev.isMilestone) continue;
if (!byResource.has(ev.resourceId)) byResource.set(ev.resourceId, []);
byResource.get(ev.resourceId)!.push(ev);
}
for (const [, resEvents] of byResource) {
for (let i = 0; i < resEvents.length; i++) {
for (let j = i + 1; j < resEvents.length; j++) {
const a = resEvents[i];
const b = resEvents[j];
const aStart = parseDate(a.startDate).getTime();
const aEnd = parseDate(a.endDate).getTime();
const bStart = parseDate(b.startDate).getTime();
const bEnd = parseDate(b.endDate).getTime();
if (aStart <= bEnd && bStart <= aEnd) {
conflictIds.add(a.id);
conflictIds.add(b.id);
}
}
}
}
return conflictIds;
}
// ─── 메인 컴포넌트 ───
export default function TimelineScheduler({
resources,
events,
startDate: propStartDate,
zoomLevel: propZoom,
onZoomChange,
onEventClick,
onEventMove,
onEventResize,
statusColors = DEFAULT_STATUS_COLORS,
showProgress = true,
showMilestones = true,
showTodayLine = true,
showLegend = true,
conflictDetection = true,
loading = false,
emptyMessage = "데이터가 없습니다",
emptyIcon,
resourceWidth = 160,
rowHeight = 48,
}: TimelineSchedulerProps) {
// ── 상태 ──
const [zoom, setZoom] = useState<ZoomLevel>(propZoom || "week");
const [baseDate, setBaseDate] = useState<Date>(() => {
const d = propStartDate || new Date();
d.setHours(0, 0, 0, 0);
return d;
});
// 드래그/리사이즈 상태
const [dragState, setDragState] = useState<{
eventId: string | number;
mode: "move" | "resize-left" | "resize-right";
origStartDate: string;
origEndDate: string;
startX: number;
currentOffsetDays: number;
} | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// 줌 레벨 동기화
useEffect(() => {
if (propZoom && propZoom !== zoom) setZoom(propZoom);
}, [propZoom]);
const config = ZOOM_CONFIG[zoom];
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
// 날짜 배열 생성
const dates = useMemo(() => {
const arr: Date[] = [];
for (let i = 0; i < config.spanDays; i++) {
arr.push(addDays(baseDate, i));
}
return arr;
}, [baseDate, config.spanDays]);
const totalWidth = config.cellWidth * config.spanDays;
// 충돌 ID 집합
const conflictIds = useMemo(() => {
return conflictDetection ? detectConflicts(events) : new Set<string | number>();
}, [events, conflictDetection]);
// 리소스별 이벤트 그룹
const eventsByResource = useMemo(() => {
const map = new Map<string, TimelineEvent[]>();
for (const r of resources) map.set(r.id, []);
for (const ev of events) {
if (!map.has(ev.resourceId)) map.set(ev.resourceId, []);
map.get(ev.resourceId)!.push(ev);
}
return map;
}, [resources, events]);
// 같은 리소스 내 겹치는 이벤트들의 행(lane) 계산
const eventLanes = useMemo(() => {
const laneMap = new Map<string | number, number>();
for (const [, resEvents] of eventsByResource) {
// 시작일 기준 정렬
const sorted = [...resEvents].sort(
(a, b) => parseDate(a.startDate).getTime() - parseDate(b.startDate).getTime()
);
const lanes: { endTime: number }[] = [];
for (const ev of sorted) {
if (ev.isMilestone) {
laneMap.set(ev.id, 0);
continue;
}
const evStart = parseDate(ev.startDate).getTime();
const evEnd = parseDate(ev.endDate).getTime();
let placed = false;
for (let l = 0; l < lanes.length; l++) {
if (evStart > lanes[l].endTime) {
lanes[l].endTime = evEnd;
laneMap.set(ev.id, l);
placed = true;
break;
}
}
if (!placed) {
laneMap.set(ev.id, lanes.length);
lanes.push({ endTime: evEnd });
}
}
}
return laneMap;
}, [eventsByResource]);
// 리소스별 최대 lane 수 -> 행 높이 결정
const resourceLaneCounts = useMemo(() => {
const map = new Map<string, number>();
for (const [resId, resEvents] of eventsByResource) {
let maxLane = 0;
for (const ev of resEvents) {
const lane = eventLanes.get(ev.id) || 0;
maxLane = Math.max(maxLane, lane);
}
map.set(resId, resEvents.length > 0 ? maxLane + 1 : 1);
}
return map;
}, [eventsByResource, eventLanes]);
// ── 줌/네비게이션 핸들러 ──
const handleZoom = useCallback(
(z: ZoomLevel) => {
setZoom(z);
onZoomChange?.(z);
},
[onZoomChange]
);
const handleNavPrev = useCallback(() => {
setBaseDate((prev) => addDays(prev, -config.navStep));
}, [config.navStep]);
const handleNavNext = useCallback(() => {
setBaseDate((prev) => addDays(prev, config.navStep));
}, [config.navStep]);
const handleNavToday = useCallback(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
setBaseDate(d);
}, []);
// ── 이벤트 바 위치 계산 ──
const getBarStyle = useCallback(
(startDateStr: string, endDateStr: string) => {
const evStart = parseDate(startDateStr);
const evEnd = parseDate(endDateStr);
const firstDate = dates[0];
const lastDate = dates[dates.length - 1];
// 완전히 범위 밖이면 표시하지 않음
if (evEnd < firstDate || evStart > lastDate) return null;
const startIdx = Math.max(0, diffDays(firstDate, evStart));
const endIdx = Math.min(config.spanDays - 1, diffDays(firstDate, evEnd));
const left = startIdx * config.cellWidth;
const width = (endIdx - startIdx + 1) * config.cellWidth;
return { left, width };
},
[dates, config.cellWidth, config.spanDays]
);
// ── 드래그/리사이즈 핸들러 ──
const handleMouseDown = useCallback(
(
e: React.MouseEvent,
eventId: string | number,
mode: "move" | "resize-left" | "resize-right",
startDate: string,
endDate: string
) => {
e.preventDefault();
e.stopPropagation();
setDragState({
eventId,
mode,
origStartDate: startDate,
origEndDate: endDate,
startX: e.clientX,
currentOffsetDays: 0,
});
},
[]
);
// mousemove / mouseup (document-level)
useEffect(() => {
if (!dragState) return;
const handleMouseMove = (e: MouseEvent) => {
const dx = e.clientX - dragState.startX;
const dayOffset = Math.round(dx / config.cellWidth);
setDragState((prev) => (prev ? { ...prev, currentOffsetDays: dayOffset } : null));
};
const handleMouseUp = (e: MouseEvent) => {
if (!dragState) return;
const dx = e.clientX - dragState.startX;
const dayOffset = Math.round(dx / config.cellWidth);
if (dayOffset !== 0) {
const origStart = parseDate(dragState.origStartDate);
const origEnd = parseDate(dragState.origEndDate);
if (dragState.mode === "move") {
const newStart = toDateStr(addDays(origStart, dayOffset));
const newEnd = toDateStr(addDays(origEnd, dayOffset));
onEventMove?.(dragState.eventId, newStart, newEnd);
} else if (dragState.mode === "resize-left") {
const newStart = toDateStr(addDays(origStart, dayOffset));
const newEnd = dragState.origEndDate.split("T")[0];
// 시작이 종료를 넘지 않도록
if (parseDate(newStart) <= parseDate(newEnd)) {
onEventResize?.(dragState.eventId, newStart, newEnd);
}
} else if (dragState.mode === "resize-right") {
const newStart = dragState.origStartDate.split("T")[0];
const newEnd = toDateStr(addDays(origEnd, dayOffset));
if (parseDate(newStart) <= parseDate(newEnd)) {
onEventResize?.(dragState.eventId, newStart, newEnd);
}
}
}
setDragState(null);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [dragState, config.cellWidth, onEventMove, onEventResize]);
// 드래그 중인 이벤트의 현재 표시 위치 계산
const getDraggedBarStyle = useCallback(
(event: TimelineEvent) => {
if (!dragState || dragState.eventId !== event.id) return null;
const origStart = parseDate(dragState.origStartDate);
const origEnd = parseDate(dragState.origEndDate);
const offset = dragState.currentOffsetDays;
let newStart: Date, newEnd: Date;
if (dragState.mode === "move") {
newStart = addDays(origStart, offset);
newEnd = addDays(origEnd, offset);
} else if (dragState.mode === "resize-left") {
newStart = addDays(origStart, offset);
newEnd = origEnd;
if (newStart > newEnd) newStart = newEnd;
} else {
newStart = origStart;
newEnd = addDays(origEnd, offset);
if (newEnd < newStart) newEnd = newStart;
}
return getBarStyle(toDateStr(newStart), toDateStr(newEnd));
},
[dragState, getBarStyle]
);
// ── 오늘 라인 위치 ──
const todayLineLeft = useMemo(() => {
if (!showTodayLine || dates.length === 0) return null;
const firstDate = dates[0];
const lastDate = dates[dates.length - 1];
if (today < firstDate || today > lastDate) return null;
const idx = diffDays(firstDate, today);
return idx * config.cellWidth + config.cellWidth / 2;
}, [dates, today, config.cellWidth, showTodayLine]);
// ── 상태 색상 매핑 ──
const getStatusColor = useCallback(
(status?: string) => {
if (!status) return statusColors[0]?.bgClass || "from-blue-500 to-blue-600";
const found = statusColors.find((c) => c.key === status);
return found?.bgClass || statusColors[0]?.bgClass || "from-blue-500 to-blue-600";
},
[statusColors]
);
// ── 날짜 헤더 그룹 ──
const dateGroups = useMemo(() => {
if (zoom === "day") {
return null; // day 뷰에서는 상위 그룹 없이 바로 날짜 표시
}
// week / month 뷰: 월 단위로 그룹
const groups: { label: string; span: number; startIdx: number }[] = [];
let currentMonth = -1;
let currentYear = -1;
for (let i = 0; i < dates.length; i++) {
const d = dates[i];
if (d.getMonth() !== currentMonth || d.getFullYear() !== currentYear) {
groups.push({
label: `${d.getFullYear()}${MONTH_NAMES[d.getMonth()]}`,
span: 1,
startIdx: i,
});
currentMonth = d.getMonth();
currentYear = d.getFullYear();
} else {
groups[groups.length - 1].span++;
}
}
return groups;
}, [dates, zoom]);
// ── 렌더링 ──
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (resources.length === 0 || events.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
{emptyIcon}
<p className="text-base font-medium mb-2 mt-3">{emptyMessage}</p>
</div>
);
}
const barHeight = 24;
const barGap = 2;
return (
<div className="flex flex-col gap-3">
{/* 컨트롤 바 */}
<div className="flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={handleNavPrev}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={handleNavToday}>
<CalendarDays className="mr-1 h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={handleNavNext}>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="text-xs text-muted-foreground ml-2">
{toDateStr(dates[0])} ~ {toDateStr(dates[dates.length - 1])}
</span>
</div>
<div className="flex items-center gap-1">
{(["day", "week", "month"] as ZoomLevel[]).map((z) => (
<Button
key={z}
size="sm"
variant={zoom === z ? "default" : "outline"}
className="h-7 text-xs px-3"
onClick={() => handleZoom(z)}
>
{z === "day" ? "일" : z === "week" ? "주" : "월"}
</Button>
))}
</div>
</div>
{/* 범례 */}
{showLegend && (
<div className="flex items-center gap-4 flex-wrap text-xs">
<span className="font-semibold text-muted-foreground">:</span>
{statusColors.map((sc) => (
<div key={sc.key} className="flex items-center gap-1.5">
<div className={cn("h-3.5 w-5 rounded bg-gradient-to-br", sc.bgClass)} />
<span>{sc.label}</span>
</div>
))}
{showMilestones && (
<div className="flex items-center gap-1.5">
<Diamond className="h-3.5 w-3.5 text-purple-500 fill-purple-500" />
<span></span>
</div>
)}
{conflictDetection && (
<div className="flex items-center gap-1.5">
<div className="h-3.5 w-5 rounded border-2 border-red-500 bg-red-500/20" />
<span></span>
</div>
)}
</div>
)}
{/* 타임라인 본체 */}
<div className="rounded-lg border bg-background overflow-hidden">
<div
ref={scrollRef}
className="overflow-x-auto overflow-y-auto"
style={{ maxHeight: "calc(100vh - 350px)" }}
>
<div className="flex" style={{ minWidth: resourceWidth + totalWidth }}>
{/* 좌측: 리소스 라벨 */}
<div
className="shrink-0 border-r bg-muted/30 z-20 sticky left-0"
style={{ width: resourceWidth }}
>
{/* 헤더 공간 */}
<div
className="border-b bg-muted/50 flex items-center justify-center text-xs font-semibold text-muted-foreground"
style={{ height: dateGroups ? 60 : 36 }}
>
</div>
{/* 리소스 행 */}
{resources.map((res) => {
const laneCount = resourceLaneCounts.get(res.id) || 1;
const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
return (
<div
key={res.id}
className="border-b px-3 flex flex-col justify-center"
style={{ height: h }}
>
<span className="text-xs font-semibold text-foreground truncate">
{res.label}
</span>
{res.subLabel && (
<span className="text-[10px] text-muted-foreground truncate">
{res.subLabel}
</span>
)}
</div>
);
})}
</div>
{/* 우측: 타임라인 그리드 */}
<div className="flex-1 relative" ref={gridRef} style={{ width: totalWidth }}>
{/* 날짜 헤더 */}
<div className="sticky top-0 z-10 bg-background border-b">
{/* 상위 그룹 (월) */}
{dateGroups && (
<div className="flex border-b">
{dateGroups.map((g, idx) => (
<div
key={idx}
className="text-center text-[11px] font-semibold text-muted-foreground border-r py-1"
style={{ width: g.span * config.cellWidth }}
>
{g.label}
</div>
))}
</div>
)}
{/* 하위 날짜 셀 */}
<div className="flex">
{dates.map((date, idx) => {
const isT = isSameDay(date, today);
const isW = isWeekend(date);
return (
<div
key={idx}
className={cn(
"text-center border-r select-none",
isW && "text-red-400",
isT && "bg-primary/10 font-bold text-primary"
)}
style={{
width: config.cellWidth,
minWidth: config.cellWidth,
fontSize: zoom === "month" ? 9 : 11,
padding: zoom === "month" ? "2px 0" : "3px 0",
}}
>
{zoom === "month" ? (
<div>{date.getDate()}</div>
) : (
<>
<div className="font-semibold">{date.getDate()}</div>
<div>{DAY_NAMES[date.getDay()]}</div>
</>
)}
</div>
);
})}
</div>
</div>
{/* 리소스별 이벤트 행 */}
{resources.map((res) => {
const resEvents = eventsByResource.get(res.id) || [];
const laneCount = resourceLaneCounts.get(res.id) || 1;
const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
return (
<div key={res.id} className="relative border-b" style={{ height: h }}>
{/* 배경 그리드 */}
<div className="absolute inset-0 flex pointer-events-none">
{dates.map((date, idx) => (
<div
key={idx}
className={cn(
"border-r border-border/20",
isWeekend(date) && "bg-red-500/[0.03]"
)}
style={{ width: config.cellWidth, minWidth: config.cellWidth }}
/>
))}
</div>
{/* 오늘 라인 */}
{todayLineLeft != null && (
<div
className="absolute top-0 bottom-0 w-[2px] bg-red-500 z-[5] pointer-events-none"
style={{ left: todayLineLeft }}
/>
)}
{/* 이벤트 바 */}
{resEvents.map((ev) => {
if (ev.isMilestone && showMilestones) {
// 마일스톤: 다이아몬드 아이콘
const pos = getBarStyle(ev.startDate, ev.startDate);
if (!pos) return null;
return (
<div
key={ev.id}
className="absolute z-10 flex items-center justify-center cursor-pointer"
style={{
left: pos.left + pos.width / 2 - 8,
top: "50%",
transform: "translateY(-50%)",
}}
title={ev.label || "마일스톤"}
onClick={() => onEventClick?.(ev)}
>
<Diamond className="h-4 w-4 text-purple-500 fill-purple-500" />
</div>
);
}
// 일반 이벤트 바
const isDragging = dragState?.eventId === ev.id;
const barStyle = isDragging
? getDraggedBarStyle(ev)
: getBarStyle(ev.startDate, ev.endDate);
if (!barStyle) return null;
const lane = eventLanes.get(ev.id) || 0;
const colorClass = getStatusColor(ev.status);
const isConflict = conflictIds.has(ev.id);
const progress = ev.progress ?? 0;
return (
<div
key={ev.id}
className={cn(
"absolute rounded shadow-sm z-10 group select-none",
`bg-gradient-to-br ${colorClass}`,
isDragging && "opacity-80 shadow-lg z-20",
isConflict && "ring-2 ring-red-500 ring-offset-1",
"cursor-grab active:cursor-grabbing"
)}
style={{
left: barStyle.left,
width: Math.max(barStyle.width, config.cellWidth * 0.5),
height: barHeight,
top: 6 + lane * (barHeight + barGap),
}}
title={`${ev.label || ""} | ${ev.startDate.split("T")[0]} ~ ${ev.endDate.split("T")[0]}${progress > 0 ? ` | ${progress}%` : ""}`}
onClick={(e) => {
if (!isDragging) {
e.stopPropagation();
onEventClick?.(ev);
}
}}
onMouseDown={(e) => handleMouseDown(e, ev.id, "move", ev.startDate, ev.endDate)}
>
{/* 진행률 바 */}
{showProgress && progress > 0 && (
<div
className="absolute inset-y-0 left-0 rounded-l bg-white/25"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
)}
{/* 라벨 */}
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-white drop-shadow-sm truncate px-1">
{ev.label || ""}
{showProgress && progress > 0 && (
<span className="ml-1 opacity-75">({progress}%)</span>
)}
</span>
{/* 좌측 리사이즈 핸들 */}
<div
className="absolute left-0 top-0 bottom-0 w-[5px] cursor-col-resize opacity-0 group-hover:opacity-100 bg-white/30 rounded-l"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, ev.id, "resize-left", ev.startDate, ev.endDate);
}}
/>
{/* 우측 리사이즈 핸들 */}
<div
className="absolute right-0 top-0 bottom-0 w-[5px] cursor-col-resize opacity-0 group-hover:opacity-100 bg-white/30 rounded-r"
onMouseDown={(e) => {
e.stopPropagation();
handleMouseDown(e, ev.id, "resize-right", ev.startDate, ev.endDate);
}}
/>
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -648,24 +648,31 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
);
if (response.data.success && response.data.data) {
// valueCode 및 valueId -> {label, color} 매핑 생성
// valueCode 및 valueId -> {label, color} 매핑 생성 (트리 재귀 평탄화)
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
// valueCode로 매핑
if (item.valueCode) {
mapping[item.valueCode] = {
label: item.valueLabel,
color: item.color,
};
}
// valueId로도 매핑 (숫자 ID 저장 시 라벨 표시용)
if (item.valueId !== undefined && item.valueId !== null) {
mapping[String(item.valueId)] = {
label: item.valueLabel,
color: item.color,
};
}
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const displayLabel = parentLabel
? `${parentLabel} / ${item.valueLabel}`
: item.valueLabel;
if (item.valueCode) {
mapping[item.valueCode] = {
label: displayLabel,
color: item.color,
};
}
if (item.valueId !== undefined && item.valueId !== null) {
mapping[String(item.valueId)] = {
label: displayLabel,
color: item.color,
};
}
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, item.valueLabel);
}
});
};
flattenCategoryTree(response.data.data);
mappings[col.columnName] = mapping;
console.log(`✅ 카테고리 매핑 로드 성공 [${col.columnName}]:`, mapping, { menuObjid });
}

View File

@ -31,7 +31,7 @@ const AlertDialog: React.FC<React.ComponentProps<typeof AlertDialogPrimitive.Roo
const isTabActiveRef = React.useRef(isTabActive);
isTabActiveRef.current = isTabActive;
const effectiveOpen = open != null ? open && isTabActive : undefined;
const effectiveOpen = open != null ? open : undefined;
const guardedOnOpenChange = React.useCallback(
(newOpen: boolean) => {
@ -94,6 +94,11 @@ const AlertDialogContent = React.forwardRef<
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
const scoped = React.useContext(ScopedAlertCtx);
// 탭 비활성 시 content를 언마운트하지 않고 CSS로 숨김 (자식 컴포넌트 상태 보존)
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
const adjustedStyle = scoped && style
? { ...style, maxHeight: undefined, maxWidth: undefined }
: style;
@ -117,7 +122,7 @@ const AlertDialogContent = React.forwardRef<
<DialogPrimitive.Portal container={container ?? undefined}>
<div
className="absolute inset-0 z-1050 flex items-center justify-center overflow-hidden p-4"
style={hiddenProp ? { display: "none" } : undefined}
style={(hiddenProp || !isTabActive) ? { display: "none" } : undefined}
>
<div className="absolute inset-0 bg-black/80" />
<DialogPrimitive.Content

View File

@ -28,7 +28,7 @@ const Dialog: React.FC<React.ComponentProps<typeof DialogPrimitive.Root>> = ({
isTabActiveRef.current = isTabActive;
const effectiveModal = modal !== undefined ? modal : !scoped ? undefined : false;
const effectiveOpen = open != null ? open && isTabActive : undefined;
const effectiveOpen = open != null ? open : undefined;
// 비활성 탭에서 발생하는 onOpenChange(false) 차단
// (탭 전환 시 content unmount → focus 이동 → Radix가 onOpenChange(false)를 호출하는 것을 방지)
@ -83,6 +83,11 @@ const DialogContent = React.forwardRef<
const container = explicitContainer !== undefined ? explicitContainer : autoContainer;
const scoped = !!container;
// 탭 비활성 시 content를 언마운트하지 않고 CSS로 숨김 (자식 컴포넌트 상태 보존)
const tabId = useTabId();
const activeTabId = useTabStore((s) => s[s.mode].activeTabId);
const isTabActive = !tabId || tabId === activeTabId;
// state 기반 ref: DialogPrimitive.Content 마운트/언마운트 시 useEffect 재실행 보장
const [contentNode, setContentNode] = React.useState<HTMLDivElement | null>(null);
const mergedRef = React.useCallback(
@ -130,7 +135,7 @@ const DialogContent = React.forwardRef<
<DialogPortal container={container ?? undefined}>
<div
className={scoped ? "absolute inset-0 z-999 flex items-center justify-center overflow-hidden p-4" : undefined}
style={hiddenProp ? { display: "none" } : undefined}
style={(hiddenProp || (scoped && !isTabActive)) ? { display: "none" } : undefined}
>
{scoped ? (
<div className="absolute inset-0 bg-black/60" />

View File

@ -92,7 +92,14 @@ const DropdownSelect = forwardRef<
className={cn("w-full", allowClear && hasValue ? "pr-8" : "", className)}
style={style}
>
<SelectValue placeholder={placeholder} />
<span data-slot="select-value">
{(() => {
const val = typeof value === "string" ? value : (value?.[0] ?? "");
const opt = options.find(o => o.value === val);
if (!opt || !val) return placeholder;
return opt.displayLabel || opt.label;
})()}
</span>
</SelectTrigger>
<SelectContent>
{options
@ -139,7 +146,7 @@ const DropdownSelect = forwardRef<
const selectedLabels = useMemo(() => {
return safeOptions
.filter((o) => selectedValues.includes(o.value))
.map((o) => o.label)
.map((o) => o.displayLabel || o.label)
.filter(Boolean) as string[];
}, [selectedValues, safeOptions]);
@ -896,18 +903,23 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
// 트리 구조를 평탄화하여 옵션으로 변환
// 🔧 value로 valueCode를 사용 (커스텀 테이블 저장/조회 호환)
const flattenTree = (
items: { valueId: number; valueCode: string; valueLabel: string; children?: any[] }[],
items: { valueId: number; valueCode: string; valueLabel: string; path?: string; children?: any[] }[],
depth: number = 0,
parentLabel: string = "",
): SelectOption[] => {
const result: SelectOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
const displayLabel = parentLabel
? `${parentLabel} / ${item.valueLabel}`
: item.valueLabel;
result.push({
value: item.valueCode, // 🔧 valueCode를 value로 사용
label: prefix + item.valueLabel,
displayLabel,
});
if (item.children && item.children.length > 0) {
result.push(...flattenTree(item.children, depth + 1));
result.push(...flattenTree(item.children, depth + 1, item.valueLabel));
}
}
return result;

View File

@ -171,6 +171,80 @@ function SectionHeader({
);
}
// ─── 화면 선택 Combobox ───
const ScreenSelector: React.FC<{
value?: number;
onChange: (screenId?: number) => void;
}> = ({ value, onChange }) => {
const [open, setOpen] = useState(false);
const [screens, setScreens] = useState<Array<{ screenId: number; screenName: string; screenCode: string }>>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadScreens = async () => {
setLoading(true);
try {
const { screenApi } = await import("@/lib/api/screen");
const response = await screenApi.getScreens({ page: 1, size: 1000 });
setScreens(
response.data.map((s: any) => ({ screenId: s.screenId, screenName: s.screenName, screenCode: s.screenCode })),
);
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setLoading(false);
}
};
loadScreens();
}, []);
const selectedScreen = screens.find((s) => s.screenId === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-8 w-full justify-between text-xs"
disabled={loading}
>
{loading ? "로딩 중..." : selectedScreen ? selectedScreen.screenName : "화면 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput placeholder="화면 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-6 text-center text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-auto">
{screens.map((screen) => (
<CommandItem
key={screen.screenId}
value={`${screen.screenName.toLowerCase()} ${screen.screenCode.toLowerCase()} ${screen.screenId}`}
onSelect={() => {
onChange(screen.screenId === value ? undefined : screen.screenId);
setOpen(false);
}}
className="text-xs"
>
<Check className={cn("mr-2 h-4 w-4", value === screen.screenId ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{screen.screenName}</span>
<span className="text-muted-foreground text-[10px]">{screen.screenCode}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
// ─── 수평 Switch Row (토스 패턴) ───
function SwitchRow({
label,
@ -2002,6 +2076,23 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<V2SplitPanelLayoutConfigPan
checked={tab.showAdd ?? false}
onCheckedChange={(checked) => updateTab(tabIndex, { showAdd: checked })}
/>
{tab.showAdd && (
<div className="border-primary/20 ml-4 space-y-2 border-l-2 pl-3 pb-1">
<span className="text-muted-foreground text-[11px]"> </span>
<ScreenSelector
value={tab.addButton?.modalScreenId}
onChange={(screenId) => {
updateTab(tabIndex, {
addButton: {
enabled: true,
mode: screenId ? "modal" : "auto",
modalScreenId: screenId,
},
});
}}
/>
</div>
)}
<SwitchRow
label="삭제"
checked={tab.showDelete ?? false}

View File

@ -457,6 +457,13 @@ apiClient.interceptors.response.use(
}
}
// TOKEN_INVALIDATED → 재로그인 필요 (갱신 시도 없이 즉시)
if (errorCode === "TOKEN_INVALIDATED") {
authLog("REDIRECT_TO_LOGIN", `토큰 무효화 (보안 정책 변경) → 즉시 로그인 리다이렉트 (${url})`);
redirectToLogin();
return Promise.reject(error);
}
// TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로
authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`);
redirectToLogin();

View File

@ -0,0 +1,188 @@
import { apiClient } from "./client";
// --- 타입 정의 ---
export interface OutboundItem {
id: string;
company_code: string;
outbound_number: string;
outbound_type: string;
outbound_date: string;
reference_number: string | null;
customer_code: string | null;
customer_name: string | null;
item_code: string | null;
item_name: string | null;
specification: string | null;
material: string | null;
unit: string | null;
outbound_qty: number;
unit_price: number;
total_amount: number;
lot_number: string | null;
warehouse_code: string | null;
warehouse_name?: string | null;
location_code: string | null;
outbound_status: string;
manager_id: string | null;
memo: string | null;
source_type: string | null;
sales_order_id: string | null;
shipment_plan_id: string | null;
item_info_id: string | null;
destination_code: string | null;
delivery_destination: string | null;
delivery_address: string | null;
created_date: string;
created_by: string | null;
}
export interface ShipmentInstructionSource {
detail_id: number;
instruction_id: number;
instruction_no: string;
instruction_date: string;
partner_id: string;
instruction_status: string;
item_code: string;
item_name: string;
spec: string | null;
material: string | null;
plan_qty: number;
ship_qty: number;
order_qty: number;
remain_qty: number;
source_type: string | null;
}
export interface PurchaseOrderSource {
id: string;
purchase_no: string;
order_date: string;
supplier_code: string;
supplier_name: string;
item_code: string;
item_name: string;
spec: string | null;
material: string | null;
order_qty: number;
received_qty: number;
unit_price: number;
status: string;
due_date: string | null;
}
export interface ItemSource {
id: string;
item_number: string;
item_name: string;
spec: string | null;
material: string | null;
unit: string | null;
standard_price: number;
}
export interface WarehouseOption {
warehouse_code: string;
warehouse_name: string;
warehouse_type: string;
}
export interface CreateOutboundPayload {
outbound_number: string;
outbound_date: string;
warehouse_code?: string;
location_code?: string;
manager_id?: string;
memo?: string;
items: Array<{
outbound_type: string;
reference_number?: string;
customer_code?: string;
customer_name?: string;
item_code?: string;
item_number?: string;
item_name?: string;
spec?: string;
specification?: string;
material?: string;
unit?: string;
outbound_qty: number;
unit_price?: number;
total_amount?: number;
lot_number?: string;
warehouse_code?: string;
location_code?: string;
outbound_status?: string;
manager_id?: string;
memo?: string;
source_type?: string;
source_id?: string;
sales_order_id?: string;
shipment_plan_id?: string;
item_info_id?: string;
destination_code?: string;
delivery_destination?: string;
delivery_address?: string;
}>;
}
// --- API 호출 ---
export async function getOutboundList(params?: {
outbound_type?: string;
outbound_status?: string;
search_keyword?: string;
date_from?: string;
date_to?: string;
}) {
const res = await apiClient.get("/outbound/list", { params });
return res.data as { success: boolean; data: OutboundItem[] };
}
export async function createOutbound(payload: CreateOutboundPayload) {
const res = await apiClient.post("/outbound", payload);
return res.data as { success: boolean; data: OutboundItem[]; message?: string };
}
export async function updateOutbound(id: string, payload: Partial<OutboundItem>) {
const res = await apiClient.put(`/outbound/${id}`, payload);
return res.data as { success: boolean; data: OutboundItem };
}
export async function deleteOutbound(id: string) {
const res = await apiClient.delete(`/outbound/${id}`);
return res.data as { success: boolean; message?: string };
}
export async function generateOutboundNumber() {
const res = await apiClient.get("/outbound/generate-number");
return res.data as { success: boolean; data: string };
}
export async function getOutboundWarehouses() {
const res = await apiClient.get("/outbound/warehouses");
return res.data as { success: boolean; data: WarehouseOption[] };
}
// 소스 데이터 조회
export async function getShipmentInstructionSources(keyword?: string) {
const res = await apiClient.get("/outbound/source/shipment-instructions", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ShipmentInstructionSource[] };
}
export async function getPurchaseOrderSources(keyword?: string) {
const res = await apiClient.get("/outbound/source/purchase-orders", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: PurchaseOrderSource[] };
}
export async function getItemSources(keyword?: string) {
const res = await apiClient.get("/outbound/source/items", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ItemSource[] };
}

View File

@ -0,0 +1,168 @@
import { apiClient } from "./client";
// --- 타입 정의 ---
export interface PkgUnit {
id: string;
company_code: string;
pkg_code: string;
pkg_name: string;
pkg_type: string;
status: string;
width_mm: number | null;
length_mm: number | null;
height_mm: number | null;
self_weight_kg: number | null;
max_load_kg: number | null;
volume_l: number | null;
remarks: string | null;
created_date: string;
writer: string | null;
}
export interface PkgUnitItem {
id: string;
company_code: string;
pkg_code: string;
item_number: string;
pkg_qty: number;
// JOIN된 필드
item_name?: string;
spec?: string;
unit?: string;
}
export interface LoadingUnit {
id: string;
company_code: string;
loading_code: string;
loading_name: string;
loading_type: string;
status: string;
width_mm: number | null;
length_mm: number | null;
height_mm: number | null;
self_weight_kg: number | null;
max_load_kg: number | null;
max_stack: number | null;
remarks: string | null;
created_date: string;
writer: string | null;
}
export interface LoadingUnitPkg {
id: string;
company_code: string;
loading_code: string;
pkg_code: string;
max_load_qty: number;
load_method: string | null;
// JOIN된 필드
pkg_name?: string;
pkg_type?: string;
}
export interface ItemInfoForPkg {
id: string;
item_number: string;
item_name: string;
size: string | null;
spec?: string | null;
material: string | null;
unit: string | null;
division: string | null;
}
// --- 포장단위 API ---
export async function getPkgUnits() {
const res = await apiClient.get("/packaging/pkg-units");
return res.data as { success: boolean; data: PkgUnit[] };
}
export async function createPkgUnit(data: Partial<PkgUnit>) {
const res = await apiClient.post("/packaging/pkg-units", data);
return res.data as { success: boolean; data: PkgUnit; message?: string };
}
export async function updatePkgUnit(id: string, data: Partial<PkgUnit>) {
const res = await apiClient.put(`/packaging/pkg-units/${id}`, data);
return res.data as { success: boolean; data: PkgUnit };
}
export async function deletePkgUnit(id: string) {
const res = await apiClient.delete(`/packaging/pkg-units/${id}`);
return res.data as { success: boolean; message?: string };
}
// --- 포장단위 매칭품목 API ---
export async function getPkgUnitItems(pkgCode: string) {
const res = await apiClient.get(`/packaging/pkg-unit-items/${encodeURIComponent(pkgCode)}`);
return res.data as { success: boolean; data: PkgUnitItem[] };
}
export async function createPkgUnitItem(data: { pkg_code: string; item_number: string; pkg_qty: number }) {
const res = await apiClient.post("/packaging/pkg-unit-items", data);
return res.data as { success: boolean; data: PkgUnitItem; message?: string };
}
export async function deletePkgUnitItem(id: string) {
const res = await apiClient.delete(`/packaging/pkg-unit-items/${id}`);
return res.data as { success: boolean; message?: string };
}
// --- 적재함 API ---
export async function getLoadingUnits() {
const res = await apiClient.get("/packaging/loading-units");
return res.data as { success: boolean; data: LoadingUnit[] };
}
export async function createLoadingUnit(data: Partial<LoadingUnit>) {
const res = await apiClient.post("/packaging/loading-units", data);
return res.data as { success: boolean; data: LoadingUnit; message?: string };
}
export async function updateLoadingUnit(id: string, data: Partial<LoadingUnit>) {
const res = await apiClient.put(`/packaging/loading-units/${id}`, data);
return res.data as { success: boolean; data: LoadingUnit };
}
export async function deleteLoadingUnit(id: string) {
const res = await apiClient.delete(`/packaging/loading-units/${id}`);
return res.data as { success: boolean; message?: string };
}
// --- 적재함 포장구성 API ---
export async function getLoadingUnitPkgs(loadingCode: string) {
const res = await apiClient.get(`/packaging/loading-unit-pkgs/${encodeURIComponent(loadingCode)}`);
return res.data as { success: boolean; data: LoadingUnitPkg[] };
}
export async function createLoadingUnitPkg(data: { loading_code: string; pkg_code: string; max_load_qty: number; load_method?: string }) {
const res = await apiClient.post("/packaging/loading-unit-pkgs", data);
return res.data as { success: boolean; data: LoadingUnitPkg; message?: string };
}
export async function deleteLoadingUnitPkg(id: string) {
const res = await apiClient.delete(`/packaging/loading-unit-pkgs/${id}`);
return res.data as { success: boolean; message?: string };
}
// --- 품목정보 연동 API ---
export async function getItemsByDivision(divisionLabel: string, keyword?: string) {
const res = await apiClient.get(`/packaging/items/${encodeURIComponent(divisionLabel)}`, {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ItemInfoForPkg[] };
}
export async function getGeneralItems(keyword?: string) {
const res = await apiClient.get("/packaging/items/general", {
params: keyword ? { keyword } : {},
});
return res.data as { success: boolean; data: ItemInfoForPkg[] };
}

View File

@ -2,7 +2,7 @@
* API
*/
import apiClient from "./client";
import { apiClient } from "./client";
// ─── 타입 정의 ───
@ -19,6 +19,7 @@ export interface OrderSummaryItem {
existing_plan_qty: number;
in_progress_qty: number;
required_plan_qty: number;
lead_time: number;
orders: OrderDetail[];
}
@ -94,10 +95,51 @@ export interface GenerateScheduleResponse {
deleted_count: number;
};
schedules: ProductionPlan[];
deletedSchedules?: ProductionPlan[];
keptSchedules?: ProductionPlan[];
}
// ─── API 함수 ───
/** 생산계획 목록 조회 */
export async function getPlans(params?: {
productType?: string;
status?: string;
startDate?: string;
endDate?: string;
itemCode?: string;
}) {
const queryParams = new URLSearchParams();
if (params?.productType) queryParams.set("productType", params.productType);
if (params?.status) queryParams.set("status", params.status);
if (params?.startDate) queryParams.set("startDate", params.startDate);
if (params?.endDate) queryParams.set("endDate", params.endDate);
if (params?.itemCode) queryParams.set("itemCode", params.itemCode);
const qs = queryParams.toString();
const url = `/production/plans${qs ? `?${qs}` : ""}`;
const response = await apiClient.get(url);
return response.data as { success: boolean; data: ProductionPlan[] };
}
/** 자동 스케줄 미리보기 (DB 변경 없이 예상 결과) */
export async function previewSchedule(request: GenerateScheduleRequest) {
const response = await apiClient.post("/production/generate-schedule/preview", request);
return response.data as { success: boolean; data: GenerateScheduleResponse };
}
/** 반제품 계획 미리보기 */
export async function previewSemiSchedule(
planIds: number[],
options?: { considerStock?: boolean; excludeUsed?: boolean }
) {
const response = await apiClient.post("/production/generate-semi-schedule/preview", {
plan_ids: planIds,
options: options || {},
});
return response.data as { success: boolean; data: { count: number; schedules: ProductionPlan[] } };
}
/** 수주 데이터 조회 (품목별 그룹핑) */
export async function getOrderSummary(params?: {
excludePlanned?: boolean;
@ -110,44 +152,44 @@ export async function getOrderSummary(params?: {
if (params?.itemName) queryParams.set("itemName", params.itemName);
const qs = queryParams.toString();
const url = `/api/production/order-summary${qs ? `?${qs}` : ""}`;
const url = `/production/order-summary${qs ? `?${qs}` : ""}`;
const response = await apiClient.get(url);
return response.data as { success: boolean; data: OrderSummaryItem[] };
}
/** 안전재고 부족분 조회 */
export async function getStockShortage() {
const response = await apiClient.get("/api/production/stock-shortage");
const response = await apiClient.get("/production/stock-shortage");
return response.data as { success: boolean; data: StockShortageItem[] };
}
/** 생산계획 상세 조회 */
export async function getPlanById(planId: number) {
const response = await apiClient.get(`/api/production/plan/${planId}`);
const response = await apiClient.get(`/production/plan/${planId}`);
return response.data as { success: boolean; data: ProductionPlan };
}
/** 생산계획 수정 */
export async function updatePlan(planId: number, data: Partial<ProductionPlan>) {
const response = await apiClient.put(`/api/production/plan/${planId}`, data);
const response = await apiClient.put(`/production/plan/${planId}`, data);
return response.data as { success: boolean; data: ProductionPlan };
}
/** 생산계획 삭제 */
export async function deletePlan(planId: number) {
const response = await apiClient.delete(`/api/production/plan/${planId}`);
const response = await apiClient.delete(`/production/plan/${planId}`);
return response.data as { success: boolean; message: string };
}
/** 자동 스케줄 생성 */
export async function generateSchedule(request: GenerateScheduleRequest) {
const response = await apiClient.post("/api/production/generate-schedule", request);
const response = await apiClient.post("/production/generate-schedule", request);
return response.data as { success: boolean; data: GenerateScheduleResponse };
}
/** 스케줄 병합 */
export async function mergeSchedules(scheduleIds: number[], productType?: string) {
const response = await apiClient.post("/api/production/merge-schedules", {
const response = await apiClient.post("/production/merge-schedules", {
schedule_ids: scheduleIds,
product_type: productType || "완제품",
});
@ -159,7 +201,7 @@ export async function generateSemiSchedule(
planIds: number[],
options?: { considerStock?: boolean; excludeUsed?: boolean }
) {
const response = await apiClient.post("/api/production/generate-semi-schedule", {
const response = await apiClient.post("/production/generate-semi-schedule", {
plan_ids: planIds,
options: options || {},
});
@ -168,7 +210,7 @@ export async function generateSemiSchedule(
/** 스케줄 분할 */
export async function splitSchedule(planId: number, splitQty: number) {
const response = await apiClient.post(`/api/production/plan/${planId}/split`, {
const response = await apiClient.post(`/production/plan/${planId}/split`, {
split_qty: splitQty,
});
return response.data as {

View File

@ -372,22 +372,27 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
if (response.data.success && response.data.data) {
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
// API 응답 형식: valueCode, valueLabel (camelCase)
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label, color };
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const rawLabel = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label: displayLabel, color };
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, rawLabel);
}
});
};
flattenCategoryTree(response.data.data);
mappings[columnName] = mapping;
}
} catch (error) {
// 카테고리 매핑 로드 실패 시 무시
}
}
setCategoryMappings(mappings);
}
} catch (error) {

View File

@ -223,7 +223,9 @@ export const CategorySelectComponent: React.FC<
key={categoryValue.valueId}
value={categoryValue.valueCode}
>
{categoryValue.valueLabel}
{categoryValue.path && categoryValue.path.includes('/')
? categoryValue.path.replace(/\//g, ' / ')
: categoryValue.valueLabel}
</SelectItem>
))}
</SelectContent>

View File

@ -258,7 +258,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const activeValues = response.data.filter((v: any) => v.isActive !== false);
const options = activeValues.map((v: any) => ({
value: v.valueCode,
label: v.valueLabel || v.valueCode,
label: (v.path && v.path.includes('/'))
? v.path.replace(/\//g, ' / ')
: (v.valueLabel || v.valueCode),
}));
setCategoryOptions(options);
}

View File

@ -1613,12 +1613,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const rawLabel = item.value_label || item.valueLabel;
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
valueMap[item.value_code || item.valueCode] = {
label: displayLabel,
color: item.color,
};
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, rawLabel);
}
});
};
flattenCategoryTree(response.data.data);
mappings[columnName] = valueMap;
console.log(`✅ 좌측 카테고리 매핑 로드 [${columnName}]:`, valueMap);
}
@ -1675,12 +1683,20 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (response.data.success && response.data.data) {
const valueMap: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
valueMap[item.value_code || item.valueCode] = {
label: item.value_label || item.valueLabel,
color: item.color,
};
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const rawLabel = item.value_label || item.valueLabel;
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
valueMap[item.value_code || item.valueCode] = {
label: displayLabel,
color: item.color,
};
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, rawLabel);
}
});
};
flattenCategoryTree(response.data.data);
// 조인된 테이블의 경우 "테이블명.컬럼명" 형태로 저장
const mappingKey = tableName === rightTableName ? columnName : `${tableName}.${columnName}`;
@ -1729,7 +1745,48 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
(panel: "left" | "right") => {
console.log("🆕 [추가모달] handleAddClick 호출:", { panel, activeTabIndex });
// screenId 기반 모달 확인
// 추가 탭의 addButton.modalScreenId 확인
if (panel === "right" && activeTabIndex > 0) {
const tabConfig = componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1];
if (tabConfig?.addButton?.mode === "modal" && tabConfig.addButton.modalScreenId) {
if (!selectedLeftItem) {
toast({
title: "항목을 선택해주세요",
description: "좌측 패널에서 항목을 먼저 선택한 후 추가해주세요.",
variant: "destructive",
});
return;
}
const tableName = tabConfig.tableName || "";
const urlParams: Record<string, any> = { mode: "add", tableName };
const parentData: Record<string, any> = {};
if (selectedLeftItem) {
const relation = tabConfig.relation;
if (relation?.keys && Array.isArray(relation.keys)) {
for (const key of relation.keys) {
if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) {
parentData[key.rightColumn] = selectedLeftItem[key.leftColumn];
}
}
}
}
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
screenId: tabConfig.addButton.modalScreenId,
urlParams,
splitPanelParentData: parentData,
},
}),
);
return;
}
}
// screenId 기반 모달 확인 (기본 패널)
const panelConfig = panel === "left" ? componentConfig.leftPanel : componentConfig.rightPanel;
const addModalConfig = panelConfig?.addModal;

View File

@ -675,8 +675,50 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
)}
</div>
{/* ===== 7. 추가 모달 컬럼 설정 (showAdd일 때) ===== */}
{/* ===== 7. 추가 버튼 설정 (showAdd일 때) ===== */}
{tab.showAdd && (
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
<Label className="text-xs font-semibold text-purple-700"> </Label>
<div className="space-y-2">
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={tab.addButton?.mode || "auto"}
onValueChange={(value: "auto" | "modal") => {
updateTab({
addButton: { ...tab.addButton, enabled: true, mode: value },
});
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto"> ()</SelectItem>
<SelectItem value="modal"> </SelectItem>
</SelectContent>
</Select>
</div>
{tab.addButton?.mode === "modal" && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<ScreenSelector
value={tab.addButton?.modalScreenId}
onChange={(screenId) => {
updateTab({
addButton: { ...tab.addButton, enabled: true, mode: "modal", modalScreenId: screenId },
});
}}
/>
</div>
)}
</div>
</div>
)}
{/* ===== 7-1. 추가 모달 컬럼 설정 (showAdd && mode=auto일 때) ===== */}
{tab.showAdd && (!tab.addButton?.mode || tab.addButton?.mode === "auto") && (
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold text-purple-700"> </Label>

View File

@ -63,6 +63,12 @@ export interface AdditionalTabConfig {
}>;
};
addButton?: {
enabled: boolean;
mode: "auto" | "modal";
modalScreenId?: number;
};
addConfig?: {
targetTable?: string;
autoFillColumns?: Record<string, any>;

View File

@ -1337,7 +1337,8 @@ export const SplitPanelLayout2Component: React.FC<SplitPanelLayout2ComponentProp
if (result.success && Array.isArray(result.data) && result.data.length > 0) {
for (const item of result.data) {
if (item.valueCode && item.valueLabel) {
labelMap[item.valueCode] = item.valueLabel;
// 계층 경로 표시: path가 있고 '/'를 포함하면 전체 경로를 ' > ' 구분자로 표시
labelMap[item.valueCode] = item.path && item.path.includes('/') ? item.path.replace(/\//g, ' > ') : item.valueLabel;
}
}
}

View File

@ -1287,22 +1287,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const apiClient = (await import("@/lib/api/client")).apiClient;
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>, parentLabel: string = "") => {
items.forEach((item: any) => {
const displayLabel = parentLabel
? `${parentLabel} / ${item.valueLabel}`
: item.valueLabel;
if (item.valueCode) {
mapping[String(item.valueCode)] = {
label: item.valueLabel,
label: displayLabel,
color: item.color,
};
}
if (item.valueId !== undefined && item.valueId !== null) {
mapping[String(item.valueId)] = {
label: item.valueLabel,
label: displayLabel,
color: item.color,
};
}
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenTree(item.children, mapping);
flattenTree(item.children, mapping, item.valueLabel);
}
});
};

View File

@ -109,6 +109,11 @@ export function FieldDetailSettingsModal({
const [cascadingRelationOpen, setCascadingRelationOpen] = useState(false);
const [parentFieldOpen, setParentFieldOpen] = useState(false);
// 기본 선택값용 옵션 목록 상태
const [defaultValueCategoryValues, setDefaultValueCategoryValues] = useState<{value: string; label: string}[]>([]);
const [defaultValueTableOptions, setDefaultValueTableOptions] = useState<{value: string; label: string}[]>([]);
const [loadingDefaultValueOptions, setLoadingDefaultValueOptions] = useState(false);
// Combobox 열림 상태
const [sourceTableOpen, setSourceTableOpen] = useState(false);
const [targetColumnOpenMap, setTargetColumnOpenMap] = useState<Record<number, boolean>>({});
@ -209,6 +214,69 @@ export function FieldDetailSettingsModal({
loadCascadingRelations();
}, [open]);
// 기본 선택값용: code 타입 카테고리 값 로드
useEffect(() => {
const loadCategoryValues = async () => {
const categoryKey = localField.selectOptions?.categoryKey;
if (!open || localField.selectOptions?.type !== "code" || !categoryKey) {
setDefaultValueCategoryValues([]);
return;
}
setLoadingDefaultValueOptions(true);
try {
const [tableName, columnName] = categoryKey.split(".");
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
if (response.data?.success && response.data?.data) {
setDefaultValueCategoryValues(
response.data.data.map((item: any) => ({
value: item.valueCode || item.value_code,
label: item.valueLabel || item.value_label,
}))
);
} else {
setDefaultValueCategoryValues([]);
}
} catch {
setDefaultValueCategoryValues([]);
} finally {
setLoadingDefaultValueOptions(false);
}
};
loadCategoryValues();
}, [open, localField.selectOptions?.type, localField.selectOptions?.categoryKey]);
// 기본 선택값용: table 타입 옵션 로드
useEffect(() => {
const loadTableOptions = async () => {
const opts = localField.selectOptions;
if (!open || opts?.type !== "table" || !opts?.tableName || !opts?.valueColumn || !opts?.labelColumn) {
setDefaultValueTableOptions([]);
return;
}
setLoadingDefaultValueOptions(true);
try {
const response = await apiClient.post(`/table-management/tables/${opts.tableName}/data`, {
page: 1,
size: 200,
autoFilter: { enabled: true, filterColumn: "company_code" },
});
const dataArray = response.data?.data?.data || response.data?.data || [];
setDefaultValueTableOptions(
dataArray.map((row: any) => ({
value: String(row[opts.valueColumn!] || ""),
label: String(row[opts.labelColumn!] || ""),
}))
);
} catch {
setDefaultValueTableOptions([]);
} finally {
setLoadingDefaultValueOptions(false);
}
};
loadTableOptions();
}, [open, localField.selectOptions?.type, localField.selectOptions?.tableName,
localField.selectOptions?.valueColumn, localField.selectOptions?.labelColumn]);
// 관계 코드 선택 시 상세 설정 자동 채움
const handleRelationCodeSelect = async (relationCode: string) => {
if (!relationCode) return;
@ -1181,6 +1249,80 @@ export function FieldDetailSettingsModal({
</div>
</div>
)}
{/* 기본 선택값 설정 (cascading 제외) */}
{(() => {
const effectiveType = localField.selectOptions?.type || "static";
if (effectiveType === "cascading") return null;
return (
<div className="border-t pt-3 mt-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-medium"> </span>
{/* static 타입 */}
{effectiveType === "static" && (localField.selectOptions?.staticOptions?.length || 0) > 0 && (
<Select
value={localField.defaultValue || "_none_"}
onValueChange={(value) => updateField({ defaultValue: value === "_none_" ? "" : value })}
>
<SelectTrigger className="h-7 w-[180px] text-xs">
<SelectValue placeholder="선택 안함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{(localField.selectOptions?.staticOptions || []).map((opt, idx) => (
<SelectItem key={`default-${idx}`} value={opt.value}>
{opt.label || opt.value}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* code 타입 */}
{effectiveType === "code" && defaultValueCategoryValues.length > 0 && (
<Select
value={localField.defaultValue || "_none_"}
onValueChange={(value) => updateField({ defaultValue: value === "_none_" ? "" : value })}
>
<SelectTrigger className="h-7 w-[180px] text-xs">
<SelectValue placeholder="선택 안함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{defaultValueCategoryValues.map((cv) => (
<SelectItem key={cv.value} value={cv.value}>
{cv.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{/* table 타입 */}
{effectiveType === "table" && defaultValueTableOptions.length > 0 && (
<Select
value={localField.defaultValue || "_none_"}
onValueChange={(value) => updateField({ defaultValue: value === "_none_" ? "" : value })}
>
<SelectTrigger className="h-7 w-[180px] text-xs">
<SelectValue placeholder="선택 안함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none_"> </SelectItem>
{defaultValueTableOptions.map((opt, idx) => (
<SelectItem key={`default-table-${idx}`} value={opt.value}>
{opt.label} ({opt.value})
</SelectItem>
))}
</SelectContent>
</Select>
)}
{loadingDefaultValueOptions && (
<span className="text-[9px] text-muted-foreground"> ...</span>
)}
</div>
<HelpText> </HelpText>
</div>
);
})()}
</AccordionContent>
</AccordionItem>
)}

View File

@ -372,22 +372,27 @@ export const CardDisplayComponent: React.FC<CardDisplayComponentProps> = ({
if (response.data.success && response.data.data) {
const mapping: Record<string, { label: string; color?: string }> = {};
response.data.data.forEach((item: any) => {
// API 응답 형식: valueCode, valueLabel (camelCase)
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const label = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
// color가 null/undefined/"none"이면 undefined로 유지 (배지 없음)
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label, color };
});
const flattenCategoryTree = (items: any[], parentLabel: string = "") => {
items.forEach((item: any) => {
const code = item.valueCode || item.value_code || item.category_code || item.code || item.value;
const rawLabel = item.valueLabel || item.value_label || item.category_name || item.name || item.label || code;
const displayLabel = parentLabel ? `${parentLabel} / ${rawLabel}` : rawLabel;
const rawColor = item.color ?? item.badge_color;
const color = (rawColor && rawColor !== "none") ? rawColor : undefined;
mapping[code] = { label: displayLabel, color };
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenCategoryTree(item.children, rawLabel);
}
});
};
flattenCategoryTree(response.data.data);
mappings[columnName] = mapping;
}
} catch (error) {
// 카테고리 매핑 로드 실패 시 무시
}
}
setCategoryMappings(mappings);
}
} catch (error) {

View File

@ -458,7 +458,7 @@ export function ItemRoutingComponent({
{/* ════ 품목 추가 다이얼로그 (테이블 형태 + 검색) ════ */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="max-w-[95vw]" style={{ maxWidth: `min(95vw, ${config.addModalMaxWidth || "600px"})` }}>
<DialogContent className="!max-w-none" style={{ width: `min(100%, 95vw, ${config.addModalMaxWidth || "600px"})` }}>
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">

View File

@ -667,8 +667,62 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [showAddModal, setShowAddModal] = useState(false);
const [addModalPanel, setAddModalPanel] = useState<"left" | "right" | "left-item" | null>(null);
const [addModalFormData, setAddModalFormData] = useState<Record<string, any>>({});
// 엔티티 관계 자동 감지 캐시 (좌측↔우측 테이블 간 FK 매핑)
const [autoDetectedTabRelations, setAutoDetectedTabRelations] = useState<
Record<string, Array<{ leftColumn: string; rightColumn: string }>>
>({});
const [bomExcelUploadOpen, setBomExcelUploadOpen] = useState(false);
// 좌측↔우측 테이블 간 엔티티 관계 자동 감지 (table_type_columns 기반)
useEffect(() => {
const leftTable = componentConfig.leftPanel?.tableName;
if (!leftTable) return;
const detectAll = async () => {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const cache: Record<string, Array<{ leftColumn: string; rightColumn: string }>> = {};
// 기본 우측 패널
const rightTable = componentConfig.rightPanel?.tableName;
if (rightTable && rightTable !== leftTable) {
try {
const res = await tableManagementApi.getTableEntityRelations(leftTable, rightTable);
if (res.success && res.data?.relations?.length) {
cache[rightTable] = res.data.relations.map((r: any) => ({
leftColumn: r.leftColumn,
rightColumn: r.rightColumn,
}));
}
} catch { /* ignore */ }
}
// 추가 탭들
const tabs = componentConfig.rightPanel?.additionalTabs || [];
for (const tab of tabs) {
const tabTable = tab.tableName;
if (!tabTable || cache[tabTable] !== undefined) continue;
try {
const res = await tableManagementApi.getTableEntityRelations(leftTable, tabTable);
if (res.success && res.data?.relations?.length) {
cache[tabTable] = res.data.relations.map((r: any) => ({
leftColumn: r.leftColumn,
rightColumn: r.rightColumn,
}));
} else {
cache[tabTable] = [];
}
} catch {
cache[tabTable] = [];
}
}
setAutoDetectedTabRelations(cache);
};
detectAll();
}, [componentConfig.leftPanel?.tableName, componentConfig.rightPanel?.tableName, componentConfig.rightPanel?.additionalTabs]);
// 수정 모달 상태
const [showEditModal, setShowEditModal] = useState(false);
const [editModalPanel, setEditModalPanel] = useState<"left" | "right" | null>(null);
@ -2518,6 +2572,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
}
// table_type_columns 기반 엔티티 관계 자동 감지 (패널 설정 없어도 동작)
if (currentTableName && autoDetectedTabRelations[currentTableName]) {
for (const rel of autoDetectedTabRelations[currentTableName]) {
if (parentData[rel.rightColumn] == null && selectedLeftItem[rel.leftColumn] != null) {
parentData[rel.rightColumn] = selectedLeftItem[rel.leftColumn];
}
}
}
window.dispatchEvent(
new CustomEvent("openScreenModal", {
detail: {
@ -2539,23 +2602,73 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
setAddModalPanel(panel);
// 우측 패널 추가 시, 좌측에서 선택된 항목의 조인 컬럼 값을 자동으로 채움
if (
panel === "right" &&
selectedLeftItem &&
componentConfig.leftPanel?.leftColumn &&
componentConfig.rightPanel?.rightColumn
) {
const leftColumnValue = selectedLeftItem[componentConfig.leftPanel.leftColumn];
setAddModalFormData({
[componentConfig.rightPanel.rightColumn]: leftColumnValue,
});
if (panel === "right" && selectedLeftItem) {
const prefill: Record<string, any> = {};
// 현재 활성 탭의 설정 가져오기 (기본 탭 or 추가 탭)
const currentAddConfig =
activeTabIndex === 0
? componentConfig.rightPanel?.addConfig
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.addConfig;
const currentRelation =
activeTabIndex === 0
? componentConfig.rightPanel?.relation
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.relation;
// 1) relation.keys 기반 FK 자동 채움
if (currentRelation?.keys && Array.isArray(currentRelation.keys)) {
for (const key of currentRelation.keys) {
if (key.leftColumn && key.rightColumn && selectedLeftItem[key.leftColumn] != null) {
prefill[key.rightColumn] = selectedLeftItem[key.leftColumn];
}
}
} else if (currentRelation) {
const leftCol = currentRelation.leftColumn || componentConfig.leftPanel?.leftColumn;
const rightCol = currentRelation.foreignKey || currentRelation.rightColumn || componentConfig.rightPanel?.rightColumn;
if (leftCol && rightCol && selectedLeftItem[leftCol] != null) {
prefill[rightCol] = selectedLeftItem[leftCol];
}
} else if (componentConfig.leftPanel?.leftColumn && componentConfig.rightPanel?.rightColumn) {
// 하위호환: leftPanel.leftColumn → rightPanel.rightColumn
prefill[componentConfig.rightPanel.rightColumn] = selectedLeftItem[componentConfig.leftPanel.leftColumn];
}
// 2) addConfig.leftPanelColumn → targetColumn (단일 키, 하위호환)
if (currentAddConfig?.leftPanelColumn && currentAddConfig?.targetColumn) {
const val = selectedLeftItem[currentAddConfig.leftPanelColumn];
if (val != null) prefill[currentAddConfig.targetColumn] = val;
}
// 3) addConfig.autoFillFromLeft — 복수 컬럼 자동 채움
if (currentAddConfig?.autoFillFromLeft) {
for (const mapping of currentAddConfig.autoFillFromLeft) {
const val = selectedLeftItem[mapping.source];
if (val != null) prefill[mapping.target] = val;
}
}
// 4) table_type_columns 기반 엔티티 관계 자동 감지 (패널 설정 없어도 동작)
const currentTableName =
activeTabIndex === 0
? componentConfig.rightPanel?.tableName
: (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)?.tableName;
if (currentTableName && autoDetectedTabRelations[currentTableName]) {
for (const rel of autoDetectedTabRelations[currentTableName]) {
// 이미 다른 방식으로 채워진 값이 없을 때만 자동 채움
if (prefill[rel.rightColumn] == null && selectedLeftItem[rel.leftColumn] != null) {
prefill[rel.rightColumn] = selectedLeftItem[rel.leftColumn];
}
}
}
setAddModalFormData(prefill);
} else {
setAddModalFormData({});
}
setShowAddModal(true);
},
[selectedLeftItem, componentConfig, activeTabIndex],
[selectedLeftItem, componentConfig, activeTabIndex, autoDetectedTabRelations],
);
// 수정 버튼 핸들러
@ -3234,21 +3347,38 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
tableName = componentConfig.leftPanel?.tableName;
modalColumns = componentConfig.leftPanel?.addModalColumns;
} else if (addModalPanel === "right") {
// 우측 패널: 중계 테이블 설정이 있는지 확인
const addConfig = componentConfig.rightPanel?.addConfig;
// 현재 활성 탭의 설정 가져오기 (기본 탭 or 추가 탭)
const isAdditionalTab = activeTabIndex > 0;
const tabConfig = isAdditionalTab
? (componentConfig.rightPanel?.additionalTabs?.[activeTabIndex - 1] as any)
: null;
const addConfig = isAdditionalTab
? tabConfig?.addConfig
: componentConfig.rightPanel?.addConfig;
if (addConfig?.targetTable) {
// 중계 테이블 모드
tableName = addConfig.targetTable;
modalColumns = componentConfig.rightPanel?.addModalColumns;
modalColumns = isAdditionalTab
? tabConfig?.addModalColumns
: componentConfig.rightPanel?.addModalColumns;
// 좌측 패널에서 선택된 값 자동 채우기
// 좌측 패널에서 선택된 값 자동 채우기 (단일 키, 하위호환)
if (addConfig.leftPanelColumn && addConfig.targetColumn && selectedLeftItem) {
const leftValue = selectedLeftItem[addConfig.leftPanelColumn];
finalData[addConfig.targetColumn] = leftValue;
}
// 자동 채움 컬럼 추가
// autoFillFromLeft — 복수 컬럼 자동 채움
if (addConfig.autoFillFromLeft && selectedLeftItem) {
for (const mapping of addConfig.autoFillFromLeft) {
const val = selectedLeftItem[mapping.source];
if (val != null) finalData[mapping.target] = val;
}
}
// 자동 채움 컬럼 추가 (정적 값)
if (addConfig.autoFillColumns) {
Object.entries(addConfig.autoFillColumns).forEach(([key, value]) => {
finalData[key] = value;
@ -3256,8 +3386,29 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
} else {
// 일반 테이블 모드
tableName = componentConfig.rightPanel?.tableName;
modalColumns = componentConfig.rightPanel?.addModalColumns;
tableName = isAdditionalTab
? tabConfig?.tableName
: componentConfig.rightPanel?.tableName;
modalColumns = isAdditionalTab
? tabConfig?.addModalColumns
: componentConfig.rightPanel?.addModalColumns;
// 일반 모드에서도 autoFillFromLeft 적용
if (addConfig?.autoFillFromLeft && selectedLeftItem) {
for (const mapping of addConfig.autoFillFromLeft) {
const val = selectedLeftItem[mapping.source];
if (val != null) finalData[mapping.target] = val;
}
}
}
// table_type_columns 기반 엔티티 관계 자동 감지 (패널 설정 없어도 동작)
if (tableName && autoDetectedTabRelations[tableName] && selectedLeftItem) {
for (const rel of autoDetectedTabRelations[tableName]) {
if (finalData[rel.rightColumn] == null && selectedLeftItem[rel.leftColumn] != null) {
finalData[rel.rightColumn] = selectedLeftItem[rel.leftColumn];
}
}
}
} else if (addModalPanel === "left-item") {
// 하위 항목 추가 (좌측 테이블에 추가)
@ -3305,8 +3456,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 좌측 패널 데이터 새로고침 (일반 추가 또는 하위 항목 추가)
loadLeftData();
} else if (addModalPanel === "right") {
// 우측 패널 데이터 새로고침
loadRightData(selectedLeftItem);
// 우측 패널 데이터 새로고침 (추가 탭이면 loadTabData)
if (activeTabIndex > 0) {
loadTabData(activeTabIndex, selectedLeftItem);
} else {
loadRightData(selectedLeftItem);
}
}
} else {
toast({
@ -3896,13 +4051,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const canDragLeftGroupedColumns = !isDesignMode && columnsToShow.length > 1;
if (groupedLeftData.length > 0) {
return (
<div className="overflow-auto">
<>
{groupedLeftData.map((group, groupIdx) => (
<div key={groupIdx} className="mb-4">
<div className="bg-muted px-3 py-2 text-sm font-semibold">
{group.groupKey} ({group.count})
</div>
<table className="divide-border min-w-full divide-y">
<table className="divide-border min-w-full divide-y" style={{ minWidth: `${Math.max(columnsToShow.length * 120, 400)}px` }}>
<thead className="bg-muted">
<tr>
{columnsToShow.map((col, idx) => {
@ -4016,7 +4171,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</table>
</div>
))}
</div>
</>
);
}
@ -4027,8 +4182,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
componentConfig.leftPanel?.showDelete !== false);
const canDragLeftColumns = !isDesignMode && columnsToShow.length > 1;
return (
<div className="overflow-auto">
<table className="divide-border min-w-full divide-y">
<table className="divide-border min-w-full divide-y" style={{ minWidth: `${Math.max(columnsToShow.length * 120, 400)}px` }}>
<thead className="bg-muted sticky top-0 z-10">
<tr>
{columnsToShow.map((col, idx) => {
@ -4135,7 +4289,6 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
})}
</tbody>
</table>
</div>
);
})()
)}
@ -5189,19 +5342,13 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
columnsToShow = [...keyCols, ...applyRuntimeOrder(dataCols, "main")];
}
// 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤)
const rightTotalColWidth = columnsToShow.reduce((sum, col) => {
const w = col.width && col.width <= 100 ? col.width : 0;
return sum + w;
}, 0);
const rightConfigColumnStart = columnsToShow.filter((c: any) => c._isKeyColumn).length;
const canDragRightColumns = displayColumns.length > 0;
return (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full">
<table className="min-w-full" style={{ minWidth: `${Math.max(columnsToShow.length * 120, 400)}px` }}>
<thead className="sticky top-0 z-10">
<tr className="border-border/60 border-b-2">
{columnsToShow.map((col, idx) => {
@ -5221,7 +5368,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
isDragging && "opacity-50",
)}
style={{
width: col.width && col.width <= 100 ? `${col.width}%` : "auto",
minWidth: col.width ? `${col.width}px` : "80px",
textAlign: col.align || "left",
}}
draggable={isDraggable}
@ -5387,14 +5534,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return filteredData.length > 0 ? (
<div className="flex h-full w-full flex-col">
<div className="min-h-0 flex-1 overflow-auto">
<table className="min-w-full text-sm">
<table className="min-w-full text-sm" style={{ minWidth: `${Math.max(columnsToDisplay.length * 120, 400)}px` }}>
<thead className="bg-background sticky top-0 z-10">
<tr className="border-border/60 border-b-2">
{columnsToDisplay.map((col) => (
<th
key={col.name}
className="text-muted-foreground px-3 py-[7px] text-left text-[9px] font-bold tracking-[0.04em] whitespace-nowrap uppercase"
style={{ width: col.width && col.width <= 100 ? `${col.width}%` : "auto" }}
style={{ minWidth: col.width ? `${col.width}px` : "80px" }}
>
{col.label}
</th>

View File

@ -422,6 +422,7 @@ interface AdditionalTabConfigPanelProps {
availableRightTables: TableInfo[];
leftTableColumns: ColumnInfo[];
menuObjid?: number;
screenTableName?: string;
// 공유 컬럼 로드 상태
loadedTableColumns: Record<string, ColumnInfo[]>;
loadTableColumns: (tableName: string) => Promise<void>;
@ -466,14 +467,45 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
loadTableColumns,
loadingColumns,
entityJoinColumns: entityJoinColumnsMap,
screenTableName,
}) => {
// 탭 테이블 변경 시 컬럼 로드
// 탭 테이블 변경 시 컬럼 로드 + 엔티티 관계 자동 감지
useEffect(() => {
if (tab.tableName && !loadedTableColumns[tab.tableName] && !loadingColumns[tab.tableName]) {
loadTableColumns(tab.tableName);
}
}, [tab.tableName, loadedTableColumns, loadingColumns, loadTableColumns]);
// 탭 테이블 변경 시 좌측 테이블과의 관계 자동 감지
useEffect(() => {
const leftTable = config.leftPanel?.tableName || screenTableName;
const rightTable = tab.tableName;
if (!leftTable || !rightTable) return;
// 이미 relation이 설정되어 있으면 스킵
if (tab.relation?.keys && tab.relation.keys.length > 0) return;
const detectRelations = async () => {
try {
const { tableManagementApi } = await import("@/lib/api/tableManagement");
const response = await tableManagementApi.getTableEntityRelations(leftTable, rightTable);
if (response.success && response.data?.relations?.length > 0) {
const firstRel = response.data.relations[0];
updateTab({
relation: {
type: "join",
keys: [{ leftColumn: firstRel.leftColumn, rightColumn: firstRel.rightColumn }],
},
});
console.log(`✅ 추가 탭 [${tab.label}] 엔티티 관계 자동 감지:`, firstRel);
}
} catch (error) {
console.warn(`추가 탭 [${tab.label}] 관계 감지 실패:`, error);
}
};
detectRelations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab.tableName, config.leftPanel?.tableName, screenTableName]);
// 현재 탭의 컬럼 목록
const tabColumns = useMemo(() => {
return tab.tableName ? loadedTableColumns[tab.tableName] || [] : [];
@ -3707,6 +3739,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
availableRightTables={availableRightTables}
leftTableColumns={leftTableColumns}
menuObjid={menuObjid}
screenTableName={screenTableName}
loadedTableColumns={loadedTableColumns}
loadTableColumns={loadTableColumns}
loadingColumns={loadingColumns}

View File

@ -10,6 +10,19 @@ import { DataFilterConfig, TabInlineComponent } from "@/types/screen-management"
*/
export type PanelInlineComponent = TabInlineComponent;
/** 우측 패널 추가 시 좌측 데이터 자동 채움 설정 */
export interface AddConfig {
targetTable?: string; // 실제로 INSERT할 테이블 (중계 테이블)
autoFillColumns?: Record<string, any>; // 자동으로 채워질 컬럼과 기본값
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지 (단일 키, 하위호환)
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지 (단일 키, 하위호환)
/** 좌측 선택 데이터에서 복수 컬럼을 자동으로 채움 (엔티티 관계 포함) */
autoFillFromLeft?: Array<{
source: string; // 좌측 데이터의 컬럼명
target: string; // 저장할 테이블의 컬럼명
}>;
}
/** 페이징 처리 설정 (좌측/우측 패널 공통) */
export interface PaginationConfig {
enabled: boolean;
@ -88,12 +101,7 @@ export interface AdditionalTabConfig {
}>;
};
addConfig?: {
targetTable?: string;
autoFillColumns?: Record<string, any>;
leftPanelColumn?: string;
targetColumn?: string;
};
addConfig?: AddConfig;
tableConfig?: {
showCheckbox?: boolean;
@ -304,12 +312,7 @@ export interface SplitPanelLayoutConfig {
};
// 우측 패널 추가 시 중계 테이블 설정 (N:M 관계)
addConfig?: {
targetTable?: string; // 실제로 INSERT할 테이블 (중계 테이블)
autoFillColumns?: Record<string, any>; // 자동으로 채워질 컬럼과 기본값
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지
};
addConfig?: AddConfig;
// 테이블 모드 설정
tableConfig?: {

View File

@ -369,6 +369,8 @@ import {
Trash2,
Lock,
GripVertical,
Loader2,
Search,
} from "lucide-react";
import * as XLSX from "xlsx";
import { FileText, ChevronRightIcon } from "lucide-react";
@ -810,17 +812,25 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
// 🆕 서버에서 가져온 컬럼별 고유값 캐시 (헤더 필터 드롭다운용)
const [asyncColumnUniqueValues, setAsyncColumnUniqueValues] = useState<
Record<string, { value: string; label: string }[]>
>({});
const [loadingFilterColumn, setLoadingFilterColumn] = useState<string | null>(null);
const [filterSearchTerm, setFilterSearchTerm] = useState("");
// 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함
const [filterGroups, setFilterGroups] = useState<FilterGroup[]>([]);
// 🆕 joinColumnMapping - filteredData에서 사용하므로 먼저 정의해야 함
const [joinColumnMapping, setJoinColumnMapping] = useState<Record<string, string>>({});
// 🆕 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용) + 헤더 필터
// 분할 패널에서 우측에 이미 추가된 항목 필터링 (좌측 테이블에만 적용)
// 헤더 필터와 필터 빌더는 서버사이드에서 처리됨 (fetchTableDataInternal에서 API 파라미터로 전달)
const filteredData = useMemo(() => {
let result = data;
// 1. 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링
// 분할 패널 좌측에 있고, 우측에 추가된 항목이 있는 경우 필터링
if (splitPanelPosition === "left" && splitPanelContext?.addedItemIds && splitPanelContext.addedItemIds.size > 0) {
const addedIds = splitPanelContext.addedItemIds;
result = result.filter((row) => {
@ -829,78 +839,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
});
}
// 2. 헤더 필터 적용 (joinColumnMapping 사용 - 조인된 컬럼과 일치해야 함)
if (Object.keys(headerFilters).length > 0) {
result = result.filter((row) => {
return Object.entries(headerFilters).every(([columnName, values]) => {
if (values.size === 0) return true;
// joinColumnMapping을 사용하여 조인된 컬럼명 확인
const mappedColumnName = joinColumnMapping[columnName] || columnName;
// 여러 가능한 컬럼명 시도 (mappedColumnName 우선)
const cellValue = row[mappedColumnName] ?? row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()];
const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue) : "";
return values.has(cellStr);
});
});
}
// 3. 🆕 Filter Builder 적용
if (filterGroups.length > 0) {
result = result.filter((row) => {
return filterGroups.every((group) => {
const validConditions = group.conditions.filter(
(c) => c.column && (c.operator === "isEmpty" || c.operator === "isNotEmpty" || c.value),
);
if (validConditions.length === 0) return true;
const evaluateCondition = (value: any, condition: (typeof group.conditions)[0]): boolean => {
const strValue = value !== null && value !== undefined ? String(value).toLowerCase() : "";
const condValue = condition.value.toLowerCase();
switch (condition.operator) {
case "equals":
return strValue === condValue;
case "notEquals":
return strValue !== condValue;
case "contains":
return strValue.includes(condValue);
case "notContains":
return !strValue.includes(condValue);
case "startsWith":
return strValue.startsWith(condValue);
case "endsWith":
return strValue.endsWith(condValue);
case "greaterThan":
return parseFloat(strValue) > parseFloat(condValue);
case "lessThan":
return parseFloat(strValue) < parseFloat(condValue);
case "greaterOrEqual":
return parseFloat(strValue) >= parseFloat(condValue);
case "lessOrEqual":
return parseFloat(strValue) <= parseFloat(condValue);
case "isEmpty":
return strValue === "" || value === null || value === undefined;
case "isNotEmpty":
return strValue !== "" && value !== null && value !== undefined;
default:
return true;
}
};
if (group.logic === "AND") {
return validConditions.every((cond) => evaluateCondition(row[cond.column], cond));
} else {
return validConditions.some((cond) => evaluateCondition(row[cond.column], cond));
}
});
});
}
return result;
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups, joinColumnMapping]);
}, [data, splitPanelPosition, splitPanelContext?.addedItemIds]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
@ -1650,16 +1590,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 트리 구조를 평탄화하는 헬퍼 함수 (메인 테이블 + 엔티티 조인 공통 사용)
// valueCode만 키로 사용 (valueId까지 넣으면 같은 라벨이 2번 나옴)
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>) => {
const flattenTree = (items: any[], mapping: Record<string, { label: string; color?: string }>, parentLabel: string = "") => {
items.forEach((item: any) => {
if (item.valueCode) {
const displayLabel = parentLabel
? `${parentLabel} / ${item.valueLabel}`
: item.valueLabel;
mapping[String(item.valueCode)] = {
label: item.valueLabel,
label: displayLabel,
color: item.color,
};
}
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
flattenTree(item.children, mapping);
flattenTree(item.children, mapping, item.valueLabel);
}
});
};
@ -1956,11 +1899,32 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
};
}
// 검색 필터, 연결 필터, RelatedDataButtons 필터 병합
// 🆕 헤더 필터를 서버 필터 형식으로 변환
const headerFilterValues: Record<string, any> = {};
Object.entries(headerFilters).forEach(([columnName, values]) => {
if (values.size > 0) {
const mappedCol = joinColumnMapping[columnName] || columnName;
headerFilterValues[mappedCol] = { value: Array.from(values), operator: "in" };
}
});
// 🆕 필터 빌더를 서버 필터 형식으로 변환
const filterBuilderValues: Record<string, any> = {};
filterGroups.forEach((group) => {
group.conditions.forEach((cond) => {
if (cond.column && (cond.operator === "isEmpty" || cond.operator === "isNotEmpty" || cond.value)) {
filterBuilderValues[cond.column] = { value: cond.value, operator: cond.operator };
}
});
});
// 검색 필터, 연결 필터, RelatedDataButtons 필터, 헤더 필터, 필터 빌더 병합
const filters = {
...(Object.keys(searchValues).length > 0 ? searchValues : {}),
...linkedFilterValues,
...relatedButtonFilterValues, // 🆕 RelatedDataButtons 필터 추가
...headerFilterValues, // 🆕 헤더 필터 추가
...filterBuilderValues, // 🆕 필터 빌더 추가
};
const hasFilters = Object.keys(filters).length > 0;
@ -2137,6 +2101,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
isRelatedButtonTarget,
// 🆕 프리뷰용 회사 코드 오버라이드
companyCode,
// 🆕 서버사이드 헤더 필터 / 필터 빌더
headerFilters,
filterGroups,
joinColumnMapping,
]);
const fetchTableDataDebounced = useCallback(
@ -2594,6 +2562,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return result;
}, [data, tableConfig.columns, joinColumnMapping]);
// 데이터 변경 시 헤더 필터 드롭다운 캐시 초기화
useEffect(() => {
setAsyncColumnUniqueValues({});
}, [data]);
// 🆕 헤더 필터 토글
const toggleHeaderFilter = useCallback((columnName: string, value: string) => {
setHeaderFilters((prev) => {
@ -6122,11 +6095,40 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<span className="text-primary">{sortDirection === "asc" ? "↑" : "↓"}</span>
)}
{/* 🆕 헤더 필터 버튼 */}
{tableConfig.headerFilter !== false &&
columnUniqueValues[column.columnName]?.length > 0 && (
{tableConfig.headerFilter !== false && (
<Popover
open={openFilterColumn === column.columnName}
onOpenChange={(open) => setOpenFilterColumn(open ? column.columnName : null)}
onOpenChange={(open) => {
if (open) {
setOpenFilterColumn(column.columnName);
setFilterSearchTerm("");
// 서버에서 고유값 가져오기
if (!asyncColumnUniqueValues[column.columnName]) {
setLoadingFilterColumn(column.columnName);
const mappedCol = joinColumnMapping[column.columnName] || column.columnName;
const tableName = tableConfig.selectedTable;
if (tableName) {
import("@/lib/api/client").then(({ apiClient }) => {
apiClient
.get(`/table-management/tables/${tableName}/column-values/${mappedCol}`)
.then((res) => {
const values = (res.data?.data || []).map((v: any) => ({
value: String(v.value ?? ""),
label: String(v.label ?? v.value ?? ""),
}));
setAsyncColumnUniqueValues((prev) => ({ ...prev, [column.columnName]: values }));
})
.catch(() => {
setAsyncColumnUniqueValues((prev) => ({ ...prev, [column.columnName]: [] }));
})
.finally(() => setLoadingFilterColumn(null));
});
}
}
} else {
setOpenFilterColumn(null);
}
}}
>
<PopoverTrigger asChild>
<button
@ -6146,7 +6148,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</button>
</PopoverTrigger>
<PopoverContent
className="w-48 p-2"
className="w-56 p-2"
align="start"
onClick={(e) => e.stopPropagation()}
>
@ -6164,35 +6166,66 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</button>
)}
</div>
{/* 검색 입력 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3 w-3 -translate-y-1/2" />
<input
type="text"
value={filterSearchTerm}
onChange={(e) => setFilterSearchTerm(e.target.value)}
placeholder="검색..."
className="border-input bg-background w-full rounded border py-1 pr-2 pl-7 text-xs"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => {
const isSelected = headerFilters[column.columnName]?.has(val);
return (
<div
key={val}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => toggleHeaderFilter(column.columnName, val)}
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded border",
isSelected ? "bg-primary border-primary" : "border-input",
)}
>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{val || "(빈 값)"}</span>
</div>
);
})}
{(columnUniqueValues[column.columnName]?.length || 0) > 50 && (
<div className="text-muted-foreground px-2 py-1 text-xs">
... {(columnUniqueValues[column.columnName]?.length || 0) - 50}
{loadingFilterColumn === column.columnName ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs">...</span>
</div>
)}
) : (asyncColumnUniqueValues[column.columnName] || []).length === 0 ? (
<div className="text-muted-foreground px-2 py-2 text-center text-xs">
</div>
) : (() => {
const filteredItems = (asyncColumnUniqueValues[column.columnName] || []).filter((item) => {
if (!filterSearchTerm) return true;
const term = filterSearchTerm.toLowerCase();
return item.value.toLowerCase().includes(term) || item.label.toLowerCase().includes(term);
});
return filteredItems.length === 0 ? (
<div className="text-muted-foreground px-2 py-2 text-center text-xs">
</div>
) : (
<>
{filteredItems.map((item) => {
const isSelected = headerFilters[column.columnName]?.has(item.value);
return (
<div
key={item.value}
className={cn(
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs",
isSelected && "bg-primary/10",
)}
onClick={() => toggleHeaderFilter(column.columnName, item.value)}
>
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded border",
isSelected ? "bg-primary border-primary" : "border-input",
)}
>
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
</div>
<span className="truncate">{item.label || item.value || "(빈 값)"}</span>
</div>
);
})}
</>
);
})()}
</div>
</div>
</PopoverContent>

View File

@ -3,7 +3,7 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Settings, X, ChevronsUpDown } from "lucide-react";
import { Settings, X, ChevronsUpDown, Search } from "lucide-react";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext";
import { useActiveTab } from "@/contexts/ActiveTabContext";
@ -77,6 +77,10 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
// 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지)
const [selectedLabels, setSelectedLabels] = useState<Record<string, string>>({});
// select 필터 드롭다운 내 검색 텍스트
const [selectSearchTexts, setSelectSearchTexts] = useState<Record<string, string>>({});
// select 필터 Popover 열림 상태
const [selectPopoverOpen, setSelectPopoverOpen] = useState<Record<string, boolean>>({});
// 높이 감지를 위한 ref
const containerRef = useRef<HTMLDivElement>(null);
@ -695,6 +699,14 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
[] as Array<{ value: string; label: string }>,
);
// 검색 텍스트로 필터링
const searchText = selectSearchTexts[filter.columnName] || "";
const filteredOptions = searchText
? uniqueOptions.filter((option) =>
option.label.toLowerCase().includes(searchText.toLowerCase())
)
: uniqueOptions;
// 항상 다중선택 모드
const selectedValues: string[] = Array.isArray(value) ? value : value ? [value] : [];
@ -719,7 +731,15 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
};
return (
<Popover>
<Popover
open={selectPopoverOpen[filter.columnName] || false}
onOpenChange={(open) => {
setSelectPopoverOpen((prev) => ({ ...prev, [filter.columnName]: open }));
if (!open) {
setSelectSearchTexts((prev) => ({ ...prev, [filter.columnName]: "" }));
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
@ -735,12 +755,34 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<div className="border-b p-2">
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
type="text"
placeholder="검색..."
value={selectSearchTexts[filter.columnName] || ""}
onChange={(e) =>
setSelectSearchTexts((prev) => ({
...prev,
[filter.columnName]: e.target.value,
}))
}
onKeyDown={(e) => { if (e.key === "Enter") e.preventDefault(); }}
className="h-8 pl-8 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 sm:text-sm"
style={{ outline: "none", boxShadow: "none" }}
autoFocus
/>
</div>
</div>
<div className="max-h-60 overflow-auto">
{uniqueOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs"> </div>
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground px-3 py-2 text-xs">
{searchText ? "검색 결과 없음" : "옵션 없음"}
</div>
) : (
<div className="p-1">
{uniqueOptions.map((option, index) => (
{filteredOptions.map((option, index) => (
<div
key={`${filter.columnName}-multi-${option.value}-${index}`}
className="hover:bg-accent flex cursor-pointer items-center space-x-2 rounded-sm px-2 py-1.5"

View File

@ -0,0 +1,92 @@
/**
* +
*/
// --- 자동 포맷팅 ---
// 전화번호: 숫자만 추출 → 자동 하이픈
// 010-1234-5678 / 02-1234-5678 / 031-123-4567
export function formatPhone(value: string): string {
const nums = value.replace(/\D/g, "").slice(0, 11);
if (nums.startsWith("02")) {
if (nums.length <= 2) return nums;
if (nums.length <= 5) return `${nums.slice(0, 2)}-${nums.slice(2)}`;
if (nums.length <= 9) return `${nums.slice(0, 2)}-${nums.slice(2, 5)}-${nums.slice(5)}`;
return `${nums.slice(0, 2)}-${nums.slice(2, 6)}-${nums.slice(6)}`;
}
if (nums.length <= 3) return nums;
if (nums.length <= 7) return `${nums.slice(0, 3)}-${nums.slice(3)}`;
return `${nums.slice(0, 3)}-${nums.slice(3, 7)}-${nums.slice(7)}`;
}
// 사업자번호: 000-00-00000
export function formatBusinessNumber(value: string): string {
const nums = value.replace(/\D/g, "").slice(0, 10);
if (nums.length <= 3) return nums;
if (nums.length <= 5) return `${nums.slice(0, 3)}-${nums.slice(3)}`;
return `${nums.slice(0, 3)}-${nums.slice(3, 5)}-${nums.slice(5)}`;
}
// 필드명으로 자동 포맷팅
export function formatField(fieldName: string, value: string): string {
switch (fieldName) {
case "contact_phone":
case "phone":
case "cell_phone":
return formatPhone(value);
case "business_number":
return formatBusinessNumber(value);
default:
return value;
}
}
// --- 유효성 검증 ---
export function validatePhone(value: string): string | null {
if (!value) return null;
const nums = value.replace(/\D/g, "");
if (nums.length < 9) return "전화번호를 끝까지 입력해주세요";
return null;
}
export function validateEmail(value: string): string | null {
if (!value) return null;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "올바른 이메일 형식이 아닙니다";
return null;
}
export function validateBusinessNumber(value: string): string | null {
if (!value) return null;
const nums = value.replace(/\D/g, "");
if (nums.length < 10) return "사업자번호를 끝까지 입력해주세요";
return null;
}
export function validateField(fieldName: string, value: string): string | null {
if (!value) return null;
switch (fieldName) {
case "contact_phone":
case "phone":
case "cell_phone":
return validatePhone(value);
case "email":
return validateEmail(value);
case "business_number":
return validateBusinessNumber(value);
default:
return null;
}
}
export function validateForm(
data: Record<string, any>,
fields: string[]
): Record<string, string> {
const errors: Record<string, string> = {};
for (const field of fields) {
const error = validateField(field, data[field] || "");
if (error) errors[field] = error;
}
return errors;
}

View File

@ -267,7 +267,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -309,7 +308,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -343,7 +341,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@ -3076,7 +3073,6 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@ -3730,7 +3726,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.6"
},
@ -3825,7 +3820,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@ -4139,7 +4133,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@ -6640,7 +6633,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -6651,7 +6643,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -6694,7 +6685,6 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@ -6777,7 +6767,6 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@ -7410,7 +7399,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -8561,8 +8549,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
@ -8884,7 +8871,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@ -9644,7 +9630,6 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -9733,7 +9718,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@ -9835,7 +9819,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -11007,7 +10990,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@ -11788,8 +11770,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
@ -13128,7 +13109,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@ -13422,7 +13402,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@ -13452,7 +13431,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@ -13501,7 +13479,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@ -13705,7 +13682,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -13775,7 +13751,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@ -13826,7 +13801,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@ -13859,8 +13833,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@ -14168,7 +14141,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@ -14191,8 +14163,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0",
@ -15222,8 +15193,7 @@
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@ -15311,7 +15281,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -15660,7 +15629,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"

View File

@ -15,6 +15,7 @@ export interface TableCategoryValue {
// 계층 구조
parentValueId?: number;
depth?: number;
path?: string;
// 추가 정보
description?: string;

View File

@ -137,6 +137,7 @@ export type V2SelectSource = "static" | "code" | "db" | "api" | "entity" | "cate
export interface SelectOption {
value: string;
label: string;
displayLabel?: string;
}
/**