Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node

This commit is contained in:
wace
2026-03-25 10:08:37 +09:00
188 changed files with 44078 additions and 3710 deletions
+212
View File
@@ -0,0 +1,212 @@
# Gitea Actions Workflow - vexplor 이미지 빌드 & Harbor Push
#
# 동작 방식:
# 1. main 브랜치에 push 시 자동 실행
# 2. Docker 이미지 빌드 (Backend, Frontend)
# 3. Harbor 레지스트리에 Push
# 4. 공장 서버의 Watchtower가 새 이미지 감지 후 자동 업데이트
#
# 필수 Secrets (Repository Settings > Secrets):
# - HARBOR_USERNAME: Harbor 사용자명
# - HARBOR_PASSWORD: Harbor 비밀번호
name: Build and Push Images
on:
push:
branches:
- main
- master
paths:
- "backend-node/**"
- "frontend/**"
- "docker/**"
- ".gitea/workflows/deploy.yml"
paths-ignore:
- "**.md"
- "deploy/**"
- "k8s/**"
workflow_dispatch: # 수동 실행도 가능
env:
GITEA_DOMAIN: g.wace.me
HARBOR_REGISTRY: localhost:5001
HARBOR_REGISTRY_EXTERNAL: harbor.wace.me
HARBOR_PROJECT: speefox_vexplor
# Frontend 빌드 환경 변수
NEXT_PUBLIC_API_URL: "https://api.vexplor.com/api"
NEXT_PUBLIC_ENV: "production"
# Frontend 설정
FRONTEND_IMAGE_NAME: vexplor-frontend
FRONTEND_BUILD_CONTEXT: frontend
FRONTEND_DOCKERFILE_PATH: docker/deploy/frontend.Dockerfile
# Backend 설정
BACKEND_IMAGE_NAME: vexplor-backend
BACKEND_BUILD_CONTEXT: backend-node
BACKEND_DOCKERFILE_PATH: docker/deploy/backend.Dockerfile
jobs:
build-and-push:
runs-on: ubuntu-24.04
steps:
# 작업 디렉토리 정리
- name: Clean workspace
run: |
echo "작업 디렉토리 정리..."
cd /workspace
rm -rf source
mkdir -p source
echo "정리 완료"
# 필수 도구 설치
- name: Install required tools
run: |
echo "필수 도구 설치 중..."
apt-get update -qq
apt-get install -y git curl ca-certificates gnupg
# Docker 클라이언트 설치
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update -qq
apt-get install -y docker-ce-cli
echo "설치 완료:"
git --version
docker --version
export DOCKER_HOST=unix:///var/run/docker.sock
docker version || echo "소켓 연결 대기 중..."
# 저장소 체크아웃
- name: Checkout code
run: |
echo "저장소 체크아웃..."
cd /workspace/source
git clone --depth 1 --branch ${GITHUB_REF_NAME} \
https://oauth2:${{ github.token }}@${GITEA_DOMAIN}/${GITHUB_REPOSITORY}.git .
echo "체크아웃 완료"
git log -1 --oneline
# 빌드 환경 설정
- name: Set up build environment
run: |
IMAGE_TAG="v$(date +%Y%m%d-%H%M%S)-${GITHUB_SHA::7}"
echo "IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_ENV
# Frontend 이미지
echo "FRONTEND_FULL_IMAGE=${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${FRONTEND_IMAGE_NAME}" >> $GITHUB_ENV
# Backend 이미지
echo "BACKEND_FULL_IMAGE=${HARBOR_REGISTRY}/${HARBOR_PROJECT}/${BACKEND_IMAGE_NAME}" >> $GITHUB_ENV
echo "=========================================="
echo "빌드 태그: ${IMAGE_TAG}"
echo "=========================================="
# Harbor 로그인
- name: Login to Harbor
env:
HARBOR_USER: ${{ secrets.HARBOR_USERNAME }}
HARBOR_PASS: ${{ secrets.HARBOR_PASSWORD }}
run: |
echo "Harbor 로그인..."
export DOCKER_HOST=unix:///var/run/docker.sock
echo "${HARBOR_PASS}" | docker login ${HARBOR_REGISTRY} \
--username ${HARBOR_USER} \
--password-stdin
echo "Harbor 로그인 완료"
# Backend 빌드 및 푸시
- name: Build and Push Backend image
run: |
echo "=========================================="
echo "Backend 이미지 빌드 시작..."
echo "=========================================="
export DOCKER_HOST=unix:///var/run/docker.sock
cd /workspace/source
docker build \
-t ${BACKEND_FULL_IMAGE}:${IMAGE_TAG} \
-t ${BACKEND_FULL_IMAGE}:latest \
-f ${BACKEND_DOCKERFILE_PATH} \
${BACKEND_BUILD_CONTEXT}
echo "Backend 이미지 푸시..."
docker push ${BACKEND_FULL_IMAGE}:${IMAGE_TAG}
docker push ${BACKEND_FULL_IMAGE}:latest
echo "=========================================="
echo "Backend 푸시 완료!"
echo " - ${BACKEND_FULL_IMAGE}:${IMAGE_TAG}"
echo " - ${BACKEND_FULL_IMAGE}:latest"
echo "=========================================="
# Frontend 빌드 및 푸시
- name: Build and Push Frontend image
run: |
echo "=========================================="
echo "Frontend 이미지 빌드 시작..."
echo "=========================================="
export DOCKER_HOST=unix:///var/run/docker.sock
cd /workspace/source
echo "빌드 환경 변수:"
echo " NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}"
echo " NEXT_PUBLIC_ENV=${NEXT_PUBLIC_ENV}"
docker build \
-t ${FRONTEND_FULL_IMAGE}:${IMAGE_TAG} \
-t ${FRONTEND_FULL_IMAGE}:latest \
-f ${FRONTEND_DOCKERFILE_PATH} \
--build-arg NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL}" \
${FRONTEND_BUILD_CONTEXT}
echo "Frontend 이미지 푸시..."
docker push ${FRONTEND_FULL_IMAGE}:${IMAGE_TAG}
docker push ${FRONTEND_FULL_IMAGE}:latest
echo "=========================================="
echo "Frontend 푸시 완료!"
echo " - ${FRONTEND_FULL_IMAGE}:${IMAGE_TAG}"
echo " - ${FRONTEND_FULL_IMAGE}:latest"
echo "=========================================="
# 빌드 완료 요약
- name: Build summary
if: success()
run: |
echo ""
echo "=========================================="
echo " 이미지 빌드 & Push 완료!"
echo "=========================================="
echo ""
echo "빌드 버전: ${IMAGE_TAG}"
echo ""
echo "푸시된 이미지:"
echo " - Backend: ${HARBOR_REGISTRY_EXTERNAL}/${HARBOR_PROJECT}/${BACKEND_IMAGE_NAME}:latest"
echo " - Frontend: ${HARBOR_REGISTRY_EXTERNAL}/${HARBOR_PROJECT}/${FRONTEND_IMAGE_NAME}:latest"
echo ""
echo "다음 단계:"
echo " - 공장 서버의 Watchtower가 자동으로 새 이미지를 감지합니다"
echo " - 또는 수동 업데이트: docker compose pull && docker compose up -d"
echo ""
echo "=========================================="
# Harbor 로그아웃
- name: Logout from Harbor
if: always()
run: |
export DOCKER_HOST=unix:///var/run/docker.sock
docker logout ${HARBOR_REGISTRY} || true
+15
View File
@@ -113,6 +113,16 @@ secrets.yml
api-keys.json
tokens.json
# Kubernetes Secrets (절대 커밋하지 않음!)
k8s/vexplor-secret.yaml
k8s/*-secret.yaml
!k8s/*-secret.yaml.template
# Kubernetes Secrets (절대 커밋하지 않음!)
k8s/vexplor-secret.yaml
k8s/*-secret.yaml
!k8s/*-secret.yaml.template
# 데이터베이스 덤프
*.sql
*.dump
@@ -168,6 +178,8 @@ uploads/
# ===== 기타 =====
claude.md
.cursor/mcp.json
# Agent Pipeline 로컬 파일
_local/
.agent-pipeline/
@@ -194,3 +206,6 @@ mcp-task-queue/
# 파이프라인 회고록 (자동 생성)
docs/retrospectives/
mes-architecture-guide.md
# MES Reference Documents
docs/mes-reference/
+441
View File
@@ -0,0 +1,441 @@
{
"version": "1.0.0",
"lastScanned": 1774313213052,
"projectRoot": "/Users/kimjuseok/ERP-node",
"techStack": {
"languages": [
{
"name": "JavaScript/TypeScript",
"version": null,
"confidence": "high",
"markers": [
"package.json"
]
}
],
"frameworks": [
{
"name": "playwright",
"version": "1.58.2",
"category": "testing"
}
],
"packageManager": "npm",
"runtime": null
},
"build": {
"buildCommand": null,
"testCommand": null,
"lintCommand": null,
"devCommand": null,
"scripts": {}
},
"conventions": {
"namingStyle": null,
"importStyle": null,
"testPattern": null,
"fileOrganization": null
},
"structure": {
"isMonorepo": false,
"workspaces": [],
"mainDirectories": [
"docs",
"scripts"
],
"gitBranches": {
"defaultBranch": "main",
"branchingStrategy": null
}
},
"customNotes": [],
"directoryMap": {
"_local": {
"path": "_local",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1774313213033,
"keyFiles": [
"pipeline-progress.json"
]
},
"ai-assistant": {
"path": "ai-assistant",
"purpose": null,
"fileCount": 5,
"lastAccessed": 1774313213036,
"keyFiles": [
"Dockerfile.win",
"README.md",
"package-lock.json",
"package.json"
]
},
"backend": {
"path": "backend",
"purpose": null,
"fileCount": 0,
"lastAccessed": 1774313213038,
"keyFiles": []
},
"backend-node": {
"path": "backend-node",
"purpose": null,
"fileCount": 17,
"lastAccessed": 1774313213039,
"keyFiles": [
"API_연동_가이드.md",
"API_키_정리.md",
"Dockerfile.win",
"PHASE1_USAGE_GUIDE.md",
"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": 14,
"lastAccessed": 1774313213041,
"keyFiles": [
"00-create-roles.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": 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": 1774313213042,
"keyFiles": []
},
"docs": {
"path": "docs",
"purpose": "Documentation",
"fileCount": 35,
"lastAccessed": 1774313213042,
"keyFiles": [
"AI_화면생성_시스템_설계서.md",
"BOM_개발_현황.md",
"DB_ARCHITECTURE_ANALYSIS.md",
"DB_STRUCTURE_DIAGRAM.html",
"DB_WORKFLOW_ANALYSIS.md"
]
},
"frontend": {
"path": "frontend",
"purpose": null,
"fileCount": 17,
"lastAccessed": 1774313213043,
"keyFiles": [
"MODAL_REPEATER_TABLE_DEBUG.md",
"README.md",
"approval-box-result.png",
"components.json",
"eslint.config.mjs"
]
},
"k8s": {
"path": "k8s",
"purpose": null,
"fileCount": 7,
"lastAccessed": 1774313213043,
"keyFiles": [
"local-path-provisioner.yaml",
"namespace.yaml",
"vexplor-backend-deployment.yaml",
"vexplor-config.yaml",
"vexplor-frontend-deployment.yaml"
]
},
"mcp-agent-orchestrator": {
"path": "mcp-agent-orchestrator",
"purpose": null,
"fileCount": 4,
"lastAccessed": 1774313213043,
"keyFiles": [
"README.md",
"package-lock.json",
"package.json",
"tsconfig.json"
]
},
"mcp-task-queue": {
"path": "mcp-task-queue",
"purpose": null,
"fileCount": 4,
"lastAccessed": 1774313213043,
"keyFiles": [
"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": 11,
"lastAccessed": 1774313213044,
"keyFiles": [
"add-modal-ids.py",
"analyze-company-info-layout.js",
"browser-test-admin-switch-button.js",
"browser-test-customer-crud.js",
"browser-test-customer-via-menu.js"
]
},
"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"
]
},
"test-results": {
"path": "test-results",
"purpose": null,
"fileCount": 1,
"lastAccessed": 1774313213044,
"keyFiles": []
},
"ai-assistant/src": {
"path": "ai-assistant/src",
"purpose": "Source code",
"fileCount": 1,
"lastAccessed": 1774313213045,
"keyFiles": [
"app.js"
]
},
"frontend/app": {
"path": "frontend/app",
"purpose": "Application code",
"fileCount": 5,
"lastAccessed": 1774313213046,
"keyFiles": [
"favicon.ico",
"globals.css",
"layout.tsx"
]
},
"frontend/components": {
"path": "frontend/components",
"purpose": "UI components",
"fileCount": 1,
"lastAccessed": 1774313213046,
"keyFiles": [
"GlobalFileViewer.tsx"
]
},
"mcp-agent-orchestrator/src": {
"path": "mcp-agent-orchestrator/src",
"purpose": "Source code",
"fileCount": 1,
"lastAccessed": 1774313213047,
"keyFiles": [
"index.ts"
]
},
"mcp-task-queue/data": {
"path": "mcp-task-queue/data",
"purpose": "Data files",
"fileCount": 2,
"lastAccessed": 1774313213047,
"keyFiles": [
"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": [
{
"path": "frontend/app/(main)/sales/order/page.tsx",
"accessCount": 16,
"lastAccessed": 1774313958064,
"type": "file"
},
{
"path": "frontend/app/(main)/sales/shipping-plan/page.tsx",
"accessCount": 4,
"lastAccessed": 1774313720455,
"type": "file"
},
{
"path": "frontend/components/common/DataGrid.tsx",
"accessCount": 3,
"lastAccessed": 1774313504763,
"type": "file"
},
{
"path": "frontend/components/common/DynamicSearchFilter.tsx",
"accessCount": 2,
"lastAccessed": 1774313460662,
"type": "file"
},
{
"path": "frontend/app/(main)/production/plan-management/page.tsx",
"accessCount": 2,
"lastAccessed": 1774313461313,
"type": "file"
},
{
"path": "frontend/app/(main)",
"accessCount": 2,
"lastAccessed": 1774313529384,
"type": "directory"
},
{
"path": "frontend/lib/api/shipping.ts",
"accessCount": 2,
"lastAccessed": 1774313725308,
"type": "file"
},
{
"path": ".claude/plans/lively-wishing-yeti.md",
"accessCount": 2,
"lastAccessed": 1774313824670,
"type": "file"
},
{
"path": "frontend/app/(main)/sales/shipping-order/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313447495,
"type": "file"
},
{
"path": "frontend/app/(main)/sales/claim/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313450420,
"type": "file"
},
{
"path": "frontend/app/(main)/production/process-info/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313450623,
"type": "file"
},
{
"path": "frontend/components/common/ExcelUploadModal.tsx",
"accessCount": 1,
"lastAccessed": 1774313454238,
"type": "file"
},
{
"path": "frontend/app/(main)/master-data/item-info/page.tsx",
"accessCount": 1,
"lastAccessed": 1774313528166,
"type": "file"
},
{
"path": "frontend/components/common/ShippingPlanModal.tsx",
"accessCount": 1,
"lastAccessed": 1774313925751,
"type": "file"
}
],
"userDirectives": []
}
@@ -0,0 +1,8 @@
{
"session_id": "591d357c-df9d-4bbc-8dfa-1b98a9184e23",
"ended_at": "2026-03-04T08:10:16.810Z",
"reason": "other",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}
@@ -0,0 +1,8 @@
{
"session_id": "d2bc3862-569e-4904-a3f9-6b20e3f14c43",
"ended_at": "2026-03-24T01:15:06.127Z",
"reason": "other",
"agents_spawned": 1,
"agents_completed": 1,
"modes_used": []
}
@@ -0,0 +1,8 @@
{
"session_id": "d6a10e69-4ebc-48f9-b451-c1d0587badc8",
"ended_at": "2026-03-24T01:15:07.644Z",
"reason": "other",
"agents_spawned": 0,
"agents_completed": 0,
"modes_used": []
}
+3
View File
@@ -0,0 +1,3 @@
{
"lastSentAt": "2026-03-24T02:36:44.477Z"
}
+53
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"
}
]
}
]
}
+26 -2
View File
@@ -144,6 +144,14 @@ import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -316,6 +324,8 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/production", productionRoutes); // 생산계획 관리
app.use("/api/material-status", materialStatusRoutes); // 자재현황
app.use("/api/process-info", processInfoRoutes); // 공정정보관리
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
@@ -337,6 +347,12 @@ app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
app.use("/api/mold", moldRoutes); // 금형 관리
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
app.use("/api/design", designRoutes); // 설계 모듈
app.use("/api/receiving", receivingRoutes); // 입고관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
@@ -362,12 +378,20 @@ app.use(errorHandler);
const PORT = config.port;
const HOST = config.host;
app.listen(PORT, HOST, async () => {
const server = app.listen(PORT, HOST, async () => {
logger.info(`🚀 Server is running on ${HOST}:${PORT}`);
logger.info(`📊 Environment: ${config.nodeEnv}`);
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
// 비동기 초기화 작업 (에러가 발생해도 서버는 유지)
initializeServices().catch(err => {
logger.error('❌ 서비스 초기화 중 치명적 에러 발생:', err);
});
});
// 서비스 초기화 함수 분리
async function initializeServices() {
// 데이터베이스 마이그레이션 실행
try {
const {
@@ -435,6 +459,6 @@ app.listen(PORT, HOST, async () => {
} catch (error) {
logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error);
}
});
}
export default app;
@@ -0,0 +1,488 @@
import { Response } from "express";
import { query } from "../database/db";
import { logger } from "../utils/logger";
function buildCompanyFilter(companyCode: string, alias: string, paramIdx: number) {
if (companyCode === "*") return { condition: "", params: [] as any[], nextIdx: paramIdx };
return {
condition: `${alias}.company_code = $${paramIdx}`,
params: [companyCode],
nextIdx: paramIdx + 1,
};
}
function buildDateFilter(startDate: string | undefined, endDate: string | undefined, dateExpr: string, paramIdx: number) {
const conditions: string[] = [];
const params: any[] = [];
let idx = paramIdx;
if (startDate) {
conditions.push(`${dateExpr} >= $${idx}`);
params.push(startDate);
idx++;
}
if (endDate) {
conditions.push(`${dateExpr} <= $${idx}`);
params.push(endDate);
idx++;
}
return { conditions, params, nextIdx: idx };
}
function buildWhereClause(conditions: string[]): string {
return conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
}
function extractFilterSet(rows: any[], field: string, labelField?: string): { value: string; label: string }[] {
const set = new Map<string, string>();
rows.forEach((r: any) => {
const val = r[field];
if (val && val !== "미지정") set.set(val, r[labelField || field] || val);
});
return [...set.entries()].map(([value, label]) => ({ value, label }));
}
// ============================================
// 생산 리포트
// ============================================
export async function getProductionReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const { startDate, endDate } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "wi", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx);
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(wi.start_date, wi.created_date::date::text) as date,
COALESCE(wi.routing, '미지정') as process,
COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment,
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
COALESCE(wi.worker, '미지정') as worker,
CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty",
COALESCE(pr.production_qty, 0) as "prodQty",
COALESCE(pr.defect_qty, 0) as "defectQty",
0 as "runTime",
0 as "downTime",
wi.status,
wi.company_code
FROM work_instruction wi
LEFT JOIN (
SELECT wo_id, company_code,
SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty,
SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty
FROM production_record GROUP BY wo_id, company_code
) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code
LEFT JOIN (
SELECT DISTINCT ON (equipment_code, company_code)
equipment_code, equipment_name, equipment_type, company_code
FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC
) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.company_code
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
${whereClause}
ORDER BY date DESC NULLS LAST
`;
const dataRows = await query(dataQuery, params);
logger.info("생산 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
processes: extractFilterSet(dataRows, "process"),
equipment: extractFilterSet(dataRows, "equipment"),
items: extractFilterSet(dataRows, "item"),
workers: extractFilterSet(dataRows, "worker"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("생산 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "생산 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 재고 리포트
// ============================================
export async function getInventoryReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "ist", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(ist.updated_date, ist.created_date)::date::text as date,
ist.item_code,
COALESCE(ii.item_name, ist.item_code, '미지정') as item,
COALESCE(wi.warehouse_name, ist.warehouse_code, '미지정') as warehouse,
'일반' as category,
CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) as "currentQty",
CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric) as "safetyQty",
COALESCE(ih_in.in_qty, 0) as "inQty",
COALESCE(ih_out.out_qty, 0) as "outQty",
0 as "stockValue",
GREATEST(CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric)
- CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric), 0) as "shortageQty",
CASE WHEN CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) > 0
AND COALESCE(ih_out.out_qty, 0) > 0
THEN ROUND(COALESCE(ih_out.out_qty, 0)::numeric
/ CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '1') AS numeric), 2)
ELSE 0 END as "turnover",
ist.company_code
FROM inventory_stock ist
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON ist.item_code = ii.item_number AND ist.company_code = ii.company_code
LEFT JOIN warehouse_info wi ON ist.warehouse_code = wi.warehouse_code
AND ist.company_code = wi.company_code
LEFT JOIN (
SELECT item_code, company_code,
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as in_qty
FROM inventory_history WHERE transaction_type = 'IN'
GROUP BY item_code, company_code
) ih_in ON ist.item_code = ih_in.item_code AND ist.company_code = ih_in.company_code
LEFT JOIN (
SELECT item_code, company_code,
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as out_qty
FROM inventory_history WHERE transaction_type = 'OUT'
GROUP BY item_code, company_code
) ih_out ON ist.item_code = ih_out.item_code AND ist.company_code = ih_out.company_code
${whereClause}
ORDER BY date DESC NULLS LAST
`;
const dataRows = await query(dataQuery, params);
logger.info("재고 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
items: extractFilterSet(dataRows, "item"),
warehouses: extractFilterSet(dataRows, "warehouse"),
categories: [
{ value: "원자재", label: "원자재" }, { value: "부자재", label: "부자재" },
{ value: "반제품", label: "반제품" }, { value: "완제품", label: "완제품" },
{ value: "일반", label: "일반" },
],
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("재고 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "재고 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 구매 리포트
// ============================================
export async function getPurchaseReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const { startDate, endDate } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "po", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const df = buildDateFilter(startDate, endDate, "COALESCE(po.order_date, po.created_date::date::text)", idx);
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(po.order_date, po.created_date::date::text) as date,
po.purchase_no,
COALESCE(po.supplier_name, po.supplier_code, '미지정') as supplier,
COALESCE(po.item_name, po.item_code, '미지정') as item,
po.item_code,
COALESCE(po.manager, '미지정') as manager,
po.status,
CAST(COALESCE(NULLIF(po.order_qty, ''), '0') AS numeric) as "orderQty",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "unitPrice",
CAST(COALESCE(NULLIF(po.amount, ''), '0') AS numeric) as "orderAmt",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric)
* CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "receiveAmt",
1 as "orderCnt",
po.company_code
FROM purchase_order_mng po
${whereClause}
ORDER BY date DESC NULLS LAST
`;
const dataRows = await query(dataQuery, params);
logger.info("구매 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
suppliers: extractFilterSet(dataRows, "supplier"),
items: extractFilterSet(dataRows, "item"),
managers: extractFilterSet(dataRows, "manager"),
statuses: extractFilterSet(dataRows, "status"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("구매 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "구매 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 품질 리포트
// ============================================
export async function getQualityReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const { startDate, endDate } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "pr", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const df = buildDateFilter(startDate, endDate, "COALESCE(pr.production_date, pr.created_date::date::text)", idx);
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(pr.production_date, pr.created_date::date::text) as date,
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
'일반검사' as "defectType",
COALESCE(wi.routing, '미지정') as process,
COALESCE(pr.worker_name, '미지정') as inspector,
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric) as "inspQty",
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric)
- CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "passQty",
CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "defectQty",
0 as "reworkQty",
0 as "scrapQty",
0 as "claimCnt",
pr.company_code
FROM production_record pr
LEFT JOIN work_instruction wi ON pr.wo_id = wi.id AND pr.company_code = wi.company_code
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
${whereClause}
ORDER BY date DESC NULLS LAST
`;
const dataRows = await query(dataQuery, params);
logger.info("품질 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
items: extractFilterSet(dataRows, "item"),
defectTypes: [
{ value: "외관불량", label: "외관불량" }, { value: "치수불량", label: "치수불량" },
{ value: "기능불량", label: "기능불량" }, { value: "재질불량", label: "재질불량" },
{ value: "일반검사", label: "일반검사" },
],
processes: extractFilterSet(dataRows, "process"),
inspectors: extractFilterSet(dataRows, "inspector"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("품질 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "품질 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 설비 리포트
// ============================================
export async function getEquipmentReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "ei", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(ei.updated_date, ei.created_date)::date::text as date,
ei.equipment_code,
COALESCE(ei.equipment_name, ei.equipment_code) as equipment,
COALESCE(ei.equipment_type, '미지정') as "equipType",
COALESCE(ei.location, '미지정') as line,
COALESCE(ui.user_name, ei.manager_id, '미지정') as manager,
ei.status,
CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "runTime",
0 as "downTime",
100 as "opRate",
0 as "faultCnt",
0 as "mtbf",
0 as "mttr",
0 as "maintCost",
CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "prodQty",
ei.company_code
FROM equipment_info ei
LEFT JOIN (
SELECT DISTINCT ON (user_id) user_id, user_name FROM user_info
) ui ON ei.manager_id = ui.user_id
${whereClause}
ORDER BY equipment ASC
`;
const dataRows = await query(dataQuery, params);
logger.info("설비 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
equipment: extractFilterSet(dataRows, "equipment"),
equipTypes: extractFilterSet(dataRows, "equipType"),
lines: extractFilterSet(dataRows, "line"),
managers: extractFilterSet(dataRows, "manager"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("설비 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "설비 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 금형 리포트
// ============================================
export async function getMoldReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "mm", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(mm.updated_date, mm.created_date)::date::text as date,
mm.mold_code,
COALESCE(mm.mold_name, mm.mold_code) as mold,
COALESCE(mm.mold_type, mm.category, '미지정') as "moldType",
COALESCE(ii.item_name, '미지정') as item,
COALESCE(mm.manufacturer, '미지정') as maker,
mm.operation_status as status,
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) as "shotCnt",
CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) as "guaranteeShot",
CASE WHEN CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) > 0
THEN ROUND(
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) * 100.0
/ CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '1') AS numeric), 1)
ELSE 0 END as "lifeRate",
0 as "repairCnt",
0 as "repairCost",
0 as "prodQty",
0 as "defectRate",
CAST(COALESCE(NULLIF(mm.cavity_count::text, ''), '0') AS numeric) as "cavityUse",
mm.company_code
FROM mold_mng mm
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON mm.mold_code = ii.item_number AND mm.company_code = ii.company_code
${whereClause}
ORDER BY mold ASC
`;
const dataRows = await query(dataQuery, params);
logger.info("금형 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
molds: extractFilterSet(dataRows, "mold"),
moldTypes: extractFilterSet(dataRows, "moldType"),
items: extractFilterSet(dataRows, "item"),
makers: extractFilterSet(dataRows, "maker"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("금형 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "금형 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
@@ -126,29 +126,41 @@ export class BatchManagementController {
*/
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
try {
const { batchName, description, cronSchedule, mappings, isActive } =
req.body;
const {
batchName, description, cronSchedule, mappings, isActive,
executionType, nodeFlowId, nodeFlowContext,
} = req.body;
const companyCode = req.user?.companyCode;
if (
!batchName ||
!cronSchedule ||
!mappings ||
!Array.isArray(mappings)
) {
if (!batchName || !cronSchedule) {
return res.status(400).json({
success: false,
message:
"필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)",
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)",
});
}
const batchConfig = await BatchService.createBatchConfig({
batchName,
description,
cronSchedule,
mappings,
isActive: isActive !== undefined ? isActive : true,
} as CreateBatchConfigRequest);
// 노드 플로우 타입은 매핑 없이 생성 가능
if (executionType !== "node_flow" && (!mappings || !Array.isArray(mappings))) {
return res.status(400).json({
success: false,
message: "매핑 타입은 mappings 배열이 필요합니다.",
});
}
const batchConfig = await BatchService.createBatchConfig(
{
batchName,
description,
cronSchedule,
mappings: mappings || [],
isActive: isActive === false || isActive === "N" ? "N" : "Y",
companyCode: companyCode || "",
executionType: executionType || "mapping",
nodeFlowId: nodeFlowId || null,
nodeFlowContext: nodeFlowContext || null,
} as CreateBatchConfigRequest,
req.user?.userId
);
return res.status(201).json({
success: true,
@@ -768,4 +780,287 @@ export class BatchManagementController {
});
}
}
/**
* 노드 플로우 목록 조회 (배치 설정에서 플로우 선택용)
* GET /api/batch-management/node-flows
*/
static async getNodeFlows(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
let flowQuery: string;
let flowParams: any[] = [];
if (companyCode === "*") {
flowQuery = `
SELECT flow_id, flow_name, flow_description AS description, company_code,
COALESCE(jsonb_array_length(
CASE WHEN flow_data IS NOT NULL AND flow_data::text != ''
THEN (flow_data::jsonb -> 'nodes')
ELSE '[]'::jsonb END
), 0) AS node_count
FROM node_flows
ORDER BY flow_name
`;
} else {
flowQuery = `
SELECT flow_id, flow_name, flow_description AS description, company_code,
COALESCE(jsonb_array_length(
CASE WHEN flow_data IS NOT NULL AND flow_data::text != ''
THEN (flow_data::jsonb -> 'nodes')
ELSE '[]'::jsonb END
), 0) AS node_count
FROM node_flows
WHERE company_code = $1
ORDER BY flow_name
`;
flowParams = [companyCode];
}
const result = await query(flowQuery, flowParams);
return res.json({ success: true, data: result });
} catch (error) {
console.error("노드 플로우 목록 조회 오류:", error);
return res.status(500).json({
success: false,
message: "노드 플로우 목록 조회 실패",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 배치 대시보드 통계 조회
* GET /api/batch-management/stats
* totalBatches, activeBatches, todayExecutions, todayFailures, prevDayExecutions, prevDayFailures
* 멀티테넌시: company_code 필터링 필수
*/
static async getBatchStats(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user?.companyCode;
// 전체/활성 배치 수
let configQuery: string;
let configParams: any[] = [];
if (companyCode === "*") {
configQuery = `
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active
FROM batch_configs
`;
} else {
configQuery = `
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active
FROM batch_configs
WHERE company_code = $1
`;
configParams = [companyCode];
}
const configResult = await query<{ total: number; active: number }>(
configQuery,
configParams
);
// 오늘/어제 실행·실패 수 (KST 기준 날짜)
const logParams: any[] = [];
let logWhere = "";
if (companyCode && companyCode !== "*") {
logWhere = " AND company_code = $1";
logParams.push(companyCode);
}
const todayLogQuery = `
SELECT
COUNT(*)::int AS today_executions,
COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS today_failures
FROM batch_execution_logs
WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date
${logWhere}
`;
const prevDayLogQuery = `
SELECT
COUNT(*)::int AS prev_executions,
COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS prev_failures
FROM batch_execution_logs
WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date - INTERVAL '1 day'
${logWhere}
`;
const [todayResult, prevResult] = await Promise.all([
query<{ today_executions: number; today_failures: number }>(
todayLogQuery,
logParams
),
query<{ prev_executions: number; prev_failures: number }>(
prevDayLogQuery,
logParams
),
]);
const config = configResult[0];
const today = todayResult[0];
const prev = prevResult[0];
return res.json({
success: true,
data: {
totalBatches: config?.total ?? 0,
activeBatches: config?.active ?? 0,
todayExecutions: today?.today_executions ?? 0,
todayFailures: today?.today_failures ?? 0,
prevDayExecutions: prev?.prev_executions ?? 0,
prevDayFailures: prev?.prev_failures ?? 0,
},
});
} catch (error) {
console.error("배치 통계 조회 오류:", error);
return res.status(500).json({
success: false,
message: "배치 통계 조회 실패",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 배치별 최근 24시간 스파크라인 (1시간 단위 집계)
* GET /api/batch-management/batch-configs/:id/sparkline
* 멀티테넌시: company_code 필터링 필수
*/
static async getBatchSparkline(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode;
const batchId = Number(id);
if (!id || isNaN(batchId)) {
return res.status(400).json({
success: false,
message: "올바른 배치 ID를 제공해주세요.",
});
}
const params: any[] = [batchId];
let companyFilter = "";
if (companyCode && companyCode !== "*") {
companyFilter = " AND bel.company_code = $2";
params.push(companyCode);
}
// KST 기준 최근 24시간 1시간 단위 슬롯 + 집계 (generate_series로 24개 보장)
const sparklineQuery = `
WITH kst_slots AS (
SELECT to_char(s, 'YYYY-MM-DD"T"HH24:00:00') AS hour
FROM generate_series(
(NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '23 hours',
(NOW() AT TIME ZONE 'Asia/Seoul'),
INTERVAL '1 hour'
) AS s
),
agg AS (
SELECT
to_char(date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul')) AT TIME ZONE 'Asia/Seoul', 'YYYY-MM-DD"T"HH24:00:00') AS hour,
COUNT(*) FILTER (WHERE bel.execution_status = 'SUCCESS')::int AS success,
COUNT(*) FILTER (WHERE bel.execution_status = 'FAILED')::int AS failed
FROM batch_execution_logs bel
WHERE bel.batch_config_id = $1
AND bel.start_time >= (NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '24 hours'
${companyFilter}
GROUP BY date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul'))
)
SELECT
k.hour,
COALESCE(a.success, 0) AS success,
COALESCE(a.failed, 0) AS failed
FROM kst_slots k
LEFT JOIN agg a ON k.hour = a.hour
ORDER BY k.hour
`;
const data = await query<{
hour: string;
success: number;
failed: number;
}>(sparklineQuery, params);
return res.json({ success: true, data });
} catch (error) {
console.error("스파크라인 조회 오류:", error);
return res.status(500).json({
success: false,
message: "스파크라인 데이터 조회 실패",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
/**
* 배치별 최근 실행 로그 (최대 20건)
* GET /api/batch-management/batch-configs/:id/recent-logs
* 멀티테넌시: company_code 필터링 필수
*/
static async getBatchRecentLogs(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode;
const batchId = Number(id);
const limit = Math.min(Number(req.query.limit) || 20, 20);
if (!id || isNaN(batchId)) {
return res.status(400).json({
success: false,
message: "올바른 배치 ID를 제공해주세요.",
});
}
let logsQuery: string;
let logsParams: any[];
if (companyCode === "*") {
logsQuery = `
SELECT
id,
start_time AS started_at,
end_time AS finished_at,
execution_status AS status,
total_records,
success_records,
failed_records,
error_message,
duration_ms
FROM batch_execution_logs
WHERE batch_config_id = $1
ORDER BY start_time DESC
LIMIT $2
`;
logsParams = [batchId, limit];
} else {
logsQuery = `
SELECT
id,
start_time AS started_at,
end_time AS finished_at,
execution_status AS status,
total_records,
success_records,
failed_records,
error_message,
duration_ms
FROM batch_execution_logs
WHERE batch_config_id = $1 AND company_code = $2
ORDER BY start_time DESC
LIMIT $3
`;
logsParams = [batchId, companyCode, limit];
}
const result = await query(logsQuery, logsParams);
return res.json({ success: true, data: result });
} catch (error) {
console.error("최근 실행 이력 조회 오류:", error);
return res.status(500).json({
success: false,
message: "최근 실행 이력 조회 실패",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
}
@@ -0,0 +1,946 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, getPool } from "../database/db";
import { logger } from "../utils/logger";
// 회사코드 필터 조건 생성 헬퍼
function companyFilter(companyCode: string, paramIndex: number, alias?: string): { condition: string; param: string; nextIndex: number } {
const col = alias ? `${alias}.company_code` : "company_code";
if (companyCode === "*") {
return { condition: "", param: "", nextIndex: paramIndex };
}
return { condition: `${col} = $${paramIndex}`, param: companyCode, nextIndex: paramIndex + 1 };
}
// ============================================
// 설계의뢰/설변요청 (DR/ECR) CRUD
// ============================================
export async function getDesignRequestList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { source_type, status, priority, search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let pi = 1;
if (companyCode !== "*") {
conditions.push(`r.company_code = $${pi}`);
params.push(companyCode);
pi++;
}
if (source_type) { conditions.push(`r.source_type = $${pi}`); params.push(source_type); pi++; }
if (status) { conditions.push(`r.status = $${pi}`); params.push(status); pi++; }
if (priority) { conditions.push(`r.priority = $${pi}`); params.push(priority); pi++; }
if (search) {
conditions.push(`(r.target_name ILIKE $${pi} OR r.request_no ILIKE $${pi} OR r.requester ILIKE $${pi})`);
params.push(`%${search}%`);
pi++;
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT r.*,
COALESCE(json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description)) FILTER (WHERE h.id IS NOT NULL), '[]') AS history,
COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact
FROM dsn_design_request r
LEFT JOIN dsn_request_history h ON h.request_id = r.id
${where}
GROUP BY r.id
ORDER BY r.created_date DESC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("설계의뢰 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function getDesignRequestDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`r.id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`r.company_code = $2`); params.push(companyCode); }
const sql = `
SELECT r.*,
COALESCE((SELECT json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_request_history h WHERE h.request_id = r.id), '[]') AS history,
COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact
FROM dsn_design_request r
WHERE ${conditions.join(" AND ")}
`;
const result = await query(sql, params);
if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("설계의뢰 상세 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const {
request_no, source_type, request_date, due_date, priority, status,
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency, reason,
content, apply_timing, review_memo, project_id, ecn_no,
impact, history,
} = req.body;
const sql = `
INSERT INTO dsn_design_request (
request_no, source_type, request_date, due_date, priority, status,
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency, reason,
content, apply_timing, review_memo, project_id, ecn_no,
writer, company_code
) 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)
RETURNING *
`;
const result = await client.query(sql, [
request_no, source_type || "dr", request_date, due_date, priority || "보통", status || "신규접수",
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency || "보통", reason,
content, apply_timing, review_memo, project_id, ecn_no,
userId, companyCode,
]);
const requestId = result.rows[0].id;
if (impact?.length) {
for (const imp of impact) {
await client.query(
`INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`,
[requestId, imp, userId, companyCode]
);
}
}
if (history?.length) {
for (const h of history) {
await client.query(
`INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[requestId, h.step, h.history_date, h.user_name, h.description, userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("설계의뢰 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function updateDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { id } = req.params;
const {
request_no, source_type, request_date, due_date, priority, status, approval_step,
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency, reason,
content, apply_timing, review_memo, project_id, ecn_no,
impact, history,
} = req.body;
const conditions = [`id = $1`];
const params: any[] = [id];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const setClauses = [];
const setParams: any[] = [];
const fields: Record<string, any> = {
request_no, source_type, request_date, due_date, priority, status, approval_step,
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency, reason,
content, apply_timing, review_memo, project_id, ecn_no,
};
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) {
setClauses.push(`${key} = $${pi}`);
setParams.push(val);
pi++;
}
}
setClauses.push(`updated_date = now()`);
const sql = `UPDATE dsn_design_request SET ${setClauses.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`;
const result = await client.query(sql, [...params, ...setParams]);
if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
if (impact !== undefined) {
await client.query(`DELETE FROM dsn_request_impact WHERE request_id = $1`, [id]);
for (const imp of impact) {
await client.query(
`INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`,
[id, imp, userId, companyCode]
);
}
}
if (history !== undefined) {
await client.query(`DELETE FROM dsn_request_history WHERE request_id = $1`, [id]);
for (const h of history) {
await client.query(
`INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[id, h.step, h.history_date, h.user_name, h.description, userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("설계의뢰 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function deleteDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const sql = `DELETE FROM dsn_design_request WHERE ${conditions.join(" AND ")} RETURNING id`;
const result = await query(sql, params);
if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("설계의뢰 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// 이력 추가 (단건)
export async function addRequestHistory(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { id } = req.params;
const { step, history_date, user_name, description } = req.body;
const sql = `INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`;
const result = await query(sql, [id, step, history_date, user_name, description, userId, companyCode]);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("의뢰 이력 추가 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 설계 프로젝트 CRUD
// ============================================
export async function getProjectList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { status, search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let pi = 1;
if (companyCode !== "*") { conditions.push(`p.company_code = $${pi}`); params.push(companyCode); pi++; }
if (status) { conditions.push(`p.status = $${pi}`); params.push(status); pi++; }
if (search) {
conditions.push(`(p.name ILIKE $${pi} OR p.project_no ILIKE $${pi} OR p.customer ILIKE $${pi})`);
params.push(`%${search}%`);
pi++;
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT p.*,
COALESCE(
(SELECT json_agg(json_build_object(
'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee,
'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status,
'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order
) ORDER BY t.sort_order, t.start_date)
FROM dsn_project_task t WHERE t.project_id = p.id), '[]'
) AS tasks
FROM dsn_project p
${where}
ORDER BY p.created_date DESC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("프로젝트 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function getProjectDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`p.id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`p.company_code = $2`); params.push(companyCode); }
const sql = `
SELECT p.*,
COALESCE(
(SELECT json_agg(json_build_object(
'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee,
'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status,
'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order
) ORDER BY t.sort_order, t.start_date)
FROM dsn_project_task t WHERE t.project_id = p.id), '[]'
) AS tasks
FROM dsn_project p
WHERE ${conditions.join(" AND ")}
`;
const result = await query(sql, params);
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("프로젝트 상세 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createProject(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, tasks } = req.body;
const result = await client.query(
`INSERT INTO dsn_project (project_no, name, status, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
[project_no, name, pStatus || "계획", pm, customer, start_date, end_date, source_no, description, progress || "0", parent_id, relation_type, userId, companyCode]
);
const projectId = result.rows[0].id;
if (tasks?.length) {
for (let i = 0; i < tasks.length; i++) {
const t = tasks[i];
await client.query(
`INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
[projectId, t.name, t.category, t.assignee, t.start_date, t.end_date, t.status || "대기", t.progress || "0", t.priority || "보통", t.remark, String(i), userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("프로젝트 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function updateProject(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type } = req.body;
const conditions = [`id = $1`];
const params: any[] = [id];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await query(`UPDATE dsn_project SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("프로젝트 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteProject(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_project WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("프로젝트 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 프로젝트 태스크 CRUD
// ============================================
export async function getTasksByProject(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { projectId } = req.params;
const conditions = [`t.project_id = $1`];
const params: any[] = [projectId];
if (companyCode !== "*") { conditions.push(`t.company_code = $2`); params.push(companyCode); }
const sql = `
SELECT t.*,
COALESCE((SELECT json_agg(json_build_object('id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'progress_before', w.progress_before, 'progress_after', w.progress_after, 'author', w.author, 'sub_item_id', w.sub_item_id) ORDER BY w.start_dt) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs,
COALESCE((SELECT json_agg(json_build_object('id', i.id, 'title', i.title, 'status', i.status, 'priority', i.priority, 'description', i.description, 'registered_by', i.registered_by, 'registered_date', i.registered_date, 'resolved_date', i.resolved_date)) FROM dsn_task_issue i WHERE i.task_id = t.id), '[]') AS issues,
COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items
FROM dsn_project_task t
WHERE ${conditions.join(" AND ")}
ORDER BY t.sort_order, t.start_date
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("태스크 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createTask(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { projectId } = req.params;
const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body;
const result = await query(
`INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`,
[projectId, name, category, assignee, start_date, end_date, status || "대기", progress || "0", priority || "보통", remark, sort_order || "0", userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("태스크 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateTask(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { taskId } = req.params;
const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body;
const conditions = [`id = $1`];
const params: any[] = [taskId];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await query(`UPDATE dsn_project_task SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("태스크 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteTask(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { taskId } = req.params;
const conditions = [`id = $1`];
const params: any[] = [taskId];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_project_task WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("태스크 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 작업일지 CRUD
// ============================================
export async function getWorkLogsByTask(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { taskId } = req.params;
const conditions = [`w.task_id = $1`];
const params: any[] = [taskId];
if (companyCode !== "*") { conditions.push(`w.company_code = $2`); params.push(companyCode); }
const sql = `
SELECT w.*,
COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]') AS attachments,
COALESCE((SELECT json_agg(json_build_object('id', p.id, 'item', p.item, 'qty', p.qty, 'unit', p.unit, 'reason', p.reason, 'status', p.status)) FROM dsn_purchase_req p WHERE p.work_log_id = w.id), '[]') AS purchase_reqs,
COALESCE((SELECT json_agg(json_build_object(
'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date,
'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]')
)) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]') AS coop_reqs
FROM dsn_work_log w
WHERE ${conditions.join(" AND ")}
ORDER BY w.start_dt DESC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("작업일지 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createWorkLog(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { taskId } = req.params;
const { start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id } = req.body;
const result = await query(
`INSERT INTO dsn_work_log (task_id, start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
[taskId, start_dt, end_dt, hours || "0", description, progress_before || "0", progress_after || "0", author, sub_item_id, userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("작업일지 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteWorkLog(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { workLogId } = req.params;
const conditions = [`id = $1`];
const params: any[] = [workLogId];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_work_log WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "작업일지를 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("작업일지 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 태스크 하위항목 CRUD
// ============================================
export async function createSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { taskId } = req.params;
const { name, weight, progress, status } = req.body;
const result = await query(
`INSERT INTO dsn_task_sub_item (task_id, name, weight, progress, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[taskId, name, weight || "0", progress || "0", status || "대기", userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("하위항목 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { subItemId } = req.params;
const { name, weight, progress, status } = req.body;
const conditions = [`id = $1`];
const params: any[] = [subItemId];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { name, weight, progress, status };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await query(`UPDATE dsn_task_sub_item SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("하위항목 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { subItemId } = req.params;
const conditions = [`id = $1`];
const params: any[] = [subItemId];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_task_sub_item WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("하위항목 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 태스크 이슈 CRUD
// ============================================
export async function createIssue(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { taskId } = req.params;
const { title, status, priority, description, registered_by, registered_date } = req.body;
const result = await query(
`INSERT INTO dsn_task_issue (task_id, title, status, priority, description, registered_by, registered_date, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
[taskId, title, status || "등록", priority || "보통", description, registered_by, registered_date, userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("이슈 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateIssue(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { issueId } = req.params;
const { title, status, priority, description, resolved_date } = req.body;
const conditions = [`id = $1`];
const params: any[] = [issueId];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { title, status, priority, description, resolved_date };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await query(`UPDATE dsn_task_issue SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.length) { res.status(404).json({ success: false, message: "이슈를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("이슈 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// ECN (설변통보) CRUD
// ============================================
export async function getEcnList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { status, search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let pi = 1;
if (companyCode !== "*") { conditions.push(`e.company_code = $${pi}`); params.push(companyCode); pi++; }
if (status) { conditions.push(`e.status = $${pi}`); params.push(status); pi++; }
if (search) {
conditions.push(`(e.ecn_no ILIKE $${pi} OR e.target ILIKE $${pi})`);
params.push(`%${search}%`);
pi++;
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT e.*,
COALESCE((SELECT json_agg(json_build_object('id', h.id, 'status', h.status, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_ecn_history h WHERE h.ecn_id = e.id), '[]') AS history,
COALESCE((SELECT json_agg(nd.dept_name) FROM dsn_ecn_notify_dept nd WHERE nd.ecn_id = e.id), '[]') AS notify_depts
FROM dsn_ecn e
${where}
ORDER BY e.created_date DESC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("ECN 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body;
const result = await client.query(
`INSERT INTO dsn_ecn (ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
[ecn_no, ecr_id, ecn_date, apply_date, status || "ECN발행", target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, userId, companyCode]
);
const ecnId = result.rows[0].id;
if (notify_depts?.length) {
for (const dept of notify_depts) {
await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [ecnId, dept, userId, companyCode]);
}
}
if (history?.length) {
for (const h of history) {
await client.query(
`INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[ecnId, h.status, h.history_date, h.user_name, h.description, userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("ECN 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function updateEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { id } = req.params;
const { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body;
const conditions = [`id = $1`];
const params: any[] = [id];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await client.query(`UPDATE dsn_ecn SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; }
if (notify_depts !== undefined) {
await client.query(`DELETE FROM dsn_ecn_notify_dept WHERE ecn_id = $1`, [id]);
for (const dept of notify_depts) {
await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [id, dept, userId, companyCode]);
}
}
if (history !== undefined) {
await client.query(`DELETE FROM dsn_ecn_history WHERE ecn_id = $1`, [id]);
for (const h of history) {
await client.query(
`INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[id, h.status, h.history_date, h.user_name, h.description, userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("ECN 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function deleteEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_ecn WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("ECN 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 나의 업무 (My Work) - 로그인 사용자 기준
// ============================================
export async function getMyWork(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userName = req.user!.userName;
const { status, project_id } = req.query;
const conditions = [`t.assignee = $1`];
const params: any[] = [userName];
let pi = 2;
if (companyCode !== "*") { conditions.push(`t.company_code = $${pi}`); params.push(companyCode); pi++; }
if (status) { conditions.push(`t.status = $${pi}`); params.push(status); pi++; }
if (project_id) { conditions.push(`t.project_id = $${pi}`); params.push(project_id); pi++; }
const sql = `
SELECT t.*,
p.project_no, p.name AS project_name, p.customer AS project_customer, p.status AS project_status,
COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items,
COALESCE((SELECT json_agg(json_build_object(
'id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'sub_item_id', w.sub_item_id,
'attachments', COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]'),
'purchase_reqs', COALESCE((SELECT json_agg(json_build_object('id', pr.id, 'item', pr.item, 'qty', pr.qty, 'unit', pr.unit, 'reason', pr.reason, 'status', pr.status)) FROM dsn_purchase_req pr WHERE pr.work_log_id = w.id), '[]'),
'coop_reqs', COALESCE((SELECT json_agg(json_build_object(
'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date,
'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]')
)) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]')
) ORDER BY w.start_dt DESC) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs
FROM dsn_project_task t
JOIN dsn_project p ON p.id = t.project_id
WHERE ${conditions.join(" AND ")}
ORDER BY
CASE t.status WHEN '진행중' THEN 1 WHEN '대기' THEN 2 WHEN '검토중' THEN 3 ELSE 4 END,
t.end_date ASC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("나의 업무 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 구매요청 / 협업요청 CRUD (my-work에서 사용)
// ============================================
export async function createPurchaseReq(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { workLogId } = req.params;
const { item, qty, unit, reason, status } = req.body;
const result = await query(
`INSERT INTO dsn_purchase_req (work_log_id, item, qty, unit, reason, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[workLogId, item, qty, unit, reason, status || "요청", userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("구매요청 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createCoopReq(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { workLogId } = req.params;
const { to_user, to_dept, title, description, due_date } = req.body;
const result = await query(
`INSERT INTO dsn_coop_req (work_log_id, to_user, to_dept, title, description, status, due_date, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
[workLogId, to_user, to_dept, title, description, "요청", due_date, userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("협업요청 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function addCoopResponse(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { coopReqId } = req.params;
const { response_date, user_name, content } = req.body;
const result = await query(
`INSERT INTO dsn_coop_response (coop_req_id, response_date, user_name, content, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[coopReqId, response_date, user_name, content, userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("협업응답 추가 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
@@ -0,0 +1,352 @@
/**
* 자재현황 컨트롤러
* - 생산계획(작업지시) 조회
* - 선택된 작업지시의 BOM 기반 자재소요량 + 재고 현황 조회
* - 창고 목록 조회
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { pool } from "../database/db";
import { logger } from "../utils/logger";
// ─── 생산계획(작업지시) 조회 ───
export async function getWorkOrders(
req: AuthenticatedRequest,
res: Response
) {
try {
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, itemCode, itemName } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (companyCode === "*") {
logger.info("최고 관리자 전체 작업지시 조회");
} else {
conditions.push(`p.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
if (dateFrom) {
conditions.push(`p.plan_date >= $${paramIndex}::date`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
conditions.push(`p.plan_date <= $${paramIndex}::date`);
params.push(dateTo);
paramIndex++;
}
if (itemCode) {
conditions.push(`p.item_code ILIKE $${paramIndex}`);
params.push(`%${itemCode}%`);
paramIndex++;
}
if (itemName) {
conditions.push(`p.item_name ILIKE $${paramIndex}`);
params.push(`%${itemName}%`);
paramIndex++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
p.id,
p.plan_no,
p.item_code,
p.item_name,
p.plan_qty,
p.completed_qty,
p.plan_date,
p.start_date,
p.end_date,
p.status,
p.work_order_no,
p.company_code
FROM production_plan_mng p
${whereClause}
ORDER BY p.plan_date DESC, p.created_date DESC
`;
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 getMaterialStatus(
req: AuthenticatedRequest,
res: Response
) {
try {
const companyCode = req.user!.companyCode;
const { planIds, warehouseCode } = req.body;
if (!planIds || !Array.isArray(planIds) || planIds.length === 0) {
return res
.status(400)
.json({ success: false, message: "작업지시를 선택해주세요." });
}
// 1) 선택된 작업지시의 품목코드 + 수량 조회
const planPlaceholders = planIds
.map((_, i) => `$${i + 1}`)
.join(",");
let paramIndex = planIds.length + 1;
const companyCondition =
companyCode === "*" ? "" : `AND p.company_code = $${paramIndex}`;
const planParams: any[] = [...planIds];
if (companyCode !== "*") {
planParams.push(companyCode);
paramIndex++;
}
const planQuery = `
SELECT p.item_code, p.item_name, p.plan_qty
FROM production_plan_mng p
WHERE p.id IN (${planPlaceholders})
${companyCondition}
`;
const planResult = await pool.query(planQuery, planParams);
if (planResult.rowCount === 0) {
return res.json({ success: true, data: [] });
}
// 2) 해당 품목들의 BOM에서 필요 자재 목록 조회
const itemCodes = planResult.rows.map((r: any) => r.item_code);
const planQtyMap: Record<string, number> = {};
for (const row of planResult.rows) {
const code = row.item_code;
planQtyMap[code] = (planQtyMap[code] || 0) + Number(row.plan_qty || 0);
}
const itemPlaceholders = itemCodes.map((_: any, i: number) => `$${i + 1}`).join(",");
// BOM 조인: bom -> bom_detail -> item_info (자재 정보)
const bomCompanyCondition =
companyCode === "*" ? "" : `AND b.company_code = $${itemCodes.length + 1}`;
const bomParams: any[] = [...itemCodes];
if (companyCode !== "*") {
bomParams.push(companyCode);
}
const bomQuery = `
SELECT
b.item_code AS parent_item_code,
b.base_qty AS bom_base_qty,
bd.child_item_id,
bd.quantity AS bom_qty,
bd.unit AS bom_unit,
bd.loss_rate,
ii.item_name AS material_name,
ii.item_number AS material_code,
ii.unit AS material_unit
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 b.company_code = ii.company_code
WHERE b.item_code IN (${itemPlaceholders})
${bomCompanyCondition}
ORDER BY b.item_code, bd.seq_no
`;
const bomResult = await pool.query(bomQuery, bomParams);
// 3) 자재별 필요수량 계산
interface MaterialNeed {
childItemId: string;
materialCode: string;
materialName: string;
unit: string;
requiredQty: number;
}
const materialMap: Record<string, MaterialNeed> = {};
for (const bomRow of bomResult.rows) {
const parentQty = planQtyMap[bomRow.parent_item_code] || 0;
const baseQty = Number(bomRow.bom_base_qty) || 1;
const bomQty = Number(bomRow.bom_qty) || 0;
const lossRate = Number(bomRow.loss_rate) || 0;
// 필요수량 = (생산수량 / BOM기준수량) * BOM자재수량 * (1 + 로스율/100)
const requiredQty =
(parentQty / baseQty) * bomQty * (1 + lossRate / 100);
const key = bomRow.child_item_id;
if (materialMap[key]) {
materialMap[key].requiredQty += requiredQty;
} else {
materialMap[key] = {
childItemId: bomRow.child_item_id,
materialCode:
bomRow.material_code || bomRow.child_item_id,
materialName: bomRow.material_name || "알 수 없음",
unit: bomRow.bom_unit || bomRow.material_unit || "EA",
requiredQty,
};
}
}
const materialIds = Object.keys(materialMap);
if (materialIds.length === 0) {
return res.json({ success: true, data: [] });
}
// 4) 재고 조회 (창고/위치별)
const stockPlaceholders = materialIds
.map((_, i) => `$${i + 1}`)
.join(",");
const stockParams: any[] = [...materialIds];
let stockParamIdx = materialIds.length + 1;
const stockConditions: string[] = [
`s.item_code IN (${stockPlaceholders})`,
];
if (companyCode !== "*") {
stockConditions.push(`s.company_code = $${stockParamIdx}`);
stockParams.push(companyCode);
stockParamIdx++;
}
if (warehouseCode) {
stockConditions.push(`s.warehouse_code = $${stockParamIdx}`);
stockParams.push(warehouseCode);
stockParamIdx++;
}
const stockQuery = `
SELECT
s.item_code,
s.warehouse_code,
s.location_code,
COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty
FROM inventory_stock s
WHERE ${stockConditions.join(" AND ")}
AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0
ORDER BY s.item_code, s.warehouse_code, s.location_code
`;
const stockResult = await pool.query(stockQuery, stockParams);
// 5) 결과 조합
// item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음)
const stockByItem: Record<
string,
{ location: string; warehouse: string; qty: number }[]
> = {};
for (const stockRow of stockResult.rows) {
const code = stockRow.item_code;
if (!stockByItem[code]) {
stockByItem[code] = [];
}
stockByItem[code].push({
location: stockRow.location_code || "",
warehouse: stockRow.warehouse_code || "",
qty: Number(stockRow.current_qty),
});
}
const resultData = materialIds.map((id) => {
const material = materialMap[id];
// inventory_stock의 item_code가 item_number 또는 child_item_id일 수 있음
const locations =
stockByItem[material.materialCode] ||
stockByItem[id] ||
[];
const totalCurrentQty = locations.reduce(
(sum, loc) => sum + loc.qty,
0
);
return {
code: material.materialCode,
name: material.materialName,
required: Math.round(material.requiredQty * 100) / 100,
current: totalCurrentQty,
unit: material.unit,
locations,
};
});
logger.info("자재현황 조회 완료", {
companyCode,
planCount: planIds.length,
materialCount: resultData.length,
});
return res.json({ success: true, data: resultData });
} 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;
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
ORDER BY warehouse_code
`;
params = [];
} else {
query = `
SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1
ORDER BY warehouse_code
`;
params = [companyCode];
}
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 });
}
}
@@ -0,0 +1,463 @@
/**
* 공정정보관리 컨트롤러
* - 공정 마스터 CRUD
* - 공정별 설비 관리
* - 품목별 라우팅 관리
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { pool } from "../database/db";
import { logger } from "../utils/logger";
// ═══════════════════════════════════════════
// 공정 마스터 CRUD
// ═══════════════════════════════════════════
export async function getProcessList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { processCode, processName, processType, useYn } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`company_code = $${idx++}`);
params.push(companyCode);
}
if (processCode) {
conditions.push(`process_code ILIKE $${idx++}`);
params.push(`%${processCode}%`);
}
if (processName) {
conditions.push(`process_name ILIKE $${idx++}`);
params.push(`%${processName}%`);
}
if (processType) {
conditions.push(`process_type = $${idx++}`);
params.push(processType);
}
if (useYn) {
conditions.push(`use_yn = $${idx++}`);
params.push(useYn);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await pool.query(
`SELECT * FROM process_mng ${where} ORDER BY process_code`,
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 createProcess(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const writer = req.user!.userId;
const { process_name, process_type, standard_time, worker_count, use_yn } = req.body;
// 공정코드 자동 채번: PROC-001, PROC-002, ...
const seqRes = await pool.query(
`SELECT process_code FROM process_mng WHERE company_code = $1 AND process_code LIKE 'PROC-%' ORDER BY process_code DESC LIMIT 1`,
[companyCode]
);
let nextNum = 1;
if (seqRes.rowCount! > 0) {
const lastCode = seqRes.rows[0].process_code;
const numPart = parseInt(lastCode.replace("PROC-", ""), 10);
if (!isNaN(numPart)) nextNum = numPart + 1;
}
const processCode = `PROC-${String(nextNum).padStart(3, "0")}`;
const result = await pool.query(
`INSERT INTO process_mng (id, company_code, process_code, process_name, process_type, standard_time, worker_count, use_yn, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[companyCode, processCode, process_name, process_type, standard_time || "0", worker_count || "0", use_yn || "Y", writer]
);
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 updateProcess(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const { process_name, process_type, standard_time, worker_count, use_yn } = req.body;
const result = await pool.query(
`UPDATE process_mng SET process_name=$1, process_type=$2, standard_time=$3, worker_count=$4, use_yn=$5, updated_date=NOW()
WHERE id=$6 AND company_code=$7 RETURNING *`,
[process_name, process_type, standard_time, worker_count, use_yn, id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." });
}
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 deleteProcesses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ success: false, message: "삭제할 공정을 선택해주세요." });
}
const placeholders = ids.map((_: any, i: number) => `$${i + 1}`).join(",");
// 설비 매핑도 삭제
await pool.query(
`DELETE FROM process_equipment WHERE process_code IN (SELECT process_code FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1})`,
[...ids, companyCode]
);
const result = await pool.query(
`DELETE FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1} RETURNING id`,
[...ids, companyCode]
);
return res.json({ success: true, deletedCount: result.rowCount });
} catch (error: any) {
logger.error("공정 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ═══════════════════════════════════════════
// 공정별 설비 관리
// ═══════════════════════════════════════════
export async function getProcessEquipments(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { processCode } = req.params;
const result = await pool.query(
`SELECT pe.*, ei.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
WHERE pe.process_code = $1 AND pe.company_code = $2
ORDER BY pe.equipment_code`,
[processCode, 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 });
}
}
export async function addProcessEquipment(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const writer = req.user!.userId;
const { process_code, equipment_code } = req.body;
const dupCheck = await pool.query(
`SELECT id FROM process_equipment WHERE process_code=$1 AND equipment_code=$2 AND company_code=$3`,
[process_code, equipment_code, companyCode]
);
if (dupCheck.rowCount! > 0) {
return res.status(400).json({ success: false, message: "이미 등록된 설비입니다." });
}
const result = await pool.query(
`INSERT INTO process_equipment (id, company_code, process_code, equipment_code, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4) RETURNING *`,
[companyCode, process_code, equipment_code, writer]
);
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 removeProcessEquipment(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await pool.query(
`DELETE FROM process_equipment WHERE id=$1 AND company_code=$2`,
[id, companyCode]
);
return res.json({ success: true });
} catch (error: any) {
logger.error("공정 설비 제거 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function getEquipmentList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const condition = companyCode === "*" ? "" : `WHERE company_code = $1`;
const params = companyCode === "*" ? [] : [companyCode];
const result = await pool.query(
`SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`,
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 getItemsForRouting(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { search } = req.query;
const conditions: string[] = ["i.company_code = rv.company_code"];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`i.company_code = $${idx++}`);
params.push(companyCode);
}
if (search) {
conditions.push(`(i.item_number ILIKE $${idx} OR i.item_name ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await pool.query(
`SELECT DISTINCT i.id, i.item_number, i.item_name, i.size, i.unit, i.type
FROM item_info i
INNER JOIN item_routing_version rv ON rv.item_code = i.item_number AND rv.company_code = i.company_code
${where}
ORDER BY i.item_number LIMIT 200`,
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 searchAllItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`company_code = $${idx++}`);
params.push(companyCode);
}
if (search) {
conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await pool.query(
`SELECT id, item_number, item_name, size, unit, type FROM item_info ${where} ORDER BY item_number LIMIT 200`,
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 getRoutingVersions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { itemCode } = req.params;
const result = await pool.query(
`SELECT * FROM item_routing_version WHERE item_code=$1 AND company_code=$2 ORDER BY created_date`,
[itemCode, 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 });
}
}
export async function createRoutingVersion(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const writer = req.user!.userId;
const { item_code, version_name, description, is_default } = req.body;
if (is_default) {
await pool.query(
`UPDATE item_routing_version SET is_default=false WHERE item_code=$1 AND company_code=$2`,
[item_code, companyCode]
);
}
const result = await pool.query(
`INSERT INTO item_routing_version (id, company_code, item_code, version_name, description, is_default, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6) RETURNING *`,
[companyCode, item_code, version_name, description || "", is_default || false, writer]
);
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 deleteRoutingVersion(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await pool.query(
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
[id, companyCode]
);
await pool.query(
`DELETE FROM item_routing_version WHERE id=$1 AND company_code=$2`,
[id, companyCode]
);
return res.json({ success: true });
} catch (error: any) {
logger.error("라우팅 버전 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function getRoutingDetails(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { versionId } = req.params;
const result = await pool.query(
`SELECT rd.*, pm.process_name
FROM item_routing_detail rd
LEFT JOIN process_mng pm ON rd.process_code = pm.process_code AND rd.company_code = pm.company_code
WHERE rd.routing_version_id=$1 AND rd.company_code=$2
ORDER BY CAST(rd.seq_no AS INTEGER)`,
[versionId, 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 });
}
}
export async function saveRoutingDetails(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const writer = req.user!.userId;
const { versionId } = req.params;
const { details } = req.body;
const client = await pool.connect();
try {
await client.query("BEGIN");
// 기존 상세 삭제 후 재입력
await client.query(
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
[versionId, companyCode]
);
for (const d of details) {
await client.query(
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", d.outsource_supplier || "", writer]
);
}
await client.query("COMMIT");
return res.json({ success: true });
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
} catch (error: any) {
logger.error("라우팅 상세 저장 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ═══════════════════════════════════════════
// BOM 구성 자재 조회 (품목코드 기반)
// ═══════════════════════════════════════════
export async function getBomMaterials(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { itemCode } = req.params;
if (!itemCode) {
return res.status(400).json({ success: false, message: "itemCode는 필수입니다" });
}
const query = `
SELECT
bd.id,
bd.child_item_id,
bd.quantity,
bd.unit as detail_unit,
bd.process_type,
i.item_name as child_item_name,
i.item_number as child_item_code,
i.type as child_item_type,
i.unit as item_unit
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info i ON bd.child_item_id = i.id AND bd.company_code = i.company_code
WHERE b.item_code = $1 AND b.company_code = $2
ORDER BY bd.seq_no ASC, bd.created_date ASC
`;
const result = await pool.query(query, [itemCode, companyCode]);
logger.info("BOM 자재 조회 성공", { companyCode, itemCode, count: result.rowCount });
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("BOM 자재 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
@@ -40,6 +40,28 @@ export async function getStockShortage(req: AuthenticatedRequest, res: Response)
}
}
// ─── 생산계획 목록 조회 ───
export async function getPlans(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { productType, status, startDate, endDate, itemCode } = req.query;
const data = await productionService.getPlans(companyCode, {
productType: productType as string,
status: status as string,
startDate: startDate as string,
endDate: endDate as string,
itemCode: itemCode as string,
});
return res.json({ success: true, data });
} catch (error: any) {
logger.error("생산계획 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 생산계획 상세 조회 ───
export async function getPlanById(req: AuthenticatedRequest, res: Response) {
@@ -0,0 +1,487 @@
/**
* 입고관리 컨트롤러
*
* 입고유형별 소스 테이블:
* - 구매입고 → purchase_order_mng (발주)
* - 반품입고 → shipment_instruction + shipment_instruction_detail (출하)
* - 기타입고 → 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 {
inbound_type,
inbound_status,
search_keyword,
date_from,
date_to,
} = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIdx = 1;
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`im.company_code = $${paramIdx}`);
params.push(companyCode);
paramIdx++;
}
if (inbound_type && inbound_type !== "all") {
conditions.push(`im.inbound_type = $${paramIdx}`);
params.push(inbound_type);
paramIdx++;
}
if (inbound_status && inbound_status !== "all") {
conditions.push(`im.inbound_status = $${paramIdx}`);
params.push(inbound_status);
paramIdx++;
}
if (search_keyword) {
conditions.push(
`(im.inbound_number ILIKE $${paramIdx} OR im.item_name ILIKE $${paramIdx} OR im.item_number ILIKE $${paramIdx} OR im.supplier_name ILIKE $${paramIdx} OR im.reference_number ILIKE $${paramIdx})`
);
params.push(`%${search_keyword}%`);
paramIdx++;
}
if (date_from) {
conditions.push(`im.inbound_date >= $${paramIdx}::date`);
params.push(date_from);
paramIdx++;
}
if (date_to) {
conditions.push(`im.inbound_date <= $${paramIdx}::date`);
params.push(date_to);
paramIdx++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
im.*,
wh.warehouse_name
FROM inbound_mng im
LEFT JOIN warehouse_info wh
ON im.warehouse_code = wh.warehouse_code
AND im.company_code = wh.company_code
${whereClause}
ORDER BY im.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, inbound_number, inbound_date, warehouse_code, location_code, inspector, manager, 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 inbound_mng (
company_code, inbound_number, inbound_type, inbound_date,
reference_number, supplier_code, supplier_name,
item_number, item_name, spec, material, unit,
inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, manager, memo,
source_table, source_id,
created_date, created_by, writer, status
) VALUES (
$1, $2, $3, $4::date,
$5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20,
$21, $22, $23,
$24, $25,
NOW(), $26, $26, '입고'
) RETURNING *`,
[
companyCode,
inbound_number || item.inbound_number,
item.inbound_type,
inbound_date || item.inbound_date,
item.reference_number || null,
item.supplier_code || null,
item.supplier_name || null,
item.item_number || null,
item.item_name || null,
item.spec || null,
item.material || null,
item.unit || "EA",
item.inbound_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.inbound_status || "대기",
item.inspection_status || "대기",
inspector || item.inspector || null,
manager || item.manager || null,
memo || item.memo || null,
item.source_table || null,
item.source_id || null,
userId,
]
);
insertedRows.push(result.rows[0]);
// 구매입고인 경우 발주의 received_qty 업데이트
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
await client.query(
`UPDATE purchase_order_mng
SET received_qty = CAST(
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text
),
remain_qty = CAST(
GREATEST(COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text
),
status = CASE
WHEN COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1
>= COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
THEN '입고완료'
ELSE '부분입고'
END,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.inbound_qty || 0, item.source_id, companyCode]
);
}
}
await client.query("COMMIT");
logger.info("입고 등록 완료", {
companyCode,
userId,
count: insertedRows.length,
inbound_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 {
inbound_date, inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, manager: mgr, memo,
} = req.body;
const pool = getPool();
const result = await pool.query(
`UPDATE inbound_mng SET
inbound_date = COALESCE($1::date, inbound_date),
inbound_qty = COALESCE($2, inbound_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),
inbound_status = COALESCE($8, inbound_status),
inspection_status = COALESCE($9, inspection_status),
inspector = COALESCE($10, inspector),
manager = COALESCE($11, manager),
memo = COALESCE($12, memo),
updated_date = NOW(),
updated_by = $13
WHERE id = $14 AND company_code = $15
RETURNING *`,
[
inbound_date, inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, 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 deleteReceiving(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM inbound_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 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(remain_qty, '') AS numeric), COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)) > 0`
);
conditions.push(`status NOT IN ('입고완료', '취소')`);
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(remain_qty, '') AS numeric),
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)
) AS remain_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 getShipments(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.ship_qty, 0) AS ship_qty,
COALESCE(sid.order_qty, 0) AS order_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 ")}
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 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 = `RCV-${yyyy}-`;
const result = await pool.query(
`SELECT inbound_number FROM inbound_mng
WHERE company_code = $1 AND inbound_number LIKE $2
ORDER BY inbound_number DESC LIMIT 1`,
[companyCode, `${prefix}%`]
);
let seq = 1;
if (result.rows.length > 0) {
const lastNo = result.rows[0].inbound_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 });
}
}
@@ -0,0 +1,161 @@
import { Response } from "express";
import { query } from "../database/db";
import { logger } from "../utils/logger";
/**
* 영업 리포트 컨트롤러
* - 수주 데이터를 기반으로 집계/분석용 원본 데이터를 반환
* - 프론트엔드에서 그룹핑/집계/필터링 처리
*/
export async function getSalesReportData(
req: any,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다" });
return;
}
const { startDate, endDate } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIdx = 1;
// 멀티테넌시: 최고관리자는 전체, 일반 회사는 자기 데이터만
if (companyCode !== "*") {
conditions.push(`som.company_code = $${paramIdx}`);
params.push(companyCode);
paramIdx++;
}
// 날짜 필터 (due_date 또는 order_date 기준)
if (startDate) {
conditions.push(
`COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) >= $${paramIdx}`
);
params.push(startDate);
paramIdx++;
}
if (endDate) {
conditions.push(
`COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) <= $${paramIdx}`
);
params.push(endDate);
paramIdx++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const dataQuery = `
SELECT
som.order_no,
COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) as date,
som.order_date,
som.partner_id,
COALESCE(cm.customer_name, som.partner_id, '미지정') as customer,
sod.part_code,
COALESCE(ii.item_name, sod.part_name, sod.part_code, '미지정') as item,
CAST(COALESCE(NULLIF(sod.qty, ''), '0') AS numeric) as "orderQty",
CAST(COALESCE(NULLIF(sod.ship_qty, ''), '0') AS numeric) as "shipQty",
CAST(COALESCE(NULLIF(sod.unit_price, ''), '0') AS numeric) as "unitPrice",
CAST(COALESCE(NULLIF(sod.amount, ''), '0') AS numeric) as "orderAmt",
1 as "orderCount",
som.status,
som.company_code
FROM sales_order_mng som
JOIN sales_order_detail sod
ON som.order_no = sod.order_no
AND som.company_code = sod.company_code
LEFT JOIN customer_mng cm
ON som.partner_id = cm.customer_code
AND som.company_code = cm.company_code
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info
ORDER BY item_number, company_code, created_date DESC
) ii
ON sod.part_code = ii.item_number
AND sod.company_code = ii.company_code
${whereClause}
ORDER BY date DESC NULLS LAST
`;
// query()는 rows 배열을 직접 반환
const dataRows = await query(dataQuery, params);
// 필터 옵션 조회 (거래처, 품목, 상태)
const filterParams: any[] = [];
let filterWhere = "";
if (companyCode !== "*") {
filterWhere = `WHERE company_code = $1`;
filterParams.push(companyCode);
}
const statusWhere = filterWhere
? `${filterWhere} AND status IS NOT NULL`
: `WHERE status IS NOT NULL`;
const [customersRows, statusRows] = await Promise.all([
query(
`SELECT DISTINCT customer_code as value, customer_name as label
FROM customer_mng ${filterWhere}
ORDER BY customer_name`,
filterParams
),
query(
`SELECT DISTINCT status as value, status as label
FROM sales_order_mng ${statusWhere}
ORDER BY status`,
filterParams
),
]);
// 품목은 데이터에서 추출 (실제 수주에 사용된 품목만)
const itemSet = new Map<string, string>();
dataRows.forEach((row: any) => {
if (row.part_code && !itemSet.has(row.part_code)) {
itemSet.set(row.part_code, row.item);
}
});
const items = Array.from(itemSet.entries()).map(([value, label]) => ({
value,
label,
}));
logger.info("영업 리포트 데이터 조회", {
companyCode,
rowCount: dataRows.length,
startDate,
endDate,
});
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
customers: customersRows,
items,
statuses: statusRows,
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("영업 리포트 데이터 조회 실패", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
success: false,
message: "영업 리포트 데이터 조회에 실패했습니다",
error: error.message,
});
}
}
@@ -0,0 +1,482 @@
/**
* 출하지시 컨트롤러 (shipment_instruction + shipment_instruction_detail)
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { numberingRuleService } from "../services/numberingRuleService";
// ─── 출하지시 목록 조회 ───
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, status, customer, keyword } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`si.company_code = $${idx}`);
params.push(companyCode);
idx++;
}
if (dateFrom) {
conditions.push(`si.instruction_date >= $${idx}::date`);
params.push(dateFrom);
idx++;
}
if (dateTo) {
conditions.push(`si.instruction_date <= $${idx}::date`);
params.push(dateTo);
idx++;
}
if (status) {
conditions.push(`si.status = $${idx}`);
params.push(status);
idx++;
}
if (customer) {
conditions.push(`(c.customer_name ILIKE $${idx} OR si.partner_id ILIKE $${idx})`);
params.push(`%${customer}%`);
idx++;
}
if (keyword) {
conditions.push(`(si.instruction_no ILIKE $${idx} OR si.memo ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
si.*,
COALESCE(c.customer_name, si.partner_id, '') AS customer_name,
COALESCE(
json_agg(
json_build_object(
'id', sid.id,
'item_code', sid.item_code,
'item_name', COALESCE(i.item_name, sid.item_name, sid.item_code),
'spec', sid.spec,
'material', sid.material,
'order_qty', sid.order_qty,
'plan_qty', sid.plan_qty,
'ship_qty', sid.ship_qty,
'source_type', sid.source_type,
'shipment_plan_id', sid.shipment_plan_id,
'sales_order_id', sid.sales_order_id,
'detail_id', sid.detail_id
)
) FILTER (WHERE sid.id IS NOT NULL),
'[]'
) AS items
FROM shipment_instruction si
LEFT JOIN customer_mng c
ON si.partner_id = c.customer_code AND si.company_code = c.company_code
LEFT JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id AND si.company_code = sid.company_code
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = sid.item_code AND company_code = si.company_code
LIMIT 1
) i ON true
${where}
GROUP BY si.id, c.customer_name
ORDER BY si.created_date DESC
`;
const pool = getPool();
const result = await pool.query(query, params);
logger.info("출하지시 목록 조회", { companyCode, count: 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 previewNextNo(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
let instructionNo: string;
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode, "shipment_instruction", "instruction_no"
);
if (rule) {
instructionNo = await numberingRuleService.previewCode(
rule.ruleId, companyCode, {}
);
} else {
throw new Error("채번 규칙 없음");
}
} catch {
const pool = getPool();
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
const seqRes = await pool.query(
`SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`,
[companyCode, `SI-${today}-%`]
);
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
instructionNo = `SI-${today}-${seq}`;
}
return res.json({ success: true, instructionNo });
} catch (error: any) {
logger.error("출하지시번호 미리보기 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 출하지시 저장 (신규/수정) ───
export async function save(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
id: editId,
instructionDate,
partnerId,
status: orderStatus,
memo,
carrierName,
vehicleNo,
driverName,
driverContact,
arrivalTime,
deliveryAddress,
items,
} = req.body;
if (!instructionDate) {
return res.status(400).json({ success: false, message: "출하지시일은 필수입니다" });
}
if (!items || items.length === 0) {
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
}
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
let instructionId: number;
let instructionNo: string;
if (editId) {
// 수정
const check = await client.query(
`SELECT id, instruction_no FROM shipment_instruction WHERE id = $1 AND company_code = $2`,
[editId, companyCode]
);
if (check.rowCount === 0) {
throw new Error("출하지시를 찾을 수 없습니다");
}
instructionId = editId;
instructionNo = check.rows[0].instruction_no;
await client.query(
`UPDATE shipment_instruction SET
instruction_date = $1::date, partner_id = $2, status = $3, memo = $4,
carrier_name = $5, vehicle_no = $6, driver_name = $7, driver_contact = $8,
arrival_time = $9, delivery_address = $10,
updated_date = NOW(), updated_by = $11
WHERE id = $12 AND company_code = $13`,
[
instructionDate, partnerId, orderStatus || "READY", memo,
carrierName, vehicleNo, driverName, driverContact,
arrivalTime || null, deliveryAddress,
userId, editId, companyCode,
]
);
// 기존 디테일 삭제 후 재삽입
await client.query(
`DELETE FROM shipment_instruction_detail WHERE instruction_id = $1 AND company_code = $2`,
[editId, companyCode]
);
} else {
// 신규 - 채번 규칙이 있으면 사용, 없으면 자체 생성
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode, "shipment_instruction", "instruction_no"
);
if (rule) {
instructionNo = await numberingRuleService.allocateCode(
rule.ruleId, companyCode, { instruction_date: instructionDate }
);
logger.info("채번 규칙으로 출하지시번호 생성", { ruleId: rule.ruleId, instructionNo });
} else {
throw new Error("채번 규칙 없음 - 폴백");
}
} catch {
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
const seqRes = await client.query(
`SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`,
[companyCode, `SI-${today}-%`]
);
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
instructionNo = `SI-${today}-${seq}`;
logger.info("폴백으로 출하지시번호 생성", { instructionNo });
}
const insertRes = await client.query(
`INSERT INTO shipment_instruction
(company_code, instruction_no, instruction_date, partner_id, status, memo,
carrier_name, vehicle_no, driver_name, driver_contact, arrival_time, delivery_address,
created_date, created_by)
VALUES ($1, $2, $3::date, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), $13)
RETURNING id`,
[
companyCode, instructionNo, instructionDate, partnerId,
orderStatus || "READY", memo,
carrierName, vehicleNo, driverName, driverContact,
arrivalTime || null, deliveryAddress, userId,
]
);
instructionId = insertRes.rows[0].id;
}
// 디테일 삽입
for (const item of items) {
await client.query(
`INSERT INTO shipment_instruction_detail
(company_code, instruction_id, shipment_plan_id, sales_order_id, detail_id,
item_code, item_name, spec, material, order_qty, plan_qty, ship_qty,
source_type, created_date, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14)`,
[
companyCode, instructionId,
item.shipmentPlanId || null, item.salesOrderId || null, item.detailId || null,
item.itemCode, item.itemName, item.spec, item.material,
item.orderQty || 0, item.planQty || 0, item.shipQty || 0,
item.sourceType || "shipmentPlan", userId,
]
);
}
await client.query("COMMIT");
logger.info("출하지시 저장 완료", { companyCode, instructionId, instructionNo, itemCount: items.length });
return res.json({ success: true, data: { id: instructionId, instructionNo } });
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("출하지시 저장 실패", { error: error.message, stack: error.stack });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 출하지시 삭제 ───
export async function remove(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ success: false, message: "삭제할 ID가 필요합니다" });
}
const pool = getPool();
// CASCADE로 디테일도 자동 삭제
const result = await pool.query(
`DELETE FROM shipment_instruction WHERE id = ANY($1::int[]) AND company_code = $2 RETURNING id`,
[ids, companyCode]
);
logger.info("출하지시 삭제", { companyCode, deletedCount: result.rowCount });
return res.json({ success: true, deletedCount: result.rowCount });
} catch (error: any) {
logger.error("출하지시 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 출하계획 목록 (모달 왼쪽 패널용) ───
export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query;
const page = Math.max(1, parseInt(pageStr as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
const offset = (page - 1) * pageSize;
const conditions = ["sp.company_code = $1", "sp.status = 'READY'"];
const params: any[] = [companyCode];
let idx = 2;
if (keyword) {
conditions.push(`(COALESCE(d.part_code, m.part_code, '') ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
if (customer) {
conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${idx})`);
params.push(`%${customer}%`);
idx++;
}
const whereClause = conditions.join(" AND ");
const fromClause = `
FROM shipment_plan sp
LEFT JOIN sales_order_detail d ON sp.detail_id = d.id AND sp.company_code = d.company_code
LEFT JOIN sales_order_mng m ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = COALESCE(d.part_code, m.part_code) AND company_code = sp.company_code
LIMIT 1
) i ON true
LEFT JOIN customer_mng c
ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code AND sp.company_code = c.company_code
WHERE ${whereClause}
`;
const pool = getPool();
const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
const totalCount = parseInt(countResult.rows[0].total);
const query = `
SELECT
sp.id, sp.plan_qty, sp.plan_date, sp.status, sp.shipment_plan_no,
COALESCE(m.order_no, d.order_no, '') AS order_no,
COALESCE(d.part_code, m.part_code, '') AS item_code,
COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS item_name,
COALESCE(d.spec, m.spec, '') AS spec,
COALESCE(m.material, '') AS material,
COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name,
COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code,
sp.detail_id, sp.sales_order_id
${fromClause}
ORDER BY sp.created_date DESC
LIMIT $${idx} OFFSET $${idx + 1}
`;
params.push(pageSize, offset);
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
} catch (error: any) {
logger.error("출하계획 소스 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 수주 목록 (모달 왼쪽 패널용) ───
export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query;
const page = Math.max(1, parseInt(pageStr as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
const offset = (page - 1) * pageSize;
const conditions = ["d.company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (keyword) {
conditions.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
if (customer) {
conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(d.delivery_partner_code, m.partner_id, '') ILIKE $${idx})`);
params.push(`%${customer}%`);
idx++;
}
const whereClause = conditions.join(" AND ");
const fromClause = `
FROM sales_order_detail d
LEFT JOIN sales_order_mng m ON d.order_no = m.order_no AND d.company_code = m.company_code
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = d.part_code AND company_code = d.company_code
LIMIT 1
) i ON true
LEFT JOIN customer_mng c
ON COALESCE(d.delivery_partner_code, m.partner_id) = c.customer_code AND d.company_code = c.company_code
WHERE ${whereClause}
`;
const pool = getPool();
const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
const totalCount = parseInt(countResult.rows[0].total);
const query = `
SELECT
d.id, d.order_no, d.part_code AS item_code,
COALESCE(i.item_name, d.part_name, d.part_code) AS item_name,
COALESCE(d.spec, '') AS spec, COALESCE(m.material, '') AS material,
COALESCE(NULLIF(d.qty,'')::numeric, 0) AS qty,
COALESCE(NULLIF(d.balance_qty,'')::numeric, 0) AS balance_qty,
COALESCE(c.customer_name, COALESCE(d.delivery_partner_code, m.partner_id, '')) AS customer_name,
COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code,
m.id AS master_id
${fromClause}
ORDER BY d.created_date DESC
LIMIT $${idx} OFFSET $${idx + 1}
`;
params.push(pageSize, offset);
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
} catch (error: any) {
logger.error("수주 소스 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 품목 목록 (모달 왼쪽 패널용) ───
export async function getItemSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page: pageStr, pageSize: pageSizeStr } = req.query;
const page = Math.max(1, parseInt(pageStr as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
const offset = (page - 1) * pageSize;
const conditions = ["company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (keyword) {
conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
const whereClause = conditions.join(" AND ");
const pool = getPool();
const countResult = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`, params);
const totalCount = parseInt(countResult.rows[0].total);
const query = `
SELECT
item_number AS item_code, item_name,
COALESCE(size, '') AS spec, COALESCE(material, '') AS material
FROM item_info
WHERE ${whereClause}
ORDER BY item_name
LIMIT $${idx} OFFSET $${idx + 1}
`;
params.push(pageSize, offset);
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
} catch (error: any) {
logger.error("품목 소스 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
@@ -144,6 +144,218 @@ async function getNormalizedOrders(
}
}
// ─── 출하계획 목록 조회 (관리 화면용) ───
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, status, customer, keyword } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
// 멀티테넌시
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`sp.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
if (dateFrom) {
conditions.push(`sp.plan_date >= $${paramIndex}::date`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
conditions.push(`sp.plan_date <= $${paramIndex}::date`);
params.push(dateTo);
paramIndex++;
}
if (status) {
conditions.push(`sp.status = $${paramIndex}`);
params.push(status);
paramIndex++;
}
if (customer) {
conditions.push(`(c.customer_name ILIKE $${paramIndex} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${paramIndex})`);
params.push(`%${customer}%`);
paramIndex++;
}
if (keyword) {
conditions.push(`(
COALESCE(m.order_no, d.order_no, '') ILIKE $${paramIndex}
OR COALESCE(d.part_code, m.part_code, '') ILIKE $${paramIndex}
OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${paramIndex}
OR sp.shipment_plan_no ILIKE $${paramIndex}
)`);
params.push(`%${keyword}%`);
paramIndex++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
sp.id,
sp.plan_date,
sp.plan_qty,
sp.status,
sp.memo,
sp.shipment_plan_no,
sp.created_date,
sp.created_by,
sp.detail_id,
sp.sales_order_id,
sp.remain_qty,
COALESCE(m.order_no, d.order_no, '') AS order_no,
COALESCE(d.part_code, m.part_code, '') AS part_code,
COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS part_name,
COALESCE(d.spec, m.spec, '') AS spec,
COALESCE(m.material, '') AS material,
COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name,
COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code,
COALESCE(d.due_date, m.due_date::text, '') AS due_date,
COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty,
COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS shipped_qty
FROM shipment_plan sp
LEFT JOIN sales_order_detail d
ON sp.detail_id = d.id AND sp.company_code = d.company_code
LEFT JOIN sales_order_mng m
ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = COALESCE(d.part_code, m.part_code)
AND company_code = sp.company_code
LIMIT 1
) i ON true
LEFT JOIN customer_mng c
ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code
AND sp.company_code = c.company_code
${whereClause}
ORDER BY sp.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,
stack: error.stack,
});
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 출하계획 단건 수정 ───
export async function updatePlan(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
const { planQty, planDate, memo } = req.body;
const pool = getPool();
const check = await pool.query(
`SELECT id, status FROM shipment_plan WHERE id = $1 AND company_code = $2`,
[id, companyCode]
);
if (check.rowCount === 0) {
return res.status(404).json({ success: false, message: "출하계획을 찾을 수 없습니다" });
}
const setClauses: string[] = [];
const updateParams: any[] = [];
let idx = 1;
if (planQty !== undefined) {
setClauses.push(`plan_qty = $${idx}`);
updateParams.push(planQty);
idx++;
}
if (planDate !== undefined) {
setClauses.push(`plan_date = $${idx}::date`);
updateParams.push(planDate);
idx++;
}
if (memo !== undefined) {
setClauses.push(`memo = $${idx}`);
updateParams.push(memo);
idx++;
}
setClauses.push(`updated_date = NOW()`);
setClauses.push(`updated_by = $${idx}`);
updateParams.push(userId);
idx++;
updateParams.push(id);
updateParams.push(companyCode);
const updateQuery = `
UPDATE shipment_plan
SET ${setClauses.join(", ")}
WHERE id = $${idx - 1} AND company_code = $${idx}
RETURNING *
`;
// 파라미터 인덱스 수정
const finalParams: any[] = [];
let pIdx = 1;
const setClausesFinal: string[] = [];
if (planQty !== undefined) {
setClausesFinal.push(`plan_qty = $${pIdx}`);
finalParams.push(planQty);
pIdx++;
}
if (planDate !== undefined) {
setClausesFinal.push(`plan_date = $${pIdx}::date`);
finalParams.push(planDate);
pIdx++;
}
if (memo !== undefined) {
setClausesFinal.push(`memo = $${pIdx}`);
finalParams.push(memo);
pIdx++;
}
setClausesFinal.push(`updated_date = NOW()`);
setClausesFinal.push(`updated_by = $${pIdx}`);
finalParams.push(userId);
pIdx++;
finalParams.push(id);
finalParams.push(companyCode);
const result = await pool.query(
`UPDATE shipment_plan
SET ${setClausesFinal.join(", ")}
WHERE id = $${pIdx} AND company_code = $${pIdx + 1}
RETURNING *`,
finalParams
);
logger.info("출하계획 수정", { companyCode, planId: id, userId });
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 getAggregate(req: AuthenticatedRequest, res: Response) {
@@ -333,8 +545,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
const savedPlans = [];
for (const plan of plans) {
const { sourceId, planQty } = plan;
const { sourceId, planQty, planDate } = plan;
if (!sourceId || !planQty || planQty <= 0) continue;
const planDateValue = planDate || null;
if (detectedSource === "detail") {
// 디테일 소스: detail_id로 저장
@@ -368,9 +581,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
const insertRes = await client.query(
`INSERT INTO shipment_plan
(company_code, detail_id, sales_order_id, plan_qty, plan_date, status, created_by)
VALUES ($1, $2, $3, $4, CURRENT_DATE, 'READY', $5)
VALUES ($1, $2, $3, $4, COALESCE($5::date, CURRENT_DATE), 'READY', $6)
RETURNING *`,
[companyCode, sourceId, detail.master_id, planQty, userId]
[companyCode, sourceId, detail.master_id, planQty, planDateValue, userId]
);
savedPlans.push(insertRes.rows[0]);
@@ -410,9 +623,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
const insertRes = await client.query(
`INSERT INTO shipment_plan
(company_code, sales_order_id, plan_qty, plan_date, status, created_by)
VALUES ($1, $2, $3, CURRENT_DATE, 'READY', $4)
VALUES ($1, $2, $3, COALESCE($4::date, CURRENT_DATE), 'READY', $5)
RETURNING *`,
[companyCode, masterId, planQty, userId]
[companyCode, masterId, planQty, planDateValue, userId]
);
savedPlans.push(insertRes.rows[0]);
@@ -0,0 +1,650 @@
/**
* 작업지시 컨트롤러 (work_instruction + work_instruction_detail)
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { numberingRuleService } from "../services/numberingRuleService";
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`wi.company_code = $${idx}`);
params.push(companyCode);
idx++;
}
if (dateFrom) {
conditions.push(`wi.start_date >= $${idx}`);
params.push(dateFrom);
idx++;
}
if (dateTo) {
conditions.push(`wi.end_date <= $${idx}`);
params.push(dateTo);
idx++;
}
if (status && status !== "all") {
conditions.push(`wi.status = $${idx}`);
params.push(status);
idx++;
}
if (progressStatus && progressStatus !== "all") {
conditions.push(`wi.progress_status = $${idx}`);
params.push(progressStatus);
idx++;
}
if (keyword) {
conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
wi.id AS wi_id,
wi.work_instruction_no,
wi.status,
wi.progress_status,
wi.qty AS total_qty,
wi.completed_qty,
wi.start_date,
wi.end_date,
wi.equipment_id,
wi.work_team,
wi.worker,
wi.remark AS wi_remark,
wi.created_date,
d.id AS detail_id,
d.item_number,
d.qty AS detail_qty,
d.remark AS detail_remark,
d.part_code,
d.source_table,
d.source_id,
COALESCE(itm.item_name, '') AS item_name,
COALESCE(itm.size, '') AS item_spec,
COALESCE(e.equipment_name, '') AS equipment_name,
COALESCE(e.equipment_code, '') AS equipment_code,
wi.routing AS routing_version_id,
COALESCE(rv.version_name, '') AS routing_name,
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq,
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
FROM work_instruction wi
INNER JOIN work_instruction_detail d
ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code
LEFT JOIN LATERAL (
SELECT item_name, size FROM item_info
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
) itm ON true
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code
${whereClause}
ORDER BY wi.created_date DESC, d.created_date ASC
`;
const pool = getPool();
const result = await pool.query(query, 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 previewNextNo(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
let wiNo: string;
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no");
if (rule) {
wiNo = await numberingRuleService.previewCode(rule.ruleId, companyCode, {});
} else { throw new Error("채번 규칙 없음"); }
} catch {
const pool = getPool();
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
const seqRes = await pool.query(
`SELECT COUNT(*) + 1 AS seq FROM work_instruction WHERE company_code = $1 AND work_instruction_no LIKE $2`,
[companyCode, `WI-${today}-%`]
);
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
}
return res.json({ success: true, instructionNo: wiNo });
} catch (error: any) {
logger.error("작업지시번호 미리보기 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 저장 (신규/수정) ───
export async function save(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body;
if (!items || items.length === 0) {
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
}
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
let wiId: string;
let wiNo: string;
if (editId) {
const check = await client.query(`SELECT id, work_instruction_no FROM work_instruction WHERE id = $1 AND company_code = $2`, [editId, companyCode]);
if (check.rowCount === 0) throw new Error("작업지시를 찾을 수 없습니다");
wiId = editId;
wiNo = check.rows[0].work_instruction_no;
await client.query(
`UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, updated_date=NOW(), writer=$11 WHERE id=$12 AND company_code=$13`,
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId, editId, companyCode]
);
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]);
} else {
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no");
if (rule) { wiNo = await numberingRuleService.allocateCode(rule.ruleId, companyCode, {}); }
else { throw new Error("채번 규칙 없음 - 폴백"); }
} catch {
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
const seqRes = await client.query(`SELECT COUNT(*)+1 AS seq FROM work_instruction WHERE company_code=$1 AND work_instruction_no LIKE $2`, [companyCode, `WI-${today}-%`]);
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
}
const insertRes = await client.query(
`INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,routing,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),$13) RETURNING id`,
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId]
);
wiId = insertRes.rows[0].id;
}
for (const item of items) {
await client.query(
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9)`,
[companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", userId]
);
}
await client.query("COMMIT");
return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } });
} catch (txErr) { await client.query("ROLLBACK"); throw txErr; }
finally { client.release(); }
} catch (error: any) {
logger.error("작업지시 저장 실패", { error: error.message, stack: error.stack });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 삭제 ───
export async function remove(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { ids } = req.body;
if (!ids || ids.length === 0) return res.status(400).json({ success: false, message: "삭제할 항목을 선택해주세요" });
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const wiNos = await client.query(`SELECT work_instruction_no FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]);
for (const row of wiNos.rows) {
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [row.work_instruction_no, companyCode]);
}
const result = await client.query(`DELETE FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]);
await client.query("COMMIT");
return res.json({ success: true, deletedCount: result.rowCount });
} catch (txErr) { await client.query("ROLLBACK"); throw txErr; }
finally { client.release(); }
} catch (error: any) {
logger.error("작업지시 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 품목 소스 (페이징) ───
export async function getItemSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page: ps, pageSize: pss } = req.query;
const page = Math.max(1, parseInt(ps as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
const offset = (page - 1) * pageSize;
const conds = ["company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
if (keyword) { conds.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
const w = conds.join(" AND ");
const pool = getPool();
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${w}`, params);
params.push(pageSize, offset);
const rows = await pool.query(`SELECT id, item_number AS item_code, item_name, COALESCE(size,'') AS spec FROM item_info WHERE ${w} ORDER BY item_name LIMIT $${idx} OFFSET $${idx+1}`, params);
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
}
// ─── 수주 소스 (페이징) ───
export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page: ps, pageSize: pss } = req.query;
const page = Math.max(1, parseInt(ps as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
const offset = (page - 1) * pageSize;
const conds = ["d.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
if (keyword) { conds.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
const fromClause = `FROM sales_order_detail d LEFT JOIN LATERAL (SELECT item_name FROM item_info WHERE item_number = d.part_code AND company_code = d.company_code LIMIT 1) i ON true WHERE ${conds.join(" AND ")}`;
const pool = getPool();
const cnt = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
params.push(pageSize, offset);
const rows = await pool.query(`SELECT d.id, d.order_no, d.part_code AS item_code, COALESCE(i.item_name, d.part_name, d.part_code) AS item_name, COALESCE(d.spec,'') AS spec, COALESCE(NULLIF(d.qty,'')::numeric,0) AS qty, d.due_date ${fromClause} ORDER BY d.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
}
// ─── 생산계획 소스 (페이징) ───
export async function getProductionPlanSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page: ps, pageSize: pss } = req.query;
const page = Math.max(1, parseInt(ps as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
const offset = (page - 1) * pageSize;
const conds = ["p.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
if (keyword) { conds.push(`(p.plan_no ILIKE $${idx} OR p.item_code ILIKE $${idx} OR COALESCE(p.item_name,'') ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
const w = conds.join(" AND ");
const pool = getPool();
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params);
params.push(pageSize, offset);
const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
}
// ─── 사원 목록 (작업자 Select용) ───
export async function getEmployeeList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
let query: string;
let params: any[];
if (companyCode !== "*") {
query = `SELECT user_id, user_name, dept_name FROM user_info WHERE company_code = $1 AND company_code != '*' ORDER BY user_name`;
params = [companyCode];
} else {
query = `SELECT user_id, user_name, dept_name, company_code FROM user_info WHERE company_code != '*' ORDER BY user_name`;
params = [];
}
const result = await pool.query(query, 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 });
}
}
// ─── 설비 목록 (Select용) ───
export async function getEquipmentList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const cond = companyCode !== "*" ? "WHERE company_code = $1" : "";
const params = companyCode !== "*" ? [companyCode] : [];
const result = await pool.query(`SELECT id, equipment_code, equipment_name FROM equipment_mng ${cond} ORDER BY equipment_name`, params);
return res.json({ success: true, data: result.rows });
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
}
// ─── 품목의 라우팅 버전 + 공정 조회 ───
export async function getRoutingVersions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { itemCode } = req.params;
const pool = getPool();
const versionsResult = await pool.query(
`SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
FROM item_routing_version
WHERE item_code = $1 AND company_code = $2
ORDER BY is_default DESC, created_date DESC`,
[itemCode, companyCode]
);
const routings = [];
for (const version of versionsResult.rows) {
const detailsResult = await pool.query(
`SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code,
rd.is_required, rd.work_type,
COALESCE(p.process_name, rd.process_code) AS process_name
FROM item_routing_detail rd
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
ORDER BY rd.seq_no::integer`,
[version.id, companyCode]
);
routings.push({ ...version, processes: detailsResult.rows });
}
return res.json({ success: true, data: routings });
} catch (error: any) {
logger.error("라우팅 버전 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 라우팅 변경 ───
export async function updateRouting(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { wiNo } = req.params;
const { routingVersionId } = req.body;
const pool = getPool();
await pool.query(
`UPDATE work_instruction SET routing = $1, updated_date = NOW() WHERE work_instruction_no = $2 AND company_code = $3`,
[routingVersionId || null, wiNo, companyCode]
);
return res.json({ success: true });
} catch (error: any) {
logger.error("라우팅 변경 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 전용 공정작업기준 조회 ───
export async function getWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { wiNo } = req.params;
const { routingVersionId } = req.query;
const pool = getPool();
if (!routingVersionId) {
return res.status(400).json({ success: false, message: "routingVersionId 필요" });
}
// 라우팅 디테일(공정) 목록 조회
const processesResult = await pool.query(
`SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code,
COALESCE(p.process_name, rd.process_code) AS process_name
FROM item_routing_detail rd
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
ORDER BY rd.seq_no::integer`,
[routingVersionId, companyCode]
);
// 커스텀 작업기준이 있는지 확인
const customCheck = await pool.query(
`SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
[wiNo, companyCode]
);
const hasCustom = parseInt(customCheck.rows[0].cnt) > 0;
const processes = [];
for (const proc of processesResult.rows) {
let workItems;
if (hasCustom) {
// 커스텀 버전에서 조회
const wiResult = await pool.query(
`SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description,
(SELECT COUNT(*) FROM wi_process_work_item_detail d WHERE d.wi_work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count
FROM wi_process_work_item wi
WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3
ORDER BY wi.work_phase, wi.sort_order`,
[wiNo, proc.routing_detail_id, companyCode]
);
workItems = wiResult.rows;
// 각 work_item의 상세도 로드
for (const wi of workItems) {
const detailsResult = await pool.query(
`SELECT id, wi_work_item_id AS work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields
FROM wi_process_work_item_detail
WHERE wi_work_item_id = $1 AND company_code = $2
ORDER BY sort_order`,
[wi.id, companyCode]
);
wi.details = detailsResult.rows;
}
} else {
// 원본에서 조회
const origResult = await pool.query(
`SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description,
(SELECT COUNT(*) FROM process_work_item_detail d WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count
FROM process_work_item wi
WHERE wi.routing_detail_id = $1 AND wi.company_code = $2
ORDER BY wi.work_phase, wi.sort_order`,
[proc.routing_detail_id, companyCode]
);
workItems = origResult.rows;
for (const wi of workItems) {
const detailsResult = await pool.query(
`SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields
FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order`,
[wi.id, companyCode]
);
wi.details = detailsResult.rows;
}
}
processes.push({
...proc,
workItems,
});
}
return res.json({ success: true, data: { processes, isCustom: hasCustom } });
} catch (error: any) {
logger.error("작업지시 공정작업기준 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 원본 공정작업기준 -> 작업지시 전용 복사 ───
export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { wiNo } = req.params;
const { routingVersionId } = req.body;
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 기존 커스텀 데이터 삭제
const existingItems = await client.query(
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
[wiNo, companyCode]
);
for (const row of existingItems.rows) {
await client.query(
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
[row.id, companyCode]
);
}
await client.query(
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
[wiNo, companyCode]
);
// 라우팅 디테일 목록 조회
const routingDetails = await client.query(
`SELECT id FROM item_routing_detail WHERE routing_version_id = $1 AND company_code = $2`,
[routingVersionId, companyCode]
);
// 각 공정(routing_detail)별 원본 작업항목 복사
for (const rd of routingDetails.rows) {
const origItems = await client.query(
`SELECT * FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2`,
[rd.id, companyCode]
);
for (const origItem of origItems.rows) {
const newItemResult = await client.query(
`INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[companyCode, wiNo, rd.id, origItem.work_phase, origItem.title, origItem.is_required, origItem.sort_order, origItem.description, origItem.id, userId]
);
const newItemId = newItemResult.rows[0].id;
// 상세 복사
const origDetails = await client.query(
`SELECT * FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2`,
[origItem.id, companyCode]
);
for (const origDetail of origDetails.rows) {
await client.query(
`INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, userId]
);
}
}
}
await client.query("COMMIT");
logger.info("공정작업기준 복사 완료", { companyCode, wiNo, routingVersionId });
return res.json({ success: true });
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("공정작업기준 복사 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 전용 공정작업기준 저장 (일괄) ───
export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { wiNo } = req.params;
const { routingDetailId, workItems } = req.body;
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 해당 공정의 기존 커스텀 데이터 삭제
const existing = await client.query(
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`,
[wiNo, routingDetailId, companyCode]
);
for (const row of existing.rows) {
await client.query(
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
[row.id, companyCode]
);
}
await client.query(
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`,
[wiNo, routingDetailId, companyCode]
);
// 새 데이터 삽입
for (const wi of workItems) {
const wiResult = await client.query(
`INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[companyCode, wiNo, routingDetailId, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description || null, wi.source_work_item_id || null, userId]
);
const newId = wiResult.rows[0].id;
if (wi.details && Array.isArray(wi.details)) {
for (const d of wi.details) {
await client.query(
`INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, userId]
);
}
}
}
await client.query("COMMIT");
logger.info("작업지시 공정작업기준 저장 완료", { companyCode, wiNo, routingDetailId });
return res.json({ success: true });
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("작업지시 공정작업기준 저장 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 전용 커스텀 데이터 삭제 (원본으로 초기화) ───
export async function resetWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { wiNo } = req.params;
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const items = await client.query(
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
[wiNo, companyCode]
);
for (const row of items.rows) {
await client.query(
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
[row.id, companyCode]
);
}
await client.query(
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
[wiNo, companyCode]
);
await client.query("COMMIT");
logger.info("작업지시 공정작업기준 초기화", { companyCode, wiNo });
return res.json({ success: true });
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("작업지시 공정작업기준 초기화 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
@@ -0,0 +1,23 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
getProductionReportData,
getInventoryReportData,
getPurchaseReportData,
getQualityReportData,
getEquipmentReportData,
getMoldReportData,
} from "../controllers/analyticsReportController";
const router = Router();
router.use(authenticateToken);
router.get("/production/data", getProductionReportData);
router.get("/inventory/data", getInventoryReportData);
router.get("/purchase/data", getPurchaseReportData);
router.get("/quality/data", getQualityReportData);
router.get("/equipment/data", getEquipmentReportData);
router.get("/mold/data", getMoldReportData);
export default router;
@@ -7,6 +7,19 @@ import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
/**
* GET /api/batch-management/stats
* 배치 대시보드 통계 (전체/활성 배치 수, 오늘·어제 실행/실패 수)
* 반드시 /batch-configs 보다 위에 등록 (/:id로 잡히지 않도록)
*/
router.get("/stats", authenticateToken, BatchManagementController.getBatchStats);
/**
* GET /api/batch-management/node-flows
* 배치 설정에서 노드 플로우 선택용 목록 조회
*/
router.get("/node-flows", authenticateToken, BatchManagementController.getNodeFlows);
/**
* GET /api/batch-management/connections
* 사용 가능한 커넥션 목록 조회
@@ -55,6 +68,18 @@ router.get("/batch-configs", authenticateToken, BatchManagementController.getBat
*/
router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById);
/**
* GET /api/batch-management/batch-configs/:id/sparkline
* 해당 배치 최근 24시간 1시간 단위 실행 집계
*/
router.get("/batch-configs/:id/sparkline", authenticateToken, BatchManagementController.getBatchSparkline);
/**
* GET /api/batch-management/batch-configs/:id/recent-logs
* 해당 배치 최근 실행 로그 (최대 20건)
*/
router.get("/batch-configs/:id/recent-logs", authenticateToken, BatchManagementController.getBatchRecentLogs);
/**
* PUT /api/batch-management/batch-configs/:id
* 배치 설정 업데이트
+139 -38
View File
@@ -4,6 +4,7 @@ import { masterDetailExcelService } from "../services/masterDetailExcelService";
import { multiTableExcelService, TableChainConfig } from "../services/multiTableExcelService";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { auditLogService } from "../services/auditLogService";
import { TableManagementService } from "../services/tableManagementService";
import { formatPgError } from "../utils/pgErrorUtil";
@@ -35,7 +36,7 @@ router.get(
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
parseInt(screenId),
);
if (!relation) {
@@ -64,7 +65,7 @@ router.get(
error: error.message,
});
}
}
},
);
/**
@@ -90,7 +91,7 @@ router.post(
// 1. 마스터-디테일 관계 조회
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
parseInt(screenId),
);
if (!relation) {
@@ -104,7 +105,7 @@ router.post(
const data = await masterDetailExcelService.getJoinedData(
relation,
companyCode,
filters
filters,
);
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}`);
@@ -121,7 +122,7 @@ router.post(
error: error.message,
});
}
}
},
);
/**
@@ -144,11 +145,13 @@ router.post(
});
}
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
console.log(
`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`,
);
// 1. 마스터-디테일 관계 조회
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
parseInt(screenId),
);
if (!relation) {
@@ -163,7 +166,7 @@ router.post(
relation,
data,
companyCode,
userId
userId,
);
console.log(`✅ 마스터-디테일 업로드 완료:`, {
@@ -194,7 +197,7 @@ router.post(
error: error.message,
});
}
}
},
);
/**
@@ -202,7 +205,7 @@ router.post(
* - 마스터 정보는 UI에서 선택
* - 디테일 정보만 엑셀에서 업로드
* - 채번 규칙을 통해 마스터 키 자동 생성
*
*
* POST /api/data/master-detail/upload-simple
*/
router.post(
@@ -210,7 +213,14 @@ router.post(
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
const {
screenId,
detailData,
masterFieldValues,
numberingRuleId,
afterUploadFlowId,
afterUploadFlows,
} = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
@@ -221,10 +231,17 @@ router.post(
});
}
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
console.log(
`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`,
);
console.log(` 마스터 필드 값:`, masterFieldValues);
console.log(` 채번 규칙 ID:`, numberingRuleId);
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}` : afterUploadFlowId || "없음");
console.log(
` 업로드 후 제어:`,
afterUploadFlows?.length > 0
? `${afterUploadFlows.length}`
: afterUploadFlowId || "없음",
);
// 업로드 실행
const result = await masterDetailExcelService.uploadSimple(
@@ -235,7 +252,7 @@ router.post(
companyCode,
userId,
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
afterUploadFlows // 업로드 후 제어 실행 (다중)
afterUploadFlows, // 업로드 후 제어 실행 (다중)
);
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
@@ -260,7 +277,7 @@ router.post(
error: error.message,
});
}
}
},
);
// ================================
@@ -504,7 +521,7 @@ router.get(
parsedDataFilter,
enableEntityJoinFlag,
parsedDisplayColumns, // 🆕 표시 컬럼 전달
parsedDeduplication // 🆕 중복 제거 설정 전달
parsedDeduplication, // 🆕 중복 제거 설정 전달
);
if (!result.success) {
@@ -512,7 +529,7 @@ router.get(
}
console.log(
`✅ 조인 데이터 조회 성공: ${result.data?.length || 0}개 항목`
`✅ 조인 데이터 조회 성공: ${result.data?.length || 0}개 항목`,
);
return res.json({
@@ -527,7 +544,7 @@ router.get(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
@@ -616,7 +633,7 @@ router.get(
}
console.log(
`✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목`
`✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목`,
);
// 페이징 정보 포함하여 반환
@@ -642,7 +659,7 @@ router.get(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
@@ -684,7 +701,7 @@ router.get(
}
console.log(
`✅ 컬럼 정보 조회 성공: ${tableName}, ${result.data?.length || 0}개 컬럼`
`✅ 컬럼 정보 조회 성공: ${tableName}, ${result.data?.length || 0}개 컬럼`,
);
return res.json(result);
@@ -696,7 +713,7 @@ router.get(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
@@ -748,7 +765,8 @@ router.get(
}
// 🆕 primaryKeyColumn 파싱
const primaryKeyColumnStr = typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined;
const primaryKeyColumnStr =
typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined;
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
enableEntityJoin: enableEntityJoinFlag,
@@ -762,7 +780,7 @@ router.get(
id,
enableEntityJoinFlag,
groupByColumnsArray,
primaryKeyColumnStr // 🆕 Primary Key 컬럼명 전달
primaryKeyColumnStr, // 🆕 Primary Key 컬럼명 전달
);
if (!result.success) {
@@ -790,7 +808,7 @@ router.get(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
@@ -844,7 +862,7 @@ router.post(
records,
req.user?.companyCode,
req.user?.userId,
deleteOrphans
deleteOrphans,
);
if (!result.success) {
@@ -856,7 +874,9 @@ router.post(
const deleted = result.data?.deleted || 0;
console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, {
inserted, updated, deleted,
inserted,
updated,
deleted,
});
const parts: string[] = [];
@@ -869,7 +889,10 @@ router.post(
companyCode: req.user?.companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: inserted > 0 && updated === 0 && deleted === 0 ? "BATCH_CREATE" : "UPDATE",
action:
inserted > 0 && updated === 0 && deleted === 0
? "BATCH_CREATE"
: "UPDATE",
resourceType: "DATA",
tableName,
summary: `${tableName} 테이블 배치 처리: ${parts.join(", ")}`,
@@ -895,7 +918,81 @@ router.post(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
* 설비 ID 일괄 검증 API
* POST /api/data/equipment_mng/validate
*
* 요청: { "equipmentIds": ["EQ_001", "EQ_002", "EQ_003"] }
* 응답: { "success": true, "data": [{ "equipment_id": "EQ_001", "equipment_name": "프레스 1호기" }, ...] }
*
* - 존재하는 설비만 반환 (존재하지 않는 ID는 응답에서 제외)
* - 멀티테넌시: company_code 필터링 적용
*/
router.post(
"/equipment_mng/validate",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { equipmentIds } = req.body;
if (!Array.isArray(equipmentIds) || equipmentIds.length === 0) {
return res.status(400).json({
success: false,
message: "equipmentIds 배열이 필요합니다.",
});
}
// 최대 500개 제한
if (equipmentIds.length > 500) {
return res.status(400).json({
success: false,
message: "한 번에 최대 500개까지 검증할 수 있습니다.",
});
}
const companyCode = req.user?.companyCode;
const pool = getPool();
// Parameterized query로 SQL Injection 방지
const placeholders = equipmentIds.map((_, i) => `$${i + 1}`).join(", ");
const params: any[] = [...equipmentIds];
let whereClause = `WHERE equipment_id IN (${placeholders})`;
// 멀티테넌시 필터링 (company_code가 '*'이 아닌 경우)
if (companyCode && companyCode !== "*") {
params.push(companyCode);
whereClause += ` AND company_code = $${params.length}`;
}
const query = `
SELECT equipment_id, equipment_name
FROM equipment_mng
${whereClause}
`;
const result = await pool.query(query, params);
console.log(
`✅ 설비 일괄 검증: ${result.rowCount}/${equipmentIds.length}개 확인`,
);
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
console.error("설비 일괄 검증 오류:", error);
return res.status(500).json({
success: false,
message: "설비 검증 중 오류가 발생했습니다.",
error: error.message,
});
}
},
);
/**
@@ -935,7 +1032,7 @@ router.post(
// 테이블에 company_code 컬럼이 있는지 확인하고 자동으로 추가
const hasCompanyCode = await dataService.checkColumnExists(
tableName,
"company_code"
"company_code",
);
if (hasCompanyCode && req.user?.companyCode) {
enrichedData.company_code = req.user.companyCode;
@@ -945,7 +1042,7 @@ router.post(
// 테이블에 company_name 컬럼이 있는지 확인하고 자동으로 추가
const hasCompanyName = await dataService.checkColumnExists(
tableName,
"company_name"
"company_name",
);
if (hasCompanyName && req.user?.companyName) {
enrichedData.company_name = req.user.companyName;
@@ -1001,7 +1098,7 @@ router.post(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
@@ -1086,7 +1183,7 @@ router.put(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
@@ -1156,7 +1253,7 @@ router.post(
error: error.message,
});
}
}
},
);
/**
@@ -1179,12 +1276,16 @@ router.post(
});
}
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany });
console.log(`🗑️ 그룹 삭제:`, {
tableName,
filterConditions,
userCompany,
});
const result = await dataService.deleteGroupRecords(
tableName,
filterConditions,
userCompany // 회사 코드 전달
userCompany, // 회사 코드 전달
);
if (!result.success) {
@@ -1201,7 +1302,7 @@ router.post(
error: error.message,
});
}
}
},
);
router.delete(
@@ -1264,7 +1365,7 @@ router.delete(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
export default router;
+56 -3
View File
@@ -13,7 +13,54 @@ import { auditLogService, getClientIp } from "../../services/auditLogService";
const router = Router();
/**
* 플로우 목록 조회
* flow_data에서 요약 정보 추출
*/
function extractFlowSummary(flowData: any) {
try {
const parsed = typeof flowData === "string" ? JSON.parse(flowData) : flowData;
const nodes = parsed?.nodes || [];
const edges = parsed?.edges || [];
const nodeTypes: Record<string, number> = {};
nodes.forEach((n: any) => {
const t = n.type || "unknown";
nodeTypes[t] = (nodeTypes[t] || 0) + 1;
});
// 미니 토폴로지용 간소화된 좌표 (0~1 정규화)
let topology = null;
if (nodes.length > 0) {
const xs = nodes.map((n: any) => n.position?.x || 0);
const ys = nodes.map((n: any) => n.position?.y || 0);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const rangeX = maxX - minX || 1;
const rangeY = maxY - minY || 1;
topology = {
nodes: nodes.map((n: any) => ({
id: n.id,
type: n.type,
x: (((n.position?.x || 0) - minX) / rangeX),
y: (((n.position?.y || 0) - minY) / rangeY),
})),
edges: edges.map((e: any) => [e.source, e.target]),
};
}
return {
nodeCount: nodes.length,
edgeCount: edges.length,
nodeTypes,
topology,
};
} catch {
return { nodeCount: 0, edgeCount: 0, nodeTypes: {}, topology: null };
}
}
/**
* 플로우 목록 조회 (summary 포함)
*/
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
try {
@@ -24,6 +71,7 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
flow_id as "flowId",
flow_name as "flowName",
flow_description as "flowDescription",
flow_data as "flowData",
company_code as "companyCode",
created_at as "createdAt",
updated_at as "updatedAt"
@@ -32,7 +80,6 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
const params: any[] = [];
// 슈퍼 관리자가 아니면 회사별 필터링
if (userCompanyCode && userCompanyCode !== "*") {
sqlQuery += ` WHERE company_code = $1`;
params.push(userCompanyCode);
@@ -42,9 +89,15 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
const flows = await query(sqlQuery, params);
const flowsWithSummary = flows.map((flow: any) => {
const summary = extractFlowSummary(flow.flowData);
const { flowData, ...rest } = flow;
return { ...rest, summary };
});
return res.json({
success: true,
data: flows,
data: flowsWithSummary,
});
} catch (error) {
logger.error("플로우 목록 조회 실패:", error);
+67
View File
@@ -0,0 +1,67 @@
import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
getDesignRequestList, getDesignRequestDetail, createDesignRequest, updateDesignRequest, deleteDesignRequest, addRequestHistory,
getProjectList, getProjectDetail, createProject, updateProject, deleteProject,
getTasksByProject, createTask, updateTask, deleteTask,
getWorkLogsByTask, createWorkLog, deleteWorkLog,
createSubItem, updateSubItem, deleteSubItem,
createIssue, updateIssue,
getEcnList, createEcn, updateEcn, deleteEcn,
getMyWork,
createPurchaseReq, createCoopReq, addCoopResponse,
} from "../controllers/designController";
const router = express.Router();
router.use(authenticateToken);
// 설계의뢰/설변요청 (DR/ECR)
router.get("/requests", getDesignRequestList);
router.get("/requests/:id", getDesignRequestDetail);
router.post("/requests", createDesignRequest);
router.put("/requests/:id", updateDesignRequest);
router.delete("/requests/:id", deleteDesignRequest);
router.post("/requests/:id/history", addRequestHistory);
// 설계 프로젝트
router.get("/projects", getProjectList);
router.get("/projects/:id", getProjectDetail);
router.post("/projects", createProject);
router.put("/projects/:id", updateProject);
router.delete("/projects/:id", deleteProject);
// 프로젝트 태스크
router.get("/projects/:projectId/tasks", getTasksByProject);
router.post("/projects/:projectId/tasks", createTask);
router.put("/tasks/:taskId", updateTask);
router.delete("/tasks/:taskId", deleteTask);
// 작업일지
router.get("/tasks/:taskId/work-logs", getWorkLogsByTask);
router.post("/tasks/:taskId/work-logs", createWorkLog);
router.delete("/work-logs/:workLogId", deleteWorkLog);
// 태스크 하위항목
router.post("/tasks/:taskId/sub-items", createSubItem);
router.put("/sub-items/:subItemId", updateSubItem);
router.delete("/sub-items/:subItemId", deleteSubItem);
// 태스크 이슈
router.post("/tasks/:taskId/issues", createIssue);
router.put("/issues/:issueId", updateIssue);
// ECN (설변통보)
router.get("/ecn", getEcnList);
router.post("/ecn", createEcn);
router.put("/ecn/:id", updateEcn);
router.delete("/ecn/:id", deleteEcn);
// 나의 업무
router.get("/my-work", getMyWork);
// 구매요청 / 협업요청
router.post("/work-logs/:workLogId/purchase-reqs", createPurchaseReq);
router.post("/work-logs/:workLogId/coop-reqs", createCoopReq);
router.post("/coop-reqs/:coopReqId/responses", addCoopResponse);
export default router;
@@ -0,0 +1,22 @@
/**
* 자재현황 라우트
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as materialStatusController from "../controllers/materialStatusController";
const router = Router();
router.use(authenticateToken);
// 생산계획(작업지시) 목록 조회
router.get("/work-orders", materialStatusController.getWorkOrders);
// 자재소요 + 재고 현황 조회 (POST: planIds 배열 전달)
router.post("/materials", materialStatusController.getMaterialStatus);
// 창고 목록 조회
router.get("/warehouses", materialStatusController.getWarehouses);
export default router;
@@ -0,0 +1,45 @@
/**
* 공정정보관리 라우트
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/processInfoController";
const router = Router();
router.use(authenticateToken);
// 공정 마스터 CRUD
router.get("/processes", ctrl.getProcessList);
router.post("/processes", ctrl.createProcess);
router.put("/processes/:id", ctrl.updateProcess);
router.post("/processes/delete", ctrl.deleteProcesses);
// 공정별 설비 관리
router.get("/processes/:processCode/equipments", ctrl.getProcessEquipments);
router.post("/process-equipments", ctrl.addProcessEquipment);
router.delete("/process-equipments/:id", ctrl.removeProcessEquipment);
// 설비 목록 (드롭다운용)
router.get("/equipments", ctrl.getEquipmentList);
// 품목 목록 (라우팅 등록된 품목만)
router.get("/items", ctrl.getItemsForRouting);
// 전체 품목 검색 (등록 모달용)
router.get("/items/search-all", ctrl.searchAllItems);
// 라우팅 버전
router.get("/routing-versions/:itemCode", ctrl.getRoutingVersions);
router.post("/routing-versions", ctrl.createRoutingVersion);
router.delete("/routing-versions/:id", ctrl.deleteRoutingVersion);
// 라우팅 상세
router.get("/routing-details/:versionId", ctrl.getRoutingDetails);
router.put("/routing-details/:versionId", ctrl.saveRoutingDetails);
// BOM 구성 자재 조회
router.get("/bom-materials/:itemCode", ctrl.getBomMaterials);
export default router;
@@ -16,6 +16,9 @@ router.get("/order-summary", productionController.getOrderSummary);
// 안전재고 부족분 조회
router.get("/stock-shortage", productionController.getStockShortage);
// 생산계획 목록 조회
router.get("/plans", productionController.getPlans);
// 생산계획 CRUD
router.get("/plan/:id", productionController.getPlanById);
router.put("/plan/:id", productionController.updatePlan);
@@ -0,0 +1,40 @@
/**
* 입고관리 라우트
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as receivingController from "../controllers/receivingController";
const router = Router();
router.use(authenticateToken);
// 입고 목록 조회
router.get("/list", receivingController.getList);
// 입고번호 자동생성
router.get("/generate-number", receivingController.generateNumber);
// 창고 목록 조회
router.get("/warehouses", receivingController.getWarehouses);
// 소스 데이터: 발주 (구매입고)
router.get("/source/purchase-orders", receivingController.getPurchaseOrders);
// 소스 데이터: 출하 (반품입고)
router.get("/source/shipments", receivingController.getShipments);
// 소스 데이터: 품목 (기타입고)
router.get("/source/items", receivingController.getItems);
// 입고 등록
router.post("/", receivingController.create);
// 입고 수정
router.put("/:id", receivingController.update);
// 입고 삭제
router.delete("/:id", receivingController.deleteReceiving);
export default router;
@@ -0,0 +1,12 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { getSalesReportData } from "../controllers/salesReportController";
const router = Router();
router.use(authenticateToken);
// 영업 리포트 원본 데이터 조회
router.get("/data", getSalesReportData);
export default router;
@@ -0,0 +1,21 @@
/**
* 출하지시 라우트
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as shippingOrderController from "../controllers/shippingOrderController";
const router = Router();
router.use(authenticateToken);
router.get("/list", shippingOrderController.getList);
router.get("/preview-no", shippingOrderController.previewNextNo);
router.post("/save", shippingOrderController.save);
router.post("/delete", shippingOrderController.remove);
// 모달 왼쪽 패널 데이터 소스
router.get("/source/shipment-plan", shippingOrderController.getShipmentPlanSource);
router.get("/source/sales-order", shippingOrderController.getSalesOrderSource);
router.get("/source/item", shippingOrderController.getItemSource);
export default router;
@@ -10,10 +10,16 @@ const router = Router();
router.use(authenticateToken);
// 출하계획 목록 조회 (관리 화면용)
router.get("/list", shippingPlanController.getList);
// 품목별 집계 + 기존 출하계획 조회
router.get("/aggregate", shippingPlanController.getAggregate);
// 출하계획 일괄 저장
router.post("/batch", shippingPlanController.batchSave);
// 출하계획 단건 수정
router.put("/:id", shippingPlanController.updatePlan);
export default router;
@@ -0,0 +1,26 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/workInstructionController";
const router = Router();
router.use(authenticateToken);
router.get("/list", ctrl.getList);
router.get("/preview-no", ctrl.previewNextNo);
router.post("/save", ctrl.save);
router.post("/delete", ctrl.remove);
router.get("/source/item", ctrl.getItemSource);
router.get("/source/sales-order", ctrl.getSalesOrderSource);
router.get("/source/production-plan", ctrl.getProductionPlanSource);
router.get("/equipment", ctrl.getEquipmentList);
router.get("/employees", ctrl.getEmployeeList);
// 라우팅 & 공정작업기준
router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions);
router.put("/:wiNo/routing", ctrl.updateRouting);
router.get("/:wiNo/work-standard", ctrl.getWorkStandard);
router.post("/:wiNo/work-standard/copy", ctrl.copyWorkStandard);
router.put("/:wiNo/work-standard/save", ctrl.saveWorkStandard);
router.delete("/:wiNo/work-standard/reset", ctrl.resetWorkStandard);
export default router;
@@ -122,20 +122,22 @@ export class BatchSchedulerService {
}
/**
* 배치 설정 실행
* 배치 설정 실행 - execution_type에 따라 매핑 또는 노드 플로우 실행
*/
static async executeBatchConfig(config: any) {
const startTime = new Date();
let executionLog: any = null;
try {
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id}, type: ${config.execution_type || "mapping"})`);
// 매핑 정보가 없으면 상세 조회로 다시 가져오기
if (!config.batch_mappings || config.batch_mappings.length === 0) {
const fullConfig = await BatchService.getBatchConfigById(config.id);
if (fullConfig.success && fullConfig.data) {
config = fullConfig.data;
// 상세 조회 (매핑 또는 노드플로우 정보가 없을 수 있음)
if (!config.execution_type || config.execution_type === "mapping") {
if (!config.batch_mappings || config.batch_mappings.length === 0) {
const fullConfig = await BatchService.getBatchConfigById(config.id);
if (fullConfig.success && fullConfig.data) {
config = fullConfig.data;
}
}
}
@@ -165,12 +167,17 @@ export class BatchSchedulerService {
executionLog = executionLogResponse.data;
// 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용)
const result = await this.executeBatchMappings(config);
let result: { totalRecords: number; successRecords: number; failedRecords: number };
if (config.execution_type === "node_flow") {
result = await this.executeNodeFlow(config);
} else {
result = await this.executeBatchMappings(config);
}
// 실행 로그 업데이트 (성공)
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "SUCCESS",
execution_status: result.failedRecords > 0 ? "PARTIAL" : "SUCCESS",
end_time: new Date(),
duration_ms: Date.now() - startTime.getTime(),
total_records: result.totalRecords,
@@ -182,12 +189,10 @@ export class BatchSchedulerService {
`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`
);
// 성공 결과 반환
return result;
} catch (error) {
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
// 실행 로그 업데이트 (실패)
if (executionLog) {
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "FAILED",
@@ -198,7 +203,6 @@ export class BatchSchedulerService {
});
}
// 실패 결과 반환
return {
totalRecords: 0,
successRecords: 0,
@@ -207,6 +211,43 @@ export class BatchSchedulerService {
}
}
/**
* 노드 플로우 실행 - NodeFlowExecutionService에 위임
*/
private static async executeNodeFlow(config: any) {
if (!config.node_flow_id) {
throw new Error("노드 플로우 ID가 설정되지 않았습니다.");
}
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
);
const contextData: Record<string, any> = {
companyCode: config.company_code,
batchConfigId: config.id,
batchName: config.batch_name,
executionSource: "batch_scheduler",
...(config.node_flow_context || {}),
};
logger.info(
`노드 플로우 실행: flowId=${config.node_flow_id}, batch=${config.batch_name}`
);
const flowResult = await NodeFlowExecutionService.executeFlow(
config.node_flow_id,
contextData
);
// 노드 플로우 실행 결과를 배치 로그 형식으로 변환
return {
totalRecords: flowResult.summary.total,
successRecords: flowResult.summary.success,
failedRecords: flowResult.summary.failed,
};
}
/**
* 배치 매핑 실행 (수동 실행과 동일한 로직)
*/
+26 -7
View File
@@ -72,9 +72,12 @@ export class BatchService {
const total = parseInt(countResult[0].count);
const totalPages = Math.ceil(total / limit);
// 목록 조회
// 목록 조회 (최근 실행 정보 포함)
const configs = await query<any>(
`SELECT bc.*
`SELECT bc.*,
(SELECT bel.execution_status FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_status,
(SELECT bel.start_time FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_executed_at,
(SELECT bel.total_records FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_total_records
FROM batch_configs bc
${whereClause}
ORDER BY bc.created_date DESC
@@ -82,9 +85,6 @@ export class BatchService {
[...values, limit, offset]
);
// 매핑 정보 조회 (N+1 문제 해결을 위해 별도 쿼리 대신 여기서는 생략하고 상세 조회에서 처리)
// 하지만 목록에서도 간단한 정보는 필요할 수 있음
return {
success: true,
data: configs as BatchConfig[],
@@ -176,8 +176,8 @@ export class BatchService {
// 배치 설정 생성
const batchConfigResult = await client.query(
`INSERT INTO batch_configs
(batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
(batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, execution_type, node_flow_id, node_flow_context, created_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
RETURNING *`,
[
data.batchName,
@@ -189,6 +189,9 @@ export class BatchService {
data.conflictKey || null,
data.authServiceName || null,
data.dataArrayPath || null,
data.executionType || "mapping",
data.nodeFlowId || null,
data.nodeFlowContext ? JSON.stringify(data.nodeFlowContext) : null,
userId,
]
);
@@ -332,6 +335,22 @@ export class BatchService {
updateFields.push(`data_array_path = $${paramIndex++}`);
updateValues.push(data.dataArrayPath || null);
}
if (data.executionType !== undefined) {
updateFields.push(`execution_type = $${paramIndex++}`);
updateValues.push(data.executionType);
}
if (data.nodeFlowId !== undefined) {
updateFields.push(`node_flow_id = $${paramIndex++}`);
updateValues.push(data.nodeFlowId || null);
}
if (data.nodeFlowContext !== undefined) {
updateFields.push(`node_flow_context = $${paramIndex++}`);
updateValues.push(
data.nodeFlowContext
? JSON.stringify(data.nodeFlowContext)
: null
);
}
// 배치 설정 업데이트
const batchConfigResult = await client.query(
@@ -952,13 +952,20 @@ export class NodeFlowExecutionService {
}
const schemaPrefix = schema ? `${schema}.` : "";
// WHERE 조건에서 field 값 조회를 위해 컨텍스트 데이터 전달
// sourceData(저장된 폼 데이터) + buttonContext(인증 정보) 병합
const contextForWhere = {
...(context.buttonContext || {}),
...(context.sourceData?.[0] || {}),
};
const whereResult = whereConditions
? this.buildWhereClause(whereConditions)
? this.buildWhereClause(whereConditions, contextForWhere)
: { clause: "", values: [] };
const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`;
logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`);
logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`, { values: whereResult.values });
const result = await query(sql, whereResult.values);
@@ -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, 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) {
@@ -293,23 +397,47 @@ export async function previewSchedule(
}
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
const itemLeadTime = item.lead_time || 0;
let requiredQty = item.required_qty;
// recalculate_unstarted가 true이면 기존 planned 삭제 후 재생성이므로,
// 프론트에서 이미 차감된 기존 계획 수량을 다시 더해줘야 정확한 필요 수량이 됨
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);
requiredQty += deletedQtyForItem;
}
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 +454,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 +472,7 @@ export async function previewSchedule(
};
logger.info("자동 스케줄 미리보기", { companyCode, summary });
return { summary, previews, deletedSchedules, keptSchedules };
return { summary, schedules: previews, deletedSchedules, keptSchedules };
}
export async function generateSchedule(
@@ -365,7 +494,21 @@ export async function generateSchedule(
const newSchedules: any[] = [];
for (const item of items) {
// 기존 미진행(planned) 스케줄 처리
// 삭제 전에 기존 planned 수량 먼저 조회
let deletedQtyForItem = 0;
if (options.recalculate_unstarted) {
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, item.item_code, productType]
);
deletedQtyForItem = parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0;
}
// 기존 미진행(planned) 스케줄 삭제
if (options.recalculate_unstarted) {
const deleteResult = await client.query(
`DELETE FROM production_plan_mng
@@ -389,27 +532,39 @@ export async function generateSchedule(
keptCount += parseInt(keptResult.rows[0].cnt, 10);
}
// 생산일수 계산
// 필요 수량 계산 (삭제된 planned 수량을 복원)
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
const itemLeadTime = item.lead_time || 0;
let requiredQty = item.required_qty + deletedQtyForItem;
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 +731,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, 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 +807,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 +822,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 +853,7 @@ export async function previewSemiSchedule(
parent_count: plansResult.rowCount,
};
return { summary, previews, deletedSchedules, keptSchedules };
return { summary, schedules: previews, deletedSchedules, keptSchedules };
}
// ─── 반제품 계획 자동 생성 ───
@@ -740,10 +910,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(
@@ -5,7 +5,7 @@ export interface BatchExecutionLog {
id?: number;
batch_config_id: number;
company_code?: string;
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED' | 'PARTIAL';
start_time: Date;
end_time?: Date | null;
duration_ms?: number | null;
@@ -21,7 +21,7 @@ export interface BatchExecutionLog {
export interface CreateBatchExecutionLogRequest {
batch_config_id: number;
company_code?: string;
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED' | 'PARTIAL';
start_time?: Date;
end_time?: Date | null;
duration_ms?: number | null;
@@ -35,7 +35,7 @@ export interface CreateBatchExecutionLogRequest {
}
export interface UpdateBatchExecutionLogRequest {
execution_status?: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
execution_status?: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED' | 'PARTIAL';
end_time?: Date | null;
duration_ms?: number | null;
total_records?: number | null;
+21 -6
View File
@@ -79,6 +79,9 @@ export interface BatchMapping {
created_date?: Date;
}
// 배치 실행 타입: 기존 매핑 방식 또는 노드 플로우 실행
export type BatchExecutionType = "mapping" | "node_flow";
// 배치 설정 타입
export interface BatchConfig {
id?: number;
@@ -87,15 +90,21 @@ export interface BatchConfig {
cron_schedule: string;
is_active: "Y" | "N";
company_code?: string;
save_mode?: "INSERT" | "UPSERT"; // 저장 모드 (기본: INSERT)
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items)
save_mode?: "INSERT" | "UPSERT";
conflict_key?: string;
auth_service_name?: string;
data_array_path?: string;
execution_type?: BatchExecutionType;
node_flow_id?: number;
node_flow_context?: Record<string, any>;
created_by?: string;
created_date?: Date;
updated_by?: string;
updated_date?: Date;
batch_mappings?: BatchMapping[];
last_status?: string;
last_executed_at?: string;
last_total_records?: number;
}
export interface BatchConnectionInfo {
@@ -149,7 +158,10 @@ export interface CreateBatchConfigRequest {
saveMode?: "INSERT" | "UPSERT";
conflictKey?: string;
authServiceName?: string;
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
dataArrayPath?: string;
executionType?: BatchExecutionType;
nodeFlowId?: number;
nodeFlowContext?: Record<string, any>;
mappings: BatchMappingRequest[];
}
@@ -161,7 +173,10 @@ export interface UpdateBatchConfigRequest {
saveMode?: "INSERT" | "UPSERT";
conflictKey?: string;
authServiceName?: string;
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
dataArrayPath?: string;
executionType?: BatchExecutionType;
nodeFlowId?: number;
nodeFlowContext?: Record<string, any>;
mappings?: BatchMappingRequest[];
}
+115
View File
@@ -0,0 +1,115 @@
# 고객사별 환경 변수 관리
## 개요
이 폴더는 각 고객사(업체)별 환경 변수 설정을 **참고용**으로 관리합니다.
**중요:** 실제 비밀번호는 이 파일에 저장하지 마세요. 템플릿으로만 사용합니다.
---
## 고객사 목록
| 고객사 | 파일 | 배포 형태 | 상태 |
| :--- | :--- | :--- | :--- |
| 스피폭스 | `spifox.env` | 온프레미스 (공장 서버) | 진행 중 |
| 엔키드 | `enkid.env` | 온프레미스 (공장 서버) | 예정 |
---
## 신규 고객사 추가 절차
### 1단계: 환경 변수 파일 생성
```bash
# 기존 파일 복사
cp spifox.env newcustomer.env
# 수정
nano newcustomer.env
```
필수 수정 항목:
- `COMPANY_CODE`: 고유한 회사 코드 (예: NEWCO)
- `SERVER_IP`: 고객사 서버 IP
- `DB_PASSWORD`: 고유한 비밀번호
- `JWT_SECRET`: 고유한 시크릿 키
### 2단계: 데이터베이스에 회사 등록
```sql
-- company_info 테이블에 추가
INSERT INTO company_info (company_code, company_name, status)
VALUES ('NEWCO', '신규고객사', 'ACTIVE');
```
### 3단계: 관리자 계정 생성
```sql
-- user_info 테이블에 관리자 추가
INSERT INTO user_info (user_id, user_name, company_code, role)
VALUES ('newco_admin', '신규고객사 관리자', 'NEWCO', 'COMPANY_ADMIN');
```
### 4단계: 고객사 서버에 배포
```bash
# 고객사 서버에 SSH 접속
ssh user@customer-server
# 설치 폴더 생성
sudo mkdir -p /opt/vexplor
cd /opt/vexplor
# docker-compose.yml 복사 (deploy/onpremise/에서)
# .env 파일 복사 및 수정
# 서비스 시작
docker compose up -d
```
---
## 환경 변수 설명
| 변수 | 설명 | 예시 |
| :--- | :--- | :--- |
| `COMPANY_CODE` | 회사 고유 코드 (멀티테넌시) | `SPIFOX`, `ENKID` |
| `SERVER_IP` | 서버의 실제 IP | `192.168.0.100` |
| `DB_PASSWORD` | DB 비밀번호 | (고객사별 고유) |
| `JWT_SECRET` | JWT 토큰 시크릿 | (고객사별 고유) |
| `IMAGE_TAG` | Docker 이미지 버전 | `latest`, `v1.0.0` |
---
## 보안 주의사항
1. **비밀번호**: 이 폴더의 파일에는 실제 비밀번호를 저장하지 마세요
2. **Git**: `.env` 파일이 커밋되지 않도록 `.gitignore` 확인
3. **고객사별 격리**: 각 고객사는 별도 서버, 별도 DB로 완전 격리
4. **키 관리**: JWT_SECRET은 고객사별로 반드시 다르게 설정
---
## 구조 다이어그램
```
[Harbor (이미지 저장소)]
│ docker pull
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 스피폭스 공장 │ │ 엔키드 공장 │ │ 신규 고객사 │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ Vexplor │ │ │ │ Vexplor │ │ │ │ Vexplor │ │
│ │ SPIFOX │ │ │ │ ENKID │ │ │ │ NEWCO │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
│ │ │ │ │ │
│ [독립 DB] │ │ [독립 DB] │ │ [독립 DB] │
└─────────────────┘ └─────────────────┘ └─────────────────┘
* 각 공장은 완전히 독립적으로 운영
* 같은 Docker 이미지 사용, .env만 다름
* 데이터는 절대 섞이지 않음 (물리적 격리)
```
+36
View File
@@ -0,0 +1,36 @@
# ============================================
# 엔키드(ENKID) 공장 서버 환경 변수
# ============================================
# 이 파일을 엔키드 공장 서버의 /opt/vexplor/.env로 복사
# 회사 정보
COMPANY_CODE=ENKID
# 서버 정보 (실제 서버 IP로 변경 필요)
SERVER_IP=10.0.0.50
# 데이터베이스
DB_USER=vexplor
DB_PASSWORD=enkid_secure_password_here
DB_NAME=vexplor
DB_PORT=5432
# 백엔드
BACKEND_PORT=3001
JWT_SECRET=enkid_jwt_secret_minimum_32_characters
JWT_EXPIRES_IN=24h
LOG_LEVEL=info
# 프론트엔드
FRONTEND_PORT=80
# Harbor 레지스트리
HARBOR_USER=enkid_harbor_user
HARBOR_PASSWORD=enkid_harbor_password
# 이미지 태그
IMAGE_TAG=latest
# Watchtower (1시간마다 업데이트 확인)
UPDATE_INTERVAL=3600
+36
View File
@@ -0,0 +1,36 @@
# ============================================
# 스피폭스(SPIFOX) 공장 서버 환경 변수
# ============================================
# 이 파일을 스피폭스 공장 서버의 /opt/vexplor/.env로 복사
# 회사 정보
COMPANY_CODE=SPIFOX
# 서버 정보 (실제 서버 IP로 변경 필요)
SERVER_IP=192.168.0.100
# 데이터베이스
DB_USER=vexplor
DB_PASSWORD=spifox_secure_password_here
DB_NAME=vexplor
DB_PORT=5432
# 백엔드
BACKEND_PORT=3001
JWT_SECRET=spifox_jwt_secret_minimum_32_characters
JWT_EXPIRES_IN=24h
LOG_LEVEL=info
# 프론트엔드
FRONTEND_PORT=80
# Harbor 레지스트리
HARBOR_USER=spifox_harbor_user
HARBOR_PASSWORD=spifox_harbor_password
# 이미지 태그
IMAGE_TAG=latest
# Watchtower (1시간마다 업데이트 확인)
UPDATE_INTERVAL=3600
+321
View File
@@ -0,0 +1,321 @@
# Vexplor 온프레미스(공장) 배포 가이드
## 개요
이 가이드는 Vexplor를 **공장 내부 서버(온프레미스)**에 배포하는 방법을 설명합니다.
**Watchtower**를 사용하여 Harbor에 새 이미지가 푸시되면 **자동으로 업데이트**됩니다.
---
## 사전 요구사항
### 서버 요구사항
| 항목 | 최소 사양 | 권장 사양 |
| :--- | :--- | :--- |
| OS | Ubuntu 20.04+ | Ubuntu 22.04 LTS |
| CPU | 4 Core | 8 Core |
| RAM | 8 GB | 16 GB |
| Disk | 50 GB | 100 GB SSD |
| Network | Harbor 접근 가능 | - |
### 필수 소프트웨어
```bash
# Docker 설치 확인
docker --version # 20.10 이상
# Docker Compose 설치 확인
docker compose version # v2.0 이상
```
---
## 1단계: 초기 설정
### 1.1 배포 폴더 생성
```bash
# 배포 폴더 생성
sudo mkdir -p /opt/vexplor
cd /opt/vexplor
# 파일 복사 (또는 git clone)
# deploy/onpremise/ 폴더의 내용을 복사
```
### 1.2 환경 변수 설정
```bash
# 예제 파일 복사
cp env.example .env
# 편집
nano .env
```
**필수 수정 항목:**
```bash
# 서버 IP (이 서버의 실제 IP)
SERVER_IP=192.168.0.100
# 회사 코드
COMPANY_CODE=SPIFOX
# DB 비밀번호 (강력한 비밀번호 설정)
DB_PASSWORD=MySecurePassword123!
# JWT 시크릿 (32자 이상)
JWT_SECRET=your-super-secret-jwt-key-minimum-32-chars
# Harbor 인증 정보
HARBOR_USER=your_username
HARBOR_PASSWORD=your_password
```
### 1.3 Harbor 레지스트리 로그인
Watchtower가 이미지를 당겨올 수 있도록 Docker 로그인이 필요합니다.
```bash
# Harbor 로그인
docker login harbor.wace.me
# Username: (입력)
# Password: (입력)
# 로그인 성공 확인
cat ~/.docker/config.json
```
---
## 2단계: 서비스 실행
### 2.1 서비스 시작
```bash
cd /opt/vexplor
# 이미지 다운로드 & 실행
docker compose up -d
# 상태 확인
docker compose ps
```
### 2.2 정상 동작 확인
```bash
# 모든 컨테이너 Running 상태 확인
docker compose ps
# 로그 확인
docker compose logs -f
# 개별 서비스 로그
docker compose logs -f backend
docker compose logs -f frontend
docker compose logs -f watchtower
```
### 2.3 웹 접속 테스트
```
프론트엔드: http://SERVER_IP:80
백엔드 API: http://SERVER_IP:3001/health
```
---
## 3단계: 자동 업데이트 확인
### Watchtower 동작 확인
```bash
# Watchtower 로그 확인
docker compose logs -f watchtower
```
**정상 로그 예시:**
```
watchtower | time="2024-12-28T10:00:00+09:00" level=info msg="Checking for updates..."
watchtower | time="2024-12-28T10:00:05+09:00" level=info msg="Found new image harbor.wace.me/vexplor/vexplor-backend:latest"
watchtower | time="2024-12-28T10:00:10+09:00" level=info msg="Stopping container vexplor-backend"
watchtower | time="2024-12-28T10:00:15+09:00" level=info msg="Starting container vexplor-backend"
```
### 업데이트 주기 변경
```bash
# .env 파일에서 변경
UPDATE_INTERVAL=3600 # 1시간마다 확인
# 변경 후 watchtower 재시작
docker compose restart watchtower
```
---
## 운영 가이드
### 서비스 관리 명령어
```bash
# 모든 서비스 상태 확인
docker compose ps
# 모든 서비스 중지
docker compose stop
# 모든 서비스 시작
docker compose start
# 모든 서비스 재시작
docker compose restart
# 모든 서비스 삭제 (데이터 유지)
docker compose down
# 모든 서비스 삭제 + 볼륨 삭제 (주의: 데이터 삭제됨!)
docker compose down -v
```
### 로그 확인
```bash
# 전체 로그 (실시간)
docker compose logs -f
# 특정 서비스 로그
docker compose logs -f backend
docker compose logs -f frontend
docker compose logs -f database
# 최근 100줄만
docker compose logs --tail=100 backend
```
### 수동 업데이트 (긴급 시)
자동 업데이트를 기다리지 않고 즉시 업데이트하려면:
```bash
# 최신 이미지 다운로드
docker compose pull
# 재시작
docker compose up -d
```
### 특정 버전으로 롤백
```bash
# .env 파일에서 버전 지정
IMAGE_TAG=v1.0.0
# 재시작
docker compose up -d
```
---
## 백업 가이드
### DB 백업
```bash
# 백업 디렉토리 생성
mkdir -p /opt/vexplor/backups
# PostgreSQL 백업
docker compose exec database pg_dump -U vexplor vexplor > /opt/vexplor/backups/backup_$(date +%Y%m%d_%H%M%S).sql
```
### 업로드 파일 백업
```bash
# 볼륨 위치 확인
docker volume inspect vexplor_backend_uploads
# 또는 직접 복사
docker cp vexplor-backend:/app/uploads /opt/vexplor/backups/uploads_$(date +%Y%m%d)
```
### 자동 백업 스크립트 (Cron)
```bash
# crontab 편집
crontab -e
# 매일 새벽 3시 DB 백업
0 3 * * * docker compose -f /opt/vexplor/docker-compose.yml exec -T database pg_dump -U vexplor vexplor > /opt/vexplor/backups/backup_$(date +\%Y\%m\%d).sql
```
---
## 문제 해결
### 컨테이너가 시작되지 않음
```bash
# 로그 확인
docker compose logs backend
# 일반적인 원인:
# 1. 환경 변수 누락 → .env 파일 확인
# 2. 포트 충돌 → netstat -tlnp | grep 3001
# 3. 메모리 부족 → free -h
```
### DB 연결 실패
```bash
# DB 컨테이너 상태 확인
docker compose logs database
# DB 직접 접속 테스트
docker compose exec database psql -U vexplor -d vexplor -c "SELECT 1"
```
### Watchtower가 업데이트하지 않음
```bash
# Watchtower 로그 확인
docker compose logs watchtower
# Harbor 인증 확인
docker pull harbor.wace.me/vexplor/vexplor-backend:latest
# 라벨 확인 (라벨이 있는 컨테이너만 업데이트)
docker inspect vexplor-backend | grep watchtower
```
### 디스크 공간 부족
```bash
# 사용하지 않는 이미지/컨테이너 정리
docker system prune -a
# 오래된 로그 정리
docker compose logs --tail=0 backend # 로그 초기화
```
---
## 보안 권장사항
1. **방화벽 설정**: 필요한 포트(80, 3001)만 개방
2. **SSL/TLS**: Nginx 리버스 프록시 + Let's Encrypt 적용 권장
3. **정기 백업**: 최소 주 1회 DB 백업
4. **로그 모니터링**: 비정상 접근 감시
---
## 연락처
배포 관련 문의: [담당자 이메일]
+155
View File
@@ -0,0 +1,155 @@
# Vexplor 온프레미스(공장) 배포용 Docker Compose
# 사용법: docker compose up -d
services:
# ============================================
# 1. 데이터베이스 (PostgreSQL)
# ============================================
database:
image: postgres:15-alpine
container_name: vexplor-db
environment:
POSTGRES_USER: ${DB_USER:-vexplor}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
POSTGRES_DB: ${DB_NAME:-vexplor}
TZ: Asia/Seoul
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db:/docker-entrypoint-initdb.d # 초기화 스크립트 (선택)
ports:
- "${DB_PORT:-5432}:5432"
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-vexplor}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- vexplor-network
# ============================================
# 2. 백엔드 API (Node.js)
# ============================================
backend:
image: harbor.wace.me/speefox_vexplor/vexplor-backend:${IMAGE_TAG:-latest}
container_name: vexplor-backend
environment:
NODE_ENV: production
PORT: 3001
HOST: 0.0.0.0
TZ: Asia/Seoul
# DB 연결
DB_HOST: database
DB_PORT: 5432
DB_USER: ${DB_USER:-vexplor}
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: ${DB_NAME:-vexplor}
# JWT
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h}
# 암호화 키 (메일 등 민감정보 암호화용)
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-vexplor-encryption-key-32characters-secure}
# 회사 코드 (온프레미스는 단일 회사)
DEFAULT_COMPANY_CODE: ${COMPANY_CODE:-SPIFOX}
# 로깅
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- backend_uploads:/app/uploads
- backend_data:/app/data
- backend_logs:/app/logs
ports:
- "${BACKEND_PORT:-3001}:3001"
depends_on:
database:
condition: service_healthy
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- vexplor-network
labels:
- "com.centurylinklabs.watchtower.enable=true"
# ============================================
# 3. 프론트엔드 (Next.js)
# ============================================
frontend:
image: harbor.wace.me/speefox_vexplor/vexplor-frontend:${IMAGE_TAG:-latest}
container_name: vexplor-frontend
environment:
NODE_ENV: production
PORT: 3000
HOSTNAME: 0.0.0.0
TZ: Asia/Seoul
# 백엔드 API URL (내부 통신)
NEXT_PUBLIC_API_URL: http://${SERVER_IP:-localhost}:${BACKEND_PORT:-3001}/api
ports:
- "${FRONTEND_PORT:-80}:3000"
depends_on:
- backend
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
- vexplor-network
labels:
- "com.centurylinklabs.watchtower.enable=true"
# ============================================
# 4. Watchtower (자동 업데이트)
# ============================================
watchtower:
image: containrrr/watchtower:latest
container_name: vexplor-watchtower
environment:
TZ: Asia/Seoul
DOCKER_API_VERSION: "1.44"
# Harbor 레지스트리 인증
REPO_USER: ${HARBOR_USER}
REPO_PASS: ${HARBOR_PASSWORD}
# 업데이트 설정
# WATCHTOWER_POLL_INTERVAL: ${UPDATE_INTERVAL:-300} # 간격 기반 (비활성화)
WATCHTOWER_SCHEDULE: "0 0 * * * *" # 매시 정각에 실행 (cron 형식)
WATCHTOWER_CLEANUP: "true" # 이전 이미지 자동 삭제
WATCHTOWER_INCLUDE_STOPPED: "true" # 중지된 컨테이너도 업데이트
WATCHTOWER_ROLLING_RESTART: "true" # 순차 재시작 (다운타임 최소화)
WATCHTOWER_LABEL_ENABLE: "true" # 라벨이 있는 컨테이너만 업데이트
# 알림 설정 (선택)
# WATCHTOWER_NOTIFICATIONS: slack
# WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL: ${SLACK_WEBHOOK_URL}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Harbor 인증 정보 (docker login 후 생성됨)
- ~/.docker/config.json:/config.json:ro
restart: always
networks:
- vexplor-network
# ============================================
# 볼륨 정의
# ============================================
volumes:
postgres_data:
driver: local
backend_uploads:
driver: local
backend_data:
driver: local
backend_logs:
driver: local
# ============================================
# 네트워크 정의
# ============================================
networks:
vexplor-network:
driver: bridge
+65
View File
@@ -0,0 +1,65 @@
# ============================================
# Vexplor 온프레미스(공장) 환경 변수
# ============================================
# 사용법: 이 파일을 .env로 복사 후 값 수정
# cp env.example .env
# ============================================
# 서버 정보
# ============================================
# 이 서버의 IP 주소 (프론트엔드가 백엔드 API 호출할 때 사용)
SERVER_IP=192.168.0.100
# ============================================
# 회사 정보
# ============================================
# 이 공장의 회사 코드 (멀티테넌시용)
COMPANY_CODE=SPIFOX
# ============================================
# 데이터베이스 설정
# ============================================
DB_USER=vexplor
DB_PASSWORD=your_secure_password_here
DB_NAME=vexplor
DB_PORT=5432
# ============================================
# 백엔드 설정
# ============================================
BACKEND_PORT=3001
JWT_SECRET=your_jwt_secret_key_minimum_32_characters
JWT_EXPIRES_IN=24h
LOG_LEVEL=info
# ============================================
# 프론트엔드 설정
# ============================================
FRONTEND_PORT=80
# ============================================
# Harbor 레지스트리 인증
# ============================================
# Watchtower가 이미지를 당겨올 때 사용
HARBOR_USER=your_harbor_username
HARBOR_PASSWORD=your_harbor_password
# ============================================
# 이미지 태그
# ============================================
# latest 또는 특정 버전 (v1.0.0 등)
IMAGE_TAG=latest
# ============================================
# Watchtower 설정
# ============================================
# 업데이트 확인 주기 (초 단위)
# 300 = 5분, 3600 = 1시간, 86400 = 24시간
UPDATE_INTERVAL=3600
# ============================================
# 알림 설정 (선택)
# ============================================
# Slack 웹훅 URL (업데이트 알림 받기)
# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/xxx/xxx
+57
View File
@@ -0,0 +1,57 @@
#!/bin/bash
# ============================================
# Vexplor 백업 스크립트
# Cron에 등록하여 정기 백업 가능
# ============================================
set -e
INSTALL_DIR="/opt/vexplor"
BACKUP_DIR="/opt/vexplor/backups"
DATE=$(date +%Y%m%d_%H%M%S)
# 백업 디렉토리 생성
mkdir -p $BACKUP_DIR
echo "=========================================="
echo " Vexplor 백업 시작 - $DATE"
echo "=========================================="
cd $INSTALL_DIR
# 1. PostgreSQL 데이터베이스 백업
echo "[1/3] 데이터베이스 백업..."
docker compose exec -T database pg_dump -U vexplor vexplor > "$BACKUP_DIR/db_$DATE.sql"
gzip "$BACKUP_DIR/db_$DATE.sql"
echo "$BACKUP_DIR/db_$DATE.sql.gz"
# 2. 업로드 파일 백업
echo "[2/3] 업로드 파일 백업..."
docker cp vexplor-backend:/app/uploads "$BACKUP_DIR/uploads_$DATE" 2>/dev/null || echo " → 업로드 폴더 없음 (스킵)"
if [ -d "$BACKUP_DIR/uploads_$DATE" ]; then
tar -czf "$BACKUP_DIR/uploads_$DATE.tar.gz" -C "$BACKUP_DIR" "uploads_$DATE"
rm -rf "$BACKUP_DIR/uploads_$DATE"
echo "$BACKUP_DIR/uploads_$DATE.tar.gz"
fi
# 3. 환경 설정 백업
echo "[3/3] 환경 설정 백업..."
cp "$INSTALL_DIR/.env" "$BACKUP_DIR/env_$DATE"
cp "$INSTALL_DIR/docker-compose.yml" "$BACKUP_DIR/docker-compose_$DATE.yml"
echo "$BACKUP_DIR/env_$DATE"
echo "$BACKUP_DIR/docker-compose_$DATE.yml"
# 4. 오래된 백업 정리 (30일 이상)
echo ""
echo "[정리] 30일 이상 된 백업 삭제..."
find $BACKUP_DIR -type f -mtime +30 -delete 2>/dev/null || true
# 완료
echo ""
echo "=========================================="
echo " 백업 완료!"
echo "=========================================="
echo ""
echo "백업 위치: $BACKUP_DIR"
ls -lh $BACKUP_DIR | tail -10
+79
View File
@@ -0,0 +1,79 @@
#!/bin/bash
# ============================================
# Vexplor 온프레미스 초기 설치 스크립트
# ============================================
set -e
echo "=========================================="
echo " Vexplor 온프레미스 설치 스크립트"
echo "=========================================="
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 설치 경로
INSTALL_DIR="/opt/vexplor"
# 1. Docker 설치 확인
echo -e "\n${YELLOW}[1/5] Docker 설치 확인...${NC}"
if ! command -v docker &> /dev/null; then
echo -e "${RED}Docker가 설치되어 있지 않습니다.${NC}"
echo "다음 명령어로 설치하세요:"
echo " curl -fsSL https://get.docker.com | sh"
echo " sudo usermod -aG docker \$USER"
exit 1
fi
echo -e "${GREEN}Docker $(docker --version | cut -d' ' -f3)${NC}"
# 2. Docker Compose 확인
echo -e "\n${YELLOW}[2/5] Docker Compose 확인...${NC}"
if ! docker compose version &> /dev/null; then
echo -e "${RED}Docker Compose v2가 설치되어 있지 않습니다.${NC}"
exit 1
fi
echo -e "${GREEN}$(docker compose version)${NC}"
# 3. 설치 디렉토리 생성
echo -e "\n${YELLOW}[3/5] 설치 디렉토리 생성...${NC}"
sudo mkdir -p $INSTALL_DIR
sudo chown $USER:$USER $INSTALL_DIR
echo -e "${GREEN}$INSTALL_DIR 생성 완료${NC}"
# 4. 파일 복사
echo -e "\n${YELLOW}[4/5] 설정 파일 복사...${NC}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cp "$SCRIPT_DIR/docker-compose.yml" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/env.example" "$INSTALL_DIR/"
if [ ! -f "$INSTALL_DIR/.env" ]; then
cp "$SCRIPT_DIR/env.example" "$INSTALL_DIR/.env"
echo -e "${YELLOW}[주의] .env 파일을 생성했습니다. 반드시 수정하세요!${NC}"
fi
echo -e "${GREEN}파일 복사 완료${NC}"
# 5. Harbor 로그인 안내
echo -e "\n${YELLOW}[5/5] Harbor 레지스트리 로그인...${NC}"
if [ ! -f ~/.docker/config.json ] || ! grep -q "harbor.wace.me" ~/.docker/config.json 2>/dev/null; then
echo -e "${YELLOW}Harbor 로그인이 필요합니다:${NC}"
echo " docker login harbor.wace.me"
else
echo -e "${GREEN}Harbor 로그인 확인됨${NC}"
fi
# 완료 메시지
echo -e "\n=========================================="
echo -e "${GREEN} 설치 준비 완료!${NC}"
echo "=========================================="
echo ""
echo "다음 단계:"
echo " 1. 환경 변수 설정: nano $INSTALL_DIR/.env"
echo " 2. Harbor 로그인: docker login harbor.wace.me"
echo " 3. 서비스 시작: cd $INSTALL_DIR && docker compose up -d"
echo ""
+130
View File
@@ -0,0 +1,130 @@
#!/bin/bash
# ============================================
# Vexplor 온프레미스 서버 초기 설정 스크립트
# 스피폭스 공장 서버용
# ============================================
# 사용법: sudo bash server-setup.sh
set -e
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo ""
echo "=========================================="
echo " Vexplor 서버 초기 설정"
echo "=========================================="
echo ""
# root 권한 확인
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}이 스크립트는 root 권한이 필요합니다.${NC}"
echo "다음 명령어로 실행하세요: sudo bash server-setup.sh"
exit 1
fi
# ============================================
# 1. Docker 설치
# ============================================
echo -e "${YELLOW}[1/5] Docker 설치 중...${NC}"
# 기존 Docker 제거
apt-get remove -y docker docker-engine docker.io containerd runc 2>/dev/null || true
# 필수 패키지 설치
apt-get update
apt-get install -y ca-certificates curl gnupg
# Docker GPG 키 추가
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
# Docker 저장소 추가
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null
# Docker 설치
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
echo -e "${GREEN}Docker 설치 완료!${NC}"
docker --version
docker compose version
# ============================================
# 2. 사용자를 docker 그룹에 추가
# ============================================
echo ""
echo -e "${YELLOW}[2/5] 사용자 권한 설정...${NC}"
# wace 사용자를 docker 그룹에 추가
usermod -aG docker wace
echo -e "${GREEN}wace 사용자를 docker 그룹에 추가했습니다.${NC}"
# ============================================
# 3. Vexplor 디렉토리 생성
# ============================================
echo ""
echo -e "${YELLOW}[3/5] Vexplor 디렉토리 생성...${NC}"
mkdir -p /opt/vexplor
chown wace:wace /opt/vexplor
echo -e "${GREEN}/opt/vexplor 디렉토리 생성 완료!${NC}"
# ============================================
# 4. Docker 서비스 시작 및 자동 시작 설정
# ============================================
echo ""
echo -e "${YELLOW}[4/5] Docker 서비스 설정...${NC}"
systemctl start docker
systemctl enable docker
echo -e "${GREEN}Docker 서비스 활성화 완료!${NC}"
# ============================================
# 5. 방화벽 설정 (필요시)
# ============================================
echo ""
echo -e "${YELLOW}[5/5] 방화벽 설정 확인...${NC}"
if command -v ufw &> /dev/null; then
ufw status
echo ""
echo "필요시 다음 포트를 개방하세요:"
echo " sudo ufw allow 80/tcp # 웹 서비스"
echo " sudo ufw allow 3001/tcp # 백엔드 API"
else
echo "ufw가 설치되어 있지 않습니다. (방화벽 설정 스킵)"
fi
# ============================================
# 완료
# ============================================
echo ""
echo "=========================================="
echo -e "${GREEN} 서버 초기 설정 완료!${NC}"
echo "=========================================="
echo ""
echo "다음 단계:"
echo " 1. 로그아웃 후 다시 로그인 (docker 그룹 적용)"
echo " exit"
echo " ssh -p 22 wace@112.168.212.142"
echo ""
echo " 2. Docker 동작 확인"
echo " docker ps"
echo ""
echo " 3. Vexplor 배포 진행"
echo " cd /opt/vexplor"
echo " # docker-compose.yml 및 .env 파일 복사 후"
echo " docker compose up -d"
echo ""
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# ============================================
# Vexplor 수동 업데이트 스크립트
# Watchtower를 기다리지 않고 즉시 업데이트할 때 사용
# ============================================
set -e
INSTALL_DIR="/opt/vexplor"
cd $INSTALL_DIR
echo "=========================================="
echo " Vexplor 수동 업데이트"
echo "=========================================="
# 1. 현재 상태 백업
echo "[1/4] 현재 설정 백업..."
docker compose config > "backup-config-$(date +%Y%m%d-%H%M%S).yml"
# 2. 최신 이미지 다운로드
echo "[2/4] 최신 이미지 다운로드..."
docker compose pull backend frontend
# 3. 서비스 재시작 (롤링 업데이트)
echo "[3/4] 서비스 재시작..."
docker compose up -d --no-deps backend
sleep 10 # 백엔드가 완전히 뜰 때까지 대기
docker compose up -d --no-deps frontend
# 4. 상태 확인
echo "[4/4] 상태 확인..."
sleep 5
docker compose ps
echo ""
echo "=========================================="
echo " 업데이트 완료!"
echo "=========================================="
echo ""
echo "로그 확인: docker compose logs -f"
+209
View File
@@ -0,0 +1,209 @@
# 디지털트윈 아키텍처 v4
## 변경사항 (v3 → v4)
| 구분 | v3 | v4 |
| :--- | :--- | :--- |
| OTA 업데이트 | 개념만 존재 | Fleet Manager + MQTT 구현 |
| 디바이스 관리 | 없음 | Device Registry 추가 |
| 상태 모니터링 | 없음 | Heartbeat + Metrics 추가 |
| 원격 제어 | 없음 | MQTT 기반 명령 추가 |
| Agent | 없음 | Fleet Agent 추가 |
---
## Mermaid 다이어그램
```mermaid
---
config:
layout: dagre
---
flowchart BT
subgraph Global_Platform["☁️ Vexplor 글로벌 플랫폼"]
direction TB
AAS_Dashboard["<b>💻 AAS 통합 대시보드</b><br>(React/Next.js)<br>• 중앙 모니터링<br>• Fleet 관리 UI"]
Global_API["<b>🌐 글로벌 API 게이트웨이</b><br>• 사용자 인증 (Auth)<br>• 고객사 라우팅<br>• Fleet API"]
subgraph Fleet_System["🎛️ Fleet Management"]
Fleet_Manager["<b>📊 Fleet Manager</b><br>• Device Registry<br>• 배포 오케스트레이션<br>• 상태 모니터링"]
MQTT_Broker["<b>📡 MQTT Broker</b><br>(Mosquitto/EMQX)<br>• 실시간 통신<br>• 10,000+ 연결"]
Monitoring["<b>📈 Monitoring</b><br>(Prometheus/Grafana)<br>• 메트릭 수집<br>• 알림"]
end
Update_Server["<b>🚀 배포/업데이트 매니저</b><br>• Docker 이미지 레지스트리 (Harbor)<br>• 버전 관리<br>• Canary 배포"]
end
subgraph Local_Server["스피폭스 사내 서버 (Local Server)"]
Fleet_Agent_A["<b>🤖 Fleet Agent</b><br>• MQTT 연결<br>• Heartbeat (30초)<br>• 원격 명령 실행<br>• Docker 관리"]
VEX_Engine["<b>VEX Flow 엔진</b><br>데이터 수집/처리"]
Customer_DB[("<b>사내 통합 DB</b><br>(모든 데이터 보유)")]
Watchtower_A["<b>🐋 Watchtower</b><br>이미지 자동 업데이트"]
end
subgraph Edge_Internals["🖥️ 엣지 디바이스 (Store & Forward)"]
Edge_Collector["<b>수집/가공</b><br>(Python)"]
Edge_Buffer[("<b>💾 로컬 버퍼</b><br>(TimescaleDB)<br>단절 시 임시 저장")]
Edge_Sender["<b>📤 전송 매니저</b><br>(Priority Queue)"]
Edge_Retry_Queue[("<b>🕒 재전송 큐</b><br>(SQLite/File)")]
end
subgraph Factory_A["🏭 스피폭스 공장 현장 (Factory Floor)"]
Edge_Internals
PLC_A["PLC / 센서"]
end
subgraph Customer_A["🏢 고객사 A: 스피폭스 (사내망)"]
Local_Server
Factory_A
end
subgraph Local_Server_B["고객사 B 사내 서버"]
Fleet_Agent_B["<b>🤖 Fleet Agent</b>"]
Watchtower_B["<b>🐋 Watchtower</b>"]
end
subgraph Customer_B["🏭 고객사 B (확장 예정)"]
Local_Server_B
end
subgraph Local_Server_N["고객사 N 사내 서버"]
Fleet_Agent_N["<b>🤖 Fleet Agent</b>"]
end
subgraph Customer_N["🏭 고객사 N (10,000개)"]
Local_Server_N
end
%% 대시보드 연결
AAS_Dashboard <--> Global_API
AAS_Dashboard <--> Fleet_Manager
%% Fleet System 내부 연결
Fleet_Manager <--> MQTT_Broker
Fleet_Manager <--> Monitoring
Fleet_Manager <--> Update_Server
%% 공장 내부 연결
PLC_A <--> Edge_Collector
Edge_Collector --> Edge_Buffer
Edge_Buffer --> Edge_Sender
Edge_Sender -- ① 정상 전송 --> VEX_Engine
Edge_Sender -- ② 전송 실패 시 --> Edge_Retry_Queue
Edge_Retry_Queue -. ③ 네트워크 복구 시 재전송 .-> Edge_Sender
VEX_Engine <--> Customer_DB
%% Fleet Agent 연결 (MQTT - Outbound Only)
Fleet_Agent_A == 📡 MQTT (Heartbeat/명령) ==> MQTT_Broker
Fleet_Agent_B == 📡 MQTT ==> MQTT_Broker
Fleet_Agent_N == 📡 MQTT ==> MQTT_Broker
%% Agent ↔ 로컬 컴포넌트
Fleet_Agent_A <--> VEX_Engine
Fleet_Agent_A <--> Watchtower_A
Fleet_Agent_A <--> Customer_DB
%% OTA 업데이트 (Pull 방식)
Update_Server -. 이미지 배포 .-> Watchtower_A
Update_Server -. 이미지 배포 .-> Watchtower_B
Watchtower_A -. 컨테이너 업데이트 .-> VEX_Engine
%% 엣지 업데이트
VEX_Engine -. 엣지 업데이트 .-> Edge_Collector
%% 스타일
AAS_Dashboard:::user
Global_API:::global
Update_Server:::global
Fleet_Manager:::fleet
MQTT_Broker:::fleet
Monitoring:::fleet
VEX_Engine:::localServer
Customer_DB:::localServer
Fleet_Agent_A:::agent
Fleet_Agent_B:::agent
Fleet_Agent_N:::agent
Watchtower_A:::agent
Watchtower_B:::agent
Edge_Collector:::edge
Edge_Buffer:::edgedb
Edge_Sender:::edge
Edge_Retry_Queue:::fail
PLC_A:::factory
classDef factory fill:#e1f5fe,stroke:#01579b,stroke-width:2px
classDef edge fill:#fff9c4,stroke:#fbc02d,stroke-width:2px
classDef edgedb fill:#fff9c4,stroke:#fbc02d,stroke-width:2px,stroke-dasharray: 5 5
classDef localServer fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef global fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px
classDef user fill:#ffebee,stroke:#c62828,stroke-width:2px
classDef fleet fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
classDef agent fill:#fff3e0,stroke:#ef6c00,stroke-width:2px
classDef fail fill:#ffebee,stroke:#c62828,stroke-width:2px,stroke-dasharray: 5 5
linkStyle 8 stroke:#2e7d32,stroke-width:2px,fill:none
linkStyle 9 stroke:#c62828,stroke-width:2px,fill:none
linkStyle 10 stroke:#fbc02d,stroke-width:2px,stroke-dasharray: 5 5,fill:none
```
---
## 추가된 컴포넌트 설명
### 1. Fleet Management (신규)
| 컴포넌트 | 역할 |
| :--- | :--- |
| **Fleet Manager** | 10,000개 디바이스 등록/관리, 배포 오케스트레이션 |
| **MQTT Broker** | 실시간 양방향 통신 (Outbound Only 유지) |
| **Monitoring** | Prometheus + Grafana, 메트릭 수집 & 알림 |
### 2. Fleet Agent (각 공장 서버에 설치)
| 기능 | 설명 |
| :--- | :--- |
| **MQTT 연결** | 글로벌 플랫폼과 상시 연결 (Outbound) |
| **Heartbeat** | 30초마다 상태 보고 |
| **원격 명령** | 업데이트, 재시작, 설정 변경 수신 |
| **Docker 관리** | 컨테이너 상태 모니터링 & 제어 |
### 3. Watchtower (기존 유지)
- Harbor에서 새 이미지 자동 Pull
- Fleet Agent의 명령으로 즉시 업데이트 가능
---
## 통신 흐름 비교
### v3 (기존)
```
보안 커넥터 ←→ 글로벌 API (양방향 터널)
```
### v4 (신규)
```
Fleet Agent ──→ MQTT Broker (Outbound Only)
←── 명령 수신 (Subscribe)
──→ 상태 보고 (Publish)
Watchtower ──→ Harbor (Pull Only)
```
**장점:**
- 방화벽 Inbound 규칙 불필요
- 10,000개 동시 연결 가능
- 실시간 명령 전달
---
## 데이터 흐름
```
[공장 → 글로벌]
PLC → 엣지 → VEX Flow → Fleet Agent → MQTT → Fleet Manager → Dashboard
[글로벌 → 공장]
Dashboard → Fleet Manager → MQTT → Fleet Agent → Docker/VEX Flow
```
+725
View File
@@ -0,0 +1,725 @@
# Fleet Management 시스템 구축 계획서
## 개요
**목표:** 10,000개 이상의 온프레미스 공장 서버를 중앙에서 효율적으로 관리
**현재 상태:** 1개 업체 (스피폭스), Watchtower 기반 자동 업데이트
**목표 상태:** 10,000개 업체, 실시간 모니터링 & 원격 제어 가능
---
## 목차
1. [아키텍처 설계](#1-아키텍처-설계)
2. [Phase별 구현 계획](#2-phase별-구현-계획)
3. [핵심 컴포넌트 상세](#3-핵심-컴포넌트-상세)
4. [데이터베이스 스키마](#4-데이터베이스-스키마)
5. [API 설계](#5-api-설계)
6. [기술 스택](#6-기술-스택)
7. [일정 및 마일스톤](#7-일정-및-마일스톤)
8. [리스크 및 대응](#8-리스크-및-대응)
---
## 1. 아키텍처 설계
### 1.1 전체 아키텍처
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Vexplor 글로벌 플랫폼 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Web UI │ │ Fleet API │ │ Config │ │ Monitoring │ │
│ │ (Dashboard) │ │ Gateway │ │ Server │ │ & Alerts │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │ │
│ └────────────────┼────────────────┼────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Message │ │ Device │ │
│ │ Broker │ │ Registry │ │
│ │ (MQTT) │ │ (Redis) │ │
│ └──────┬──────┘ └─────────────┘ │
│ │ │
└──────────────────────────┼────────────────────────────────────────────┘
│ MQTT (TLS)
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Agent │ │ Agent │ │ Agent │
│ 스피폭스 │ │ 엔키드 │ │ 고객 N │
└─────────┘ └─────────┘ └─────────┘
│ │ │
┌─────────┐ ┌─────────┐ ┌─────────┐
│ Vexplor │ │ Vexplor │ │ Vexplor │
│ Backend │ │ Backend │ │ Backend │
│Frontend │ │Frontend │ │Frontend │
│ DB │ │ DB │ │ DB │
└─────────┘ └─────────┘ └─────────┘
```
### 1.2 통신 흐름
```
[공장 서버 → 글로벌]
1. Agent 시작 시 MQTT 연결 (Outbound Only)
2. 주기적 Heartbeat 전송 (30초)
3. 상태/메트릭 보고 (5분)
4. 로그 전송 (선택적)
[글로벌 → 공장 서버]
1. 업데이트 명령
2. 설정 변경
3. 재시작 명령
4. 데이터 요청
```
---
## 2. Phase별 구현 계획
### Phase 1: 기반 구축 (1~10개 업체)
**기간:** 2주
| 구현 항목 | 설명 | 우선순위 |
| :--- | :--- | :--- |
| Device Registry API | 디바이스 등록/조회 | P0 |
| Heartbeat API | 상태 보고 수신 | P0 |
| 기본 대시보드 | 디바이스 목록/상태 표시 | P1 |
| Agent 기본 버전 | Heartbeat 전송 기능 | P0 |
**산출물:**
- `POST /api/fleet/devices/register`
- `POST /api/fleet/devices/heartbeat`
- `GET /api/fleet/devices`
- Agent Docker 이미지
---
### Phase 2: 실시간 통신 (10~100개 업체)
**기간:** 4주
| 구현 항목 | 설명 | 우선순위 |
| :--- | :--- | :--- |
| MQTT 브로커 설치 | Eclipse Mosquitto | P0 |
| Agent MQTT 연결 | 상시 연결 유지 | P0 |
| 원격 명령 기능 | 업데이트/재시작 명령 | P1 |
| 실시간 상태 업데이트 | WebSocket → 대시보드 | P1 |
**산출물:**
- MQTT 브로커 (Docker)
- Agent v2 (MQTT 지원)
- 원격 명령 UI
---
### Phase 3: 배포 관리 (100~500개 업체)
**기간:** 6주
| 구현 항목 | 설명 | 우선순위 |
| :--- | :--- | :--- |
| 버전 관리 시스템 | 릴리즈 버전 관리 | P0 |
| 단계적 롤아웃 | Canary 배포 | P0 |
| 롤백 기능 | 이전 버전 복구 | P0 |
| 그룹 관리 | 지역/업종별 그룹핑 | P1 |
| 배포 스케줄링 | 시간대별 배포 | P2 |
**산출물:**
- Release Management UI
- Deployment Pipeline
- Rollback 자동화
---
### Phase 4: 모니터링 강화 (500~2,000개 업체)
**기간:** 6주
| 구현 항목 | 설명 | 우선순위 |
| :--- | :--- | :--- |
| 메트릭 수집 | CPU/Memory/Disk | P0 |
| 알림 시스템 | Slack/Email/SMS | P0 |
| 로그 중앙화 | 원격 로그 수집 | P1 |
| 이상 탐지 | 자동 장애 감지 | P1 |
| SLA 대시보드 | 가용성 리포트 | P2 |
**산출물:**
- Prometheus + Grafana
- Alert Manager
- Log Aggregator (Loki)
---
### Phase 5: 대규모 확장 (2,000~10,000개 업체)
**기간:** 8주
| 구현 항목 | 설명 | 우선순위 |
| :--- | :--- | :--- |
| MQTT 클러스터링 | 고가용성 브로커 | P0 |
| 샤딩 | 지역별 분산 | P0 |
| 자동 프로비저닝 | 신규 업체 자동 설정 | P1 |
| API Rate Limiting | 과부하 방지 | P1 |
| 멀티 리전 | 글로벌 분산 | P2 |
**산출물:**
- MQTT Cluster (EMQX)
- Regional Gateway
- Auto-provisioning System
---
## 3. 핵심 컴포넌트 상세
### 3.1 Fleet Agent (공장 서버에 설치)
```
┌─────────────────────────────────────────┐
│ Fleet Agent │
├─────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ │
│ │ MQTT │ │ Command │ │
│ │ Client │ │ Executor │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────┐ │
│ │ Core Controller │ │
│ └─────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Metrics │ │ Docker │ │
│ │ Collector │ │ Manager │ │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────┘
```
**주요 기능:**
- MQTT 연결 유지 (자동 재연결)
- Heartbeat 전송 (30초)
- 시스템 메트릭 수집
- Docker 컨테이너 관리
- 원격 명령 실행
### 3.2 Fleet Manager (글로벌 서버)
**주요 기능:**
- 디바이스 등록/인증
- 상태 모니터링
- 배포 오케스트레이션
- 설정 관리
- 알림 발송
### 3.3 Message Broker (MQTT)
**선택지:**
| 옵션 | 장점 | 단점 | 추천 규모 |
| :--- | :--- | :--- | :--- |
| Mosquitto | 가볍고 간단 | 클러스터링 어려움 | ~1,000 |
| EMQX | 클러스터링, 고성능 | 복잡함 | 1,000~100,000 |
| HiveMQ | 엔터프라이즈급 | 비용 | 100,000+ |
**권장:** Phase 1~3은 Mosquitto, Phase 4~5는 EMQX
---
## 4. 데이터베이스 스키마
### 4.1 디바이스 테이블
```sql
-- 디바이스 (공장 서버) 정보
CREATE TABLE fleet_devices (
id SERIAL PRIMARY KEY,
device_id VARCHAR(50) UNIQUE NOT NULL, -- 고유 식별자
company_code VARCHAR(20) NOT NULL, -- 회사 코드
device_name VARCHAR(100), -- 표시 이름
-- 연결 정보
ip_address VARCHAR(45),
last_seen_at TIMESTAMPTZ,
is_online BOOLEAN DEFAULT false,
-- 버전 정보
agent_version VARCHAR(20),
app_version VARCHAR(20),
-- 시스템 정보
os_info JSONB,
hardware_info JSONB,
-- 그룹/태그
device_group VARCHAR(50),
tags JSONB DEFAULT '[]',
-- 메타
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
FOREIGN KEY (company_code) REFERENCES company_info(company_code)
);
CREATE INDEX idx_fleet_devices_company ON fleet_devices(company_code);
CREATE INDEX idx_fleet_devices_online ON fleet_devices(is_online);
CREATE INDEX idx_fleet_devices_group ON fleet_devices(device_group);
```
### 4.2 Heartbeat 로그 테이블
```sql
-- Heartbeat 기록 (TimescaleDB 권장)
CREATE TABLE fleet_heartbeats (
id BIGSERIAL,
device_id VARCHAR(50) NOT NULL,
received_at TIMESTAMPTZ DEFAULT NOW(),
-- 상태
status VARCHAR(20), -- OK, WARNING, ERROR
uptime_seconds BIGINT,
-- 메트릭
cpu_percent DECIMAL(5,2),
memory_percent DECIMAL(5,2),
disk_percent DECIMAL(5,2),
-- 컨테이너 상태
containers JSONB,
PRIMARY KEY (device_id, received_at)
);
-- TimescaleDB 하이퍼테이블 변환 (선택)
-- SELECT create_hypertable('fleet_heartbeats', 'received_at');
```
### 4.3 배포 테이블
```sql
-- 릴리즈 버전 관리
CREATE TABLE fleet_releases (
id SERIAL PRIMARY KEY,
version VARCHAR(20) NOT NULL,
release_type VARCHAR(20), -- stable, beta, hotfix
-- 이미지 정보
backend_image VARCHAR(200),
frontend_image VARCHAR(200),
agent_image VARCHAR(200),
-- 변경사항
changelog TEXT,
-- 상태
status VARCHAR(20) DEFAULT 'draft', -- draft, testing, released, deprecated
released_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 배포 작업
CREATE TABLE fleet_deployments (
id SERIAL PRIMARY KEY,
release_id INTEGER REFERENCES fleet_releases(id),
-- 배포 대상
target_type VARCHAR(20), -- all, group, specific
target_value VARCHAR(100), -- 그룹명 또는 device_id
-- 롤아웃 설정
rollout_strategy VARCHAR(20), -- immediate, canary, scheduled
rollout_percentage INTEGER,
scheduled_at TIMESTAMPTZ,
-- 상태
status VARCHAR(20) DEFAULT 'pending',
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
-- 결과
total_devices INTEGER,
success_count INTEGER DEFAULT 0,
failed_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 개별 디바이스 배포 상태
CREATE TABLE fleet_deployment_status (
id SERIAL PRIMARY KEY,
deployment_id INTEGER REFERENCES fleet_deployments(id),
device_id VARCHAR(50),
status VARCHAR(20) DEFAULT 'pending', -- pending, downloading, installing, completed, failed
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
UNIQUE(deployment_id, device_id)
);
```
### 4.4 알림 규칙 테이블
```sql
-- 알림 규칙
CREATE TABLE fleet_alert_rules (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
-- 조건
condition_type VARCHAR(50), -- offline, version_mismatch, high_cpu, etc.
condition_value JSONB,
threshold_minutes INTEGER, -- 조건 지속 시간
-- 알림 채널
notify_channels JSONB, -- ["slack", "email"]
notify_targets JSONB, -- 수신자 목록
-- 상태
is_enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 알림 기록
CREATE TABLE fleet_alerts (
id SERIAL PRIMARY KEY,
rule_id INTEGER REFERENCES fleet_alert_rules(id),
device_id VARCHAR(50),
alert_type VARCHAR(50),
message TEXT,
severity VARCHAR(20), -- info, warning, critical
-- 해결 상태
status VARCHAR(20) DEFAULT 'open', -- open, acknowledged, resolved
resolved_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
---
## 5. API 설계
### 5.1 Device Management API
```yaml
# 디바이스 등록
POST /api/fleet/devices/register
Request:
device_id: string (required)
company_code: string (required)
device_name: string
agent_version: string
os_info: object
Response:
success: boolean
data:
device_id: string
mqtt_credentials:
broker_url: string
username: string
password: string
# 디바이스 목록 조회
GET /api/fleet/devices
Query:
company_code: string
is_online: boolean
device_group: string
page: number
limit: number
Response:
success: boolean
data: Device[]
pagination: { total, page, limit }
# 디바이스 상세 조회
GET /api/fleet/devices/:deviceId
Response:
success: boolean
data:
device: Device
recent_heartbeats: Heartbeat[]
recent_alerts: Alert[]
```
### 5.2 Heartbeat API
```yaml
# Heartbeat 전송
POST /api/fleet/devices/:deviceId/heartbeat
Request:
status: string
uptime_seconds: number
metrics:
cpu_percent: number
memory_percent: number
disk_percent: number
containers:
- name: string
status: string
version: string
Response:
success: boolean
data:
commands: Command[] # 대기 중인 명령 반환
```
### 5.3 Deployment API
```yaml
# 배포 생성
POST /api/fleet/deployments
Request:
release_id: number
target_type: "all" | "group" | "specific"
target_value: string
rollout_strategy: "immediate" | "canary" | "scheduled"
rollout_percentage: number
scheduled_at: datetime
Response:
success: boolean
data:
deployment_id: number
estimated_devices: number
# 배포 상태 조회
GET /api/fleet/deployments/:deploymentId
Response:
success: boolean
data:
deployment: Deployment
status_summary:
pending: number
in_progress: number
completed: number
failed: number
device_statuses: DeploymentStatus[]
# 배포 롤백
POST /api/fleet/deployments/:deploymentId/rollback
Response:
success: boolean
data:
rollback_deployment_id: number
```
### 5.4 Command API
```yaml
# 원격 명령 전송
POST /api/fleet/devices/:deviceId/commands
Request:
command_type: "update" | "restart" | "config" | "logs"
payload: object
Response:
success: boolean
data:
command_id: string
status: "queued"
# 명령 결과 조회
GET /api/fleet/commands/:commandId
Response:
success: boolean
data:
command_id: string
status: "queued" | "sent" | "executing" | "completed" | "failed"
result: object
```
---
## 6. 기술 스택
### 6.1 글로벌 플랫폼
| 컴포넌트 | 기술 | 비고 |
| :--- | :--- | :--- |
| Fleet API | Node.js (기존 backend-node 확장) | 기존 코드 재사용 |
| Message Broker | Mosquitto → EMQX | 단계적 전환 |
| Device Registry | Redis | 빠른 조회 |
| Database | PostgreSQL | 기존 DB 확장 |
| Time-series DB | TimescaleDB | Heartbeat 저장 |
| Monitoring | Prometheus + Grafana | 메트릭 시각화 |
| Log | Loki | 로그 중앙화 |
| Alert | AlertManager | 알림 관리 |
### 6.2 Fleet Agent
| 컴포넌트 | 기술 | 비고 |
| :--- | :--- | :--- |
| Runtime | Go 또는 Node.js | 가볍고 안정적 |
| MQTT Client | Paho MQTT | 표준 라이브러리 |
| Docker SDK | Docker API | 컨테이너 관리 |
| Metrics | gopsutil | 시스템 메트릭 |
### 6.3 대시보드
| 컴포넌트 | 기술 | 비고 |
| :--- | :--- | :--- |
| UI Framework | Next.js (기존) | 기존 코드 확장 |
| Real-time | Socket.io | 실시간 상태 |
| Charts | Recharts | 메트릭 시각화 |
| Map | Leaflet | 지역별 표시 |
---
## 7. 일정 및 마일스톤
### 7.1 전체 일정
```
2025 Q1 2025 Q2 2025 Q3
│ │ │
├── Phase 1 (2주) ─────────┤ │
│ Device Registry │ │
│ Heartbeat API │ │
│ 기본 대시보드 │ │
│ │ │
│ ├── Phase 2 (4주) ──────────┤ │
│ │ MQTT 브로커 │ │
│ │ Agent v2 │ │
│ │ 원격 명령 │ │
│ │ │ │
│ │ ├── Phase 3 (6주) ──────┤
│ │ │ 버전 관리 │
│ │ │ Canary 배포 │
│ │ │ 롤백 │
│ │ │ │
```
### 7.2 상세 마일스톤
| 마일스톤 | 목표 | 완료 기준 | 예상 일정 |
| :--- | :--- | :--- | :--- |
| M1 | Device Registry | 디바이스 등록/조회 API 완료 | 1주차 |
| M2 | Heartbeat | 상태 보고 & 저장 완료 | 2주차 |
| M3 | Basic Dashboard | 디바이스 목록 UI 완료 | 2주차 |
| M4 | MQTT Setup | 브로커 설치 & 연결 테스트 | 4주차 |
| M5 | Agent v2 | MQTT 기반 Agent 완료 | 6주차 |
| M6 | Remote Command | 업데이트/재시작 명령 완료 | 8주차 |
| M7 | Release Mgmt | 버전 관리 UI 완료 | 10주차 |
| M8 | Canary Deploy | 단계적 배포 완료 | 14주차 |
---
## 8. 리스크 및 대응
### 8.1 기술적 리스크
| 리스크 | 영향 | 확률 | 대응 |
| :--- | :--- | :--- | :--- |
| MQTT 연결 불안정 | 높음 | 중간 | 자동 재연결, 오프라인 큐 |
| 대량 동시 접속 | 높음 | 높음 | 클러스터링, 로드밸런싱 |
| 보안 취약점 | 높음 | 낮음 | TLS 필수, 인증 강화 |
| 네트워크 단절 | 중간 | 높음 | 로컬 캐시, 재전송 로직 |
### 8.2 운영 리스크
| 리스크 | 영향 | 확률 | 대응 |
| :--- | :--- | :--- | :--- |
| 잘못된 배포 | 높음 | 중간 | Canary 배포, 자동 롤백 |
| 모니터링 누락 | 중간 | 중간 | 다중 알림 채널 |
| 버전 파편화 | 중간 | 높음 | 강제 업데이트 정책 |
---
## 9. 다음 단계
### 즉시 시작할 작업 (Phase 1)
1. **Device Registry 테이블 생성**
- `fleet_devices` 테이블 마이그레이션
2. **Fleet API 엔드포인트 개발**
- `POST /api/fleet/devices/register`
- `POST /api/fleet/devices/:deviceId/heartbeat`
- `GET /api/fleet/devices`
3. **Agent 기본 버전 개발**
- Docker 이미지로 배포
- 주기적 Heartbeat 전송
4. **대시보드 기본 화면**
- 디바이스 목록
- 온라인/오프라인 상태 표시
---
## 부록
### A. MQTT 토픽 설계
```
vexplor/
├── devices/
│ ├── {device_id}/
│ │ ├── status # 상태 보고 (Agent → Server)
│ │ ├── metrics # 메트릭 보고 (Agent → Server)
│ │ ├── commands # 명령 수신 (Server → Agent)
│ │ └── responses # 명령 응답 (Agent → Server)
│ │
├── broadcasts/
│ ├── all # 전체 공지
│ └── groups/{group} # 그룹별 공지
└── system/
├── announcements # 시스템 공지
└── maintenance # 점검 알림
```
### B. Agent 설정 파일
```yaml
# /opt/vexplor/agent/config.yaml
device:
id: "SPIFOX-001"
company_code: "SPIFOX"
name: "스피폭스 메인 서버"
mqtt:
broker: "mqtts://mqtt.vexplor.com:8883"
username: "${MQTT_USERNAME}"
password: "${MQTT_PASSWORD}"
keepalive: 60
reconnect_interval: 5
heartbeat:
interval: 30 # seconds
metrics:
enabled: true
interval: 300 # 5 minutes
collect:
- cpu
- memory
- disk
- network
docker:
socket: "/var/run/docker.sock"
managed_containers:
- vexplor-backend
- vexplor-frontend
- vexplor-db
```
### C. 참고 자료
- [EMQX Documentation](https://docs.emqx.com/)
- [Eclipse Mosquitto](https://mosquitto.org/)
- [AWS IoT Device Management](https://aws.amazon.com/iot-device-management/)
- [Google Cloud IoT Core](https://cloud.google.com/iot-core)
- [HashiCorp Nomad](https://www.nomadproject.io/)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

+1 -1
View File
@@ -39,6 +39,6 @@ RUN mkdir -p logs uploads data && \
chown -R appuser:appgroup /app && \
chmod -R 755 /app
EXPOSE 8080
EXPOSE 3001
USER appuser
CMD ["node", "dist/app.js"]
+3 -3
View File
@@ -1,6 +1,6 @@
services:
# Node.js 백엔드 (운영용)
backend:
# Node.js 백엔드
plm-backend:
build:
context: ../../backend-node
dockerfile: ../docker/prod/backend.Dockerfile # 운영용 Dockerfile
@@ -25,7 +25,7 @@ services:
- EXPRESSWAY_API_KEY=${EXPRESSWAY_API_KEY:-}
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
interval: 30s
timeout: 10s
retries: 3
+21 -13
View File
@@ -1,22 +1,30 @@
services:
# Next.js 프론트엔드
frontend:
# Next.js 프론트엔드
plm-frontend:
build:
context: ../../frontend
dockerfile: ../docker/prod/frontend.Dockerfile
args:
- NEXT_PUBLIC_API_URL=http://39.117.244.52:8080/api
container_name: pms-frontend-linux
ports:
- "5555:5555"
- NEXT_PUBLIC_API_URL=https://api.vexplor.com
container_name: plm-frontend
restart: always
environment:
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=http://39.117.244.52:8080/api
networks:
- pms-network
restart: unless-stopped
NODE_ENV: production
NEXT_PUBLIC_API_URL: https://api.vexplor.com
NEXT_TELEMETRY_DISABLED: "1"
PORT: "3000"
HOSTNAME: 0.0.0.0
volumes:
- /home/vexplor/frontend_data:/app/data
labels:
- traefik.enable=true
- traefik.http.routers.frontend.rule=Host(`v1.vexplor.com`)
- traefik.http.routers.frontend.entrypoints=websecure,web
- traefik.http.routers.frontend.tls=true
- traefik.http.routers.frontend.tls.certresolver=le
- traefik.http.services.frontend.loadbalancer.server.port=3000
networks:
pms-network:
driver: bridge
default:
name: toktork_server_default
external: true
+4 -4
View File
@@ -22,8 +22,8 @@ COPY . .
# Disable telemetry during the build
ENV NEXT_TELEMETRY_DISABLED 1
# 빌드 시 환경변수 설정 (ARG로 받아서 ENV로 설정)
ARG NEXT_PUBLIC_API_URL=http://192.168.0.70:8080/api
# 빌드 시 환경변수 설정
ARG NEXT_PUBLIC_API_URL=https://api.vexplor.com
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
# Build the application
@@ -55,9 +55,9 @@ COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
USER nextjs
EXPOSE 5555
EXPOSE 3000
ENV PORT 5555
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
# Next.js start 명령어 사용
+451
View File
@@ -0,0 +1,451 @@
# AI 기반 화면 자동 생성 시스템 설계서
## 1. 개요
### 1.1 목표
사용자가 자연어로 "자재입고페이지 만들어줘"라고 요청하면, AI가 자동으로 완성된 화면과 워크플로우를 생성
### 1.2 핵심 가치
- **제로 코딩**: 대화만으로 화면 생성
- **워크플로우 포함**: 저장 시 재고 업데이트 등 자동 설정
- **기존 시스템 활용**: 새 인프라 최소화, 기존 API 재사용
### 1.3 예상 시나리오
```
사용자: "자재입고페이지 만들어줘"
AI: "입고관리 화면 41개를 분석했습니다.
✅ 자동 적용 (90%+ 사용)
- 레이아웃: 분할화면 (좌: 목록, 우: 상세)
- 필수 필드: 입고번호, 입고일자, 품목코드, 수량
💡 추천 기능 (60% 사용)
- 저장 시 재고 자동 증가
[추가하기]
선택 필드
[ ] 창고 (68%) [ ] 공급업체 (61%)
[화면 생성하기]"
사용자: "재고 연동 추가하고, 창고 필드도 넣어줘"
AI: "화면을 생성했습니다.
- 화면명: 자재 입고관리
- 레이아웃: 분할화면
- 필드: 입고번호, 입고일자, 품목, 수량, 창고
- 워크플로우: 저장 시 inventory_stock +qty
[미리보기] [메뉴에 추가]"
```
---
## 2. 시스템 아키텍처
```
┌───────────────────────────────────────────────────────────────┬───────────────────────────────┐
│ 신규 개발 (AI 담당) │ 기존 드래그앤드랍 시스템 │
│ ┌─────────────┐ ┌──────────────────────────────┐ │ ┌─────────────────────┐ │
│ │ Chat UI │────▶│ AI 서비스 │ │ │ D&D UI Builder │ │
│ └─────────────┘ │ • LLM 호출 (Claude/GPT) │ │ └─────────┬──────────┘ │
│ │ • 패턴 분석 + RAG 검색 │ │ │ │
│ │ • JSON 생성 │ │ ▼ │
│ └──────────────┬──────────────┘ │ ┌─────────────────────┐ │
└─────────────────────────────────────┼─────────────────────---──┴─┼─────────────────────┬─────┘
│ 기존 API 호출 │ 기존 API 호출 │
▼ ┼─────────────────────┘
┌-------------------------------------------------------------------------------┐
│ 기존 시스템 (vexplor) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Screen API │ │ Flow API │ │ Dataflow API│ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ PostgreSQL │ │
│ └─────────────┘ │
└-------------------------------------------------------------------------------┘
```
### 설계 원칙
| 원칙 | 설명 |
| --------------------- | --------------------------------- |
| 기존 코드 변경 최소화 | 기존 백엔드/프론트 수정 없음 |
| 기존 API 재사용 | AI가 기존 API를 "사용자처럼" 호출 |
| RAG 기반 지식 주입 | 필요한 패턴만 동적으로 LLM에 주입 |
| **Assistive AI** | AI는 결정하지 않고, 사용자의 결정을 돕는다 |
---
## 3. AI가 화면을 "알아서" 만드는 방법
### 3.1 지식 소스
| 데이터 | 테이블 | 활용 |
| --------------- | ---------------------------------- | ------------------- |
| 기존 화면 | screen_definitions, screen_layouts | 패턴 학습 |
| 테이블 라벨 | table_labels | 테이블 검색 |
| 컬럼 라벨 | column_labels | 필드→컴포넌트 매핑 |
| 워크플로우 패턴 | workflow_patterns (신규) | 비즈니스 로직 |
### 3.2 통계 기반 패턴 분석
> **핵심 아이디어**: 사용자들이 이미 만든 화면들을 분석해서 "입고 화면은 보통 이렇게 생겼더라"를 알아내는 것
#### 예시: "입고페이지 만들어줘" 요청 시
**Step 1. 테이블 찾기**
```sql
-- "입고"라는 단어로 table_labels 검색
SELECT table_name, table_label FROM table_labels
WHERE table_label LIKE '%입고%';
-- 결과: inbound_mng (입고관리)
```
**Step 2. 이 테이블을 쓰는 기존 화면 찾기**
```sql
-- inbound_mng 테이블을 사용하는 화면들 조회
SELECT * FROM screen_definitions
WHERE table_name = 'inbound_mng';
-- 결과: 41개 화면 발견!
```
**Step 3. 41개 화면의 "공통점" 분석**
레이아웃 통계:
| 레이아웃 | 개수 | 비율 |
| -------------------------------- | ----- | -------- |
| split-panel (좌: 목록, 우: 상세) | 32개 | **78%** |
| table-list (목록만) | 6개 | 15% |
| form (폼만) | 3개 | 7% |
필드 사용 통계:
| 필드 | 사용 화면 수 | 비율 |
| ---------------------- | ------------ | -------- |
| inbound_no (입고번호) | 41개 | **100%** |
| inbound_date (입고일자)| 41개 | **100%** |
| item_code (품목코드) | 40개 | **98%** |
| qty (수량) | 39개 | **95%** |
| warehouse_code (창고) | 28개 | 68% |
| supplier_code (공급업체)| 25개 | 61% |
**Step 4. 확신도(Confidence)에 따라 다르게 처리**
AI는 통계 결과의 확신도에 따라 행동을 다르게 합니다:
| 확신도 | 기준 | AI 행동 | 예시 |
| ------ | ---- | ------- | ---- |
| **높음** | 90%+ | 자동 적용 | 필수 필드(입고번호, 일자) 자동 추가 |
| **중간** | 60~90% | 추천하며 확인 | "분할화면으로 만들까요? (78% 사용)" |
| **낮음** | 60% 미만 | 옵션 나열 | "창고 필드 추가할까요? (68%)" |
```
AI 판단 예시:
- 레이아웃: split-panel (78%) → "분할화면으로 생성합니다"
- 필수 필드: 입고번호, 입고일자 (100%) → 자동 추가
- 워크플로우: 입고→재고 (60%) → "💡 재고 자동 연동 추가할까요?"
- 선택 필드: 창고 (68%) → "추가 필드: [ ] 창고 [ ] 공급업체"
```
**핵심**: 확실한 것은 빠르게 처리하고, 애매한 것은 사용자에게 물어본다.
#### 비유: "맛집 추천 AI"와 같은 원리
| 맛집 추천 AI | 화면 생성 AI |
| ------------ | ------------ |
| "강남에서 점심 뭐 먹지?" | "입고페이지 만들어줘" |
| 강남 식당 1000개 리뷰 분석 | 기존 입고 화면 41개 분석 |
| "70%가 파스타집, 평균 1.5만원" | "78%가 분할화면, 100%가 입고번호 사용" |
| "파스타집 추천드릴까요?" | "분할화면으로 만들까요?" |
### 3.3 RAG 기반 동적 지식 주입 (핵심)
> **문제**: 모든 도메인 지식을 프롬프트에 넣으면 Context Window 초과
> **해결**: 필요한 지식만 검색해서 동적 주입
```
사용자: "입고페이지 만들어줘"
1. 키워드 추출: "입고"
2. workflow_patterns 검색 → "입고→재고 증가" 패턴 발견
3. LLM 프롬프트에 해당 패턴만 주입
4. 재고 증가 로직 포함된 화면 생성
```
#### 장점
| 장점 | 설명 |
| ------------- | ---------------------------------- |
| 토큰 절약 | 관련 패턴 1-2개만 주입 |
| 확장성 | 패턴 1000개여도 프롬프트 길이 동일 |
| 회사별 커스텀 | company_code로 회사별 패턴 적용 |
### 3.4 멀티테넌트 & Fallback 전략
회사마다 테이블명이 다르거나, 신규 회사라 기존 화면이 없을 수 있습니다.
**데이터 검색 우선순위:**
```
1순위: 해당 회사의 기존 화면 (가장 정확)
↓ 없거나 부족하면
2순위: 전체 회사의 익명화된 통계 (company_code 제외, 패턴만)
↓ 그래도 부족하면
3순위: vexplor 표준 템플릿 (기본 레이아웃 + 필수 필드)
```
**여러 테이블이 검색될 때:**
```
사용자: "입고페이지 만들어줘"
AI: "입고 관련 테이블이 3개 있습니다:
1. 자재입고관리 (material_inbound)
2. 제품입고관리 (product_inbound)
3. 반품입고관리 (return_inbound)
어떤 테이블로 만들까요?"
```
### 3.5 기존 시스템 분석 결과
**발견된 학습 가능 데이터:**
- `transferData` 액션: 14개 (발주→입고, 수주→출고 등)
- 제어관리 프레임워크: `dataflowControlService.ts` 존재
- 입고→재고 규칙: 아직 정의되지 않음 → AI가 생성하면 됨
---
## 4. 개발 범위
### 4.1 AI 담당 (신규)
| 작업 | 파일 | 우선순위 |
| ------------------------------------ | ----------------------------- | ------------ |
| AI 채팅 API | `aiRoutes.ts` | P0 |
| 화면 패턴 분석 | `screenAnalyzer.ts` | P0 |
| LLM 호출 | `llmService.ts` | P0 |
| **워크플로우 패턴 검색 (RAG)** | `workflowPatternService.ts` | **P0** |
| 채팅 UI | `AIChatPanel.tsx` | P0 |
| **workflow_patterns 테이블** | DB | **P0** |
```typescript
// 패턴 검색 핵심 로직
export async function searchWorkflowPatterns(userIntent: string, companyCode: string) {
const keywords = extractKeywords(userIntent);
return await query(`
SELECT * FROM workflow_patterns
WHERE intent_keywords && $1::text[]
AND (company_code = $2 OR company_code = '*')
ORDER BY priority DESC
LIMIT 3
`, [keywords, companyCode]);
}
```
### 4.2 vexplor 담당 (기존 보완)
| 작업 | 현재 상태 | 필요 작업 |
| -------------- | --------- | ------------------- |
| 화면 생성 API | ✅ 존재 | 문서화 |
| 워크플로우 API | ✅ 존재 | 문서화 |
| 제어관리 API | ✅ 존재 | AI 활용 가능 |
| table_labels | 부분 존재 | 주요 테이블 한글 라벨 |
| column_labels | 부분 존재 | web_type 보완 |
#### 4.2.1 table_labels가 필요한 이유
AI가 "입고"라는 단어로 테이블을 찾으려면 한글 라벨이 있어야 합니다.
**현재 상태:**
| table_name | table_label | AI 검색 |
| ---------- | ----------- | ------- |
| inbound_mng | **입고관리** | ✅ "입고" 검색 가능 |
| outbound_mng | **출고관리** | ✅ "출고" 검색 가능 |
| production_record | production_record | ❌ "생산" 검색 불가 |
| purchase_order_master | purchase_order_master | ❌ "발주" 검색 불가 |
**필요 작업**: 주요 업무 테이블에 한글 라벨 추가
```sql
UPDATE table_labels SET table_label = '생산실적' WHERE table_name = 'production_record';
UPDATE table_labels SET table_label = '발주관리' WHERE table_name = 'purchase_order_master';
```
#### 4.2.2 column_labels의 web_type이 필요한 이유
AI가 컬럼을 보고 **어떤 컴포넌트를 생성할지** 결정해야 합니다.
**현재 상태** (inbound_mng):
| column_name | column_label | web_type |
| ----------- | ------------ | -------- |
| inbound_date | 입고일 | **null** |
| inbound_qty | 입고수량 | **null** |
| item_code | 품목코드 | **null** |
**web_type이 null이면?** → AI가 모든 필드를 text-input으로 만들어버림
**web_type이 있으면:**
| column_name | web_type | AI가 생성할 컴포넌트 |
| ----------- | -------- | ------------------- |
| inbound_date | **date** | 📅 날짜 선택기 |
| inbound_qty | **number** | 🔢 숫자 입력 |
| item_code | **entity** | 🔍 품목 검색 팝업 |
| memo | **textarea** | 📝 여러 줄 텍스트 |
**필요 작업**: 주요 테이블 컬럼에 web_type 추가
```sql
UPDATE column_labels SET web_type = 'date' WHERE column_name LIKE '%_date';
UPDATE column_labels SET web_type = 'number' WHERE column_name LIKE '%_qty';
UPDATE column_labels SET web_type = 'entity' WHERE column_name LIKE '%_code' AND column_name != 'company_code';
UPDATE column_labels SET web_type = 'textarea' WHERE column_name = 'memo';
```
---
## 5. 데이터베이스 스키마 (AI 전용)
```sql
-- 핵심: 워크플로우 패턴 (RAG 지식 베이스)
CREATE TABLE workflow_patterns (
pattern_id SERIAL PRIMARY KEY,
category VARCHAR(50) NOT NULL, -- 'inventory', 'sales'
pattern_name VARCHAR(200) NOT NULL, -- '입고→재고 증가'
intent_keywords TEXT[] NOT NULL, -- ['입고', '자재입고', 'inbound']
description TEXT,
source_table_hint VARCHAR(100), -- 'inbound_mng'
target_table_hint VARCHAR(100), -- 'inventory_stock'
logic_template JSONB NOT NULL, -- 제어관리 생성 템플릿
company_code VARCHAR(20) DEFAULT '*',
priority INTEGER DEFAULT 100,
is_active BOOLEAN DEFAULT true
);
CREATE INDEX idx_workflow_patterns_keywords ON workflow_patterns USING GIN(intent_keywords);
-- 초기 데이터
INSERT INTO workflow_patterns (category, pattern_name, intent_keywords, description, source_table_hint, target_table_hint, logic_template) VALUES
('inventory', '입고→재고 증가',
ARRAY['입고', '구매입고', '자재입고', 'inbound'],
'입고 저장 시 재고 수량 증가',
'inbound_mng', 'inventory_stock',
'{"actionType": "upsert", "operation": "increment", "fieldMappings": [{"source": "item_code", "target": "item_code", "type": "key"}, {"source": "inbound_qty", "target": "qty", "type": "increment"}]}'::jsonb
),
('inventory', '출고→재고 감소',
ARRAY['출고', '판매출고', 'outbound'],
'출고 저장 시 재고 수량 감소',
'outbound_mng', 'inventory_stock',
'{"actionType": "update", "operation": "decrement", "fieldMappings": [{"source": "item_code", "target": "item_code", "type": "key"}, {"source": "outbound_qty", "target": "qty", "type": "decrement"}]}'::jsonb
);
-- 동의어 매핑 (P1)
CREATE TABLE keyword_mapping (
id SERIAL PRIMARY KEY,
keyword VARCHAR(100) NOT NULL,
table_name VARCHAR(100) NOT NULL,
company_code VARCHAR(20) DEFAULT '*'
);
-- AI 대화 이력 (P2)
CREATE TABLE ai_conversations (
id SERIAL PRIMARY KEY,
session_id VARCHAR(100) NOT NULL,
company_code VARCHAR(20) NOT NULL,
user_id VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE ai_messages (
id SERIAL PRIMARY KEY,
conversation_id INTEGER REFERENCES ai_conversations(id),
role VARCHAR(20) NOT NULL,
content TEXT NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
```
---
## 6. 개발 로드맵
### Phase 1: MVP
**AI:**
- 채팅 UI + LLM 연동
- 화면 패턴 분석
- workflow_patterns 테이블 + RAG 검색
**vexplor:**
- API 스펙 문서화 (screen, flow)
- 주요 테이블 한글 라벨 20개
### Phase 2: 워크플로우
**AI:**
- 자연어 → 워크플로우 변환
- dataflow_diagrams 자동 생성
**vexplor:**
- 워크플로우 API 문서화
### Phase 3: 고도화
- 대화형 수정 ("왼쪽 패널 넓혀줘")
- 멀티턴 컨텍스트
- 사용자 피드백 학습
---
## 7. vexplor 체크리스트
### 즉시 (P0)
- [ ] POST /api/screen/create 스펙
- [ ] POST /api/flow/definitions 스펙
- [ ] 화면 레이아웃 JSON 예시
### 1주 내 (P1)
- [ ] 주요 테이블 20개 한글 라벨
- inbound_mng, outbound_mng, inventory_stock
- item_info, customer_mng, supplier_mng
- sales_order_mng, purchase_order_mng
- [ ] column_labels web_type 보완
---
## 8. 성공 지표
| 지표 | 목표 |
| -------------------- | --------- |
| 화면 생성 성공률 | 90%+ |
| 평균 생성 시간 | 10초 이내 |
| 수정 없이 사용 | 70%+ |
| 워크플로우 자동 연결 | 80%+ |
+375
View File
@@ -0,0 +1,375 @@
# vexplor 쿠버네티스 자동 배포 가이드
## 개요
이 문서는 vexplor 프로젝트를 Gitea Actions를 통해 쿠버네티스 클러스터에 자동 배포하는 방법을 설명합니다.
**작성일**: 2024년 12월 22일
---
## 아키텍처
```
┌─────────────────────────────────────────────────────────────────┐
│ Gitea Repository │
│ g.wace.me/chpark/vexplor │
└─────────────────────┬───────────────────────────────────────────┘
│ push to main
┌─────────────────────────────────────────────────────────────────┐
│ Gitea Actions Runner │
│ 1. Checkout code │
│ 2. Build Docker images (frontend, backend) │
│ 3. Push to Harbor Registry │
│ 4. Deploy to Kubernetes │
└─────────────────────┬───────────────────────────────────────────┘
┌──────────┴──────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Harbor Registry │ │ Kubernetes (K8s) │
│ harbor.wace.me │ │ 112.168.212.142 │
└──────────────────┘ └──────────────────┘
┌────────────────┼────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Frontend │ │ Backend │ │ Ingress │
│ :3000 │ │ :3001 │ │ Nginx │
└──────────┘ └──────────┘ └──────────┘
│ │ │
└────────────────┴────────────────┘
┌─────────────────────┐
│ External Access │
│ v1.vexplor.com │
│ api.vexplor.com │
└─────────────────────┘
```
---
## 사전 요구사항
### 1. 쿠버네티스 클러스터
```bash
# 서버 정보
IP: 112.168.212.142
SSH: ssh -p 22 wace@112.168.212.142
K8s 버전: v1.28.15
```
### 2. Harbor 레지스트리 접근 권한
Harbor에 `vexplor` 프로젝트가 생성되어 있어야 합니다.
### 3. Gitea Repository Secrets
Gitea 저장소에 다음 Secrets를 설정해야 합니다:
| Secret 이름 | 설명 |
|------------|------|
| `HARBOR_USERNAME` | Harbor 사용자명 |
| `HARBOR_PASSWORD` | Harbor 비밀번호 |
| `KUBECONFIG` | base64 인코딩된 Kubernetes config |
---
## 초기 설정
### 1단계: 쿠버네티스 클러스터 접속
```bash
ssh -p 22 wace@112.168.212.142
```
### 2단계: Nginx Ingress Controller 설치
```bash
# Nginx Ingress Controller 설치 (baremetal용)
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.9.5/deploy/static/provider/baremetal/deploy.yaml
# 설치 확인
kubectl get pods -n ingress-nginx
kubectl get svc -n ingress-nginx
```
### 3단계: Local Path Provisioner 설치 (PVC용)
```bash
# Local Path Provisioner 설치
kubectl apply -f k8s/local-path-provisioner.yaml
# 설치 확인
kubectl get pods -n local-path-storage
kubectl get storageclass
```
### 4단계: Cert-Manager 설치 (SSL 인증서용)
```bash
# Cert-Manager 설치
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.3/cert-manager.yaml
# 설치 확인
kubectl get pods -n cert-manager
# ClusterIssuer 생성 (Let's Encrypt)
cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: admin@vexplor.com
privateKeySecretRef:
name: letsencrypt-prod
solvers:
- http01:
ingress:
class: nginx
EOF
```
### 5단계: vexplor Secret 생성
```bash
# Secret 템플릿을 복사하여 실제 값으로 수정
cp k8s/vexplor-secret.yaml.template k8s/vexplor-secret.yaml
# 값 수정 후 적용
kubectl apply -f k8s/vexplor-secret.yaml
```
### 6단계: Gitea Secrets 설정
1. Gitea 저장소로 이동: https://g.wace.me/chpark/vexplor
2. Settings > Secrets > Actions 메뉴로 이동
3. 다음 Secrets 추가:
#### HARBOR_USERNAME
Harbor 로그인 사용자명
#### HARBOR_PASSWORD
Harbor 로그인 비밀번호
#### KUBECONFIG
```bash
# 쿠버네티스 서버에서 실행
cat ~/.kube/config | base64 -w 0
```
출력된 값을 KUBECONFIG secret으로 등록
---
## 배포 트리거
### 자동 배포 (Push)
다음 경로의 파일이 변경되어 `main` 브랜치에 push되면 자동으로 배포됩니다:
- `backend-node/**`
- `frontend/**`
- `docker/**`
- `k8s/**`
- `.gitea/workflows/deploy.yml`
### 수동 배포
1. Gitea 저장소 > Actions 탭으로 이동
2. "Deploy vexplor" 워크플로우 선택
3. "Run workflow" 버튼 클릭
---
## 파일 구조
```
vexplor/
├── .gitea/
│ └── workflows/
│ └── deploy.yml # Gitea Actions 워크플로우
├── docker/
│ └── deploy/
│ ├── backend.Dockerfile # 백엔드 배포용 Dockerfile
│ └── frontend.Dockerfile # 프론트엔드 배포용 Dockerfile
├── k8s/
│ ├── namespace.yaml # 네임스페이스 정의
│ ├── vexplor-config.yaml # ConfigMap
│ ├── vexplor-secret.yaml.template # Secret 템플릿
│ ├── vexplor-backend-deployment.yaml # 백엔드 Deployment/Service/PVC
│ ├── vexplor-frontend-deployment.yaml # 프론트엔드 Deployment/Service
│ ├── vexplor-ingress.yaml # Ingress 설정
│ ├── local-path-provisioner.yaml # 스토리지 프로비저너
│ └── ingress-nginx.yaml # Ingress 컨트롤러 패치
└── docs/
└── KUBERNETES_DEPLOYMENT_GUIDE.md # 이 문서
```
---
## 운영 명령어
### 상태 확인
```bash
# 전체 리소스 확인
kubectl get all -n vexplor
# Pod 상태 확인
kubectl get pods -n vexplor -o wide
# 로그 확인
kubectl logs -f deployment/vexplor-backend -n vexplor
kubectl logs -f deployment/vexplor-frontend -n vexplor
# Pod 상세 정보
kubectl describe pod <pod-name> -n vexplor
```
### 수동 배포/롤백
```bash
# 이미지 업데이트
kubectl set image deployment/vexplor-backend \
vexplor-backend=harbor.wace.me/vexplor/vexplor-backend:v20241222-120000-abc1234 \
-n vexplor
# 롤아웃 상태 확인
kubectl rollout status deployment/vexplor-backend -n vexplor
# 롤백
kubectl rollout undo deployment/vexplor-backend -n vexplor
kubectl rollout undo deployment/vexplor-frontend -n vexplor
# 히스토리 확인
kubectl rollout history deployment/vexplor-backend -n vexplor
```
### 스케일링
```bash
# 레플리카 수 조정
kubectl scale deployment/vexplor-backend --replicas=3 -n vexplor
kubectl scale deployment/vexplor-frontend --replicas=3 -n vexplor
```
### Pod 재시작
```bash
# Deployment 재시작 (롤링 업데이트)
kubectl rollout restart deployment/vexplor-backend -n vexplor
kubectl rollout restart deployment/vexplor-frontend -n vexplor
```
---
## 문제 해결
### Pod이 Pending 상태일 때
```bash
# Pod 이벤트 확인
kubectl describe pod <pod-name> -n vexplor
# 노드 리소스 확인
kubectl describe node
kubectl top nodes
```
### ImagePullBackOff 오류
```bash
# Harbor Secret 확인
kubectl get secret harbor-registry -n vexplor -o yaml
# Secret 재생성
kubectl delete secret harbor-registry -n vexplor
kubectl create secret docker-registry harbor-registry \
--docker-server=192.168.1.100:5001 \
--docker-username=<username> \
--docker-password=<password> \
-n vexplor
```
### Ingress가 작동하지 않을 때
```bash
# Ingress 상태 확인
kubectl get ingress -n vexplor
kubectl describe ingress vexplor-ingress -n vexplor
# Ingress Controller 로그
kubectl logs -f deployment/ingress-nginx-controller -n ingress-nginx
```
### SSL 인증서 문제
```bash
# Certificate 상태 확인
kubectl get certificate -n vexplor
kubectl describe certificate vexplor-tls -n vexplor
# Cert-Manager 로그
kubectl logs -f deployment/cert-manager -n cert-manager
```
---
## 네트워크 설정
### 방화벽 포트 개방
쿠버네티스 서버에서 다음 포트가 개방되어야 합니다:
| 포트 | 용도 |
|-----|------|
| 30080 | HTTP (Ingress NodePort) |
| 30443 | HTTPS (Ingress NodePort) |
| 6443 | Kubernetes API |
### DNS 설정
다음 도메인이 쿠버네티스 서버 IP를 가리키도록 설정:
- `v1.vexplor.com` → 112.168.212.142
- `api.vexplor.com` → 112.168.212.142
---
## 환경 변수
### Backend 환경 변수
| 변수 | 설명 | 소스 |
|-----|------|-----|
| `NODE_ENV` | 환경 (production) | ConfigMap |
| `PORT` | 서버 포트 (3001) | ConfigMap |
| `DATABASE_URL` | PostgreSQL 연결 문자열 | Secret |
| `JWT_SECRET` | JWT 서명 키 | Secret |
| `JWT_EXPIRES_IN` | JWT 만료 시간 | ConfigMap |
| `CORS_ORIGIN` | CORS 허용 도메인 | ConfigMap |
### Frontend 환경 변수
| 변수 | 설명 | 소스 |
|-----|------|-----|
| `NODE_ENV` | 환경 (production) | ConfigMap |
| `NEXT_PUBLIC_API_URL` | 클라이언트 API URL | ConfigMap |
| `SERVER_API_URL` | SSR용 내부 API URL | Deployment |
---
## 참고 자료
- [Kubernetes 공식 문서](https://kubernetes.io/docs/)
- [Gitea Actions 문서](https://docs.gitea.com/usage/actions/overview)
- [Nginx Ingress Controller](https://kubernetes.github.io/ingress-nginx/)
- [Cert-Manager](https://cert-manager.io/docs/)
- [Harbor Registry](https://goharbor.io/docs/)
@@ -0,0 +1,909 @@
# 배치 스케줄러 + 노드 플로우 연동 계획서
## 1. 배경 및 목적
### 현재 상태
현재 시스템에는 두 개의 독립적인 실행 엔진이 있다:
| 시스템 | 역할 | 트리거 방식 |
|--------|------|-------------|
| **배치 스케줄러** | Cron 기반 자동 실행 (데이터 복사만 가능) | 시간 기반 (node-cron) |
| **노드 플로우 엔진** | 조건/변환/INSERT/UPDATE/DELETE 등 복합 로직 | 버튼 클릭 (수동) |
### 문제
- 배치는 **INSERT/UPSERT만** 가능하고, 조건 기반 UPDATE/DELETE를 못 함
- 노드 플로우는 강력하지만 **수동 실행만** 가능 (버튼 클릭 필수)
- "퇴사일이 지나면 자동으로 퇴사 처리" 같은 **시간 기반 비즈니스 로직**을 구현할 수 없음
### 목표
배치 스케줄러가 노드 플로우를 자동 실행할 수 있도록 연동하여,
시간 기반 비즈니스 로직 자동화를 지원한다.
```
[배치 스케줄러] ──Cron 트리거──> [노드 플로우 실행 엔진]
│ │
│ ├── 테이블 소스 조회
│ ├── 조건 분기
│ ├── UPDATE / DELETE / INSERT
│ ├── 이메일 발송
│ └── 로깅
└── 실행 로그 기록 (batch_execution_logs)
```
---
## 2. 사용 시나리오
### 시나리오 A: 자동 퇴사 처리
```
매일 자정 실행:
1. user_info에서 퇴사일 <= NOW() AND 상태 != '퇴사' 인 사람 조회
2. 해당 사용자의 상태를 '퇴사'로 UPDATE
3. 관리자에게 이메일 알림 발송
```
### 시나리오 B: 월말 재고 마감
```
매월 1일 00:00 실행:
1. 전월 재고 데이터를 재고마감 테이블로 INSERT
2. 이월 수량 계산 후 UPDATE
```
### 시나리오 C: 미납 알림
```
매일 09:00 실행:
1. 납기일이 지난 미납 주문 조회
2. 담당자에게 이메일 발송
3. 알림 로그 INSERT
```
### 시나리오 D: 외부 API 연동 자동화
```
매시간 실행:
1. 외부 REST API에서 데이터 조회
2. 조건 필터링 (변경된 데이터만)
3. 내부 테이블에 UPSERT
```
---
## 3. 구현 범위
### 3.1 DB 변경 (batch_configs 테이블 확장)
```sql
-- batch_configs 테이블에 컬럼 추가
ALTER TABLE batch_configs
ADD COLUMN execution_type VARCHAR(20) DEFAULT 'mapping',
ADD COLUMN node_flow_id INTEGER DEFAULT NULL,
ADD COLUMN node_flow_context JSONB DEFAULT NULL;
-- execution_type: 'mapping' (기존 데이터 복사) | 'node_flow' (노드 플로우 실행)
-- node_flow_id: node_flows 테이블의 flow_id (FK)
-- node_flow_context: 플로우 실행 시 전달할 컨텍스트 데이터 (선택)
COMMENT ON COLUMN batch_configs.execution_type IS '실행 타입: mapping(기존 데이터 복사), node_flow(노드 플로우 실행)';
COMMENT ON COLUMN batch_configs.node_flow_id IS '연결된 노드 플로우 ID (execution_type이 node_flow일 때 사용)';
COMMENT ON COLUMN batch_configs.node_flow_context IS '플로우 실행 시 전달할 컨텍스트 데이터 (JSON)';
```
기존 데이터에 영향 없음 (`DEFAULT 'mapping'`으로 하위 호환성 보장)
### 3.2 백엔드 변경
#### BatchSchedulerService 수정 (핵심)
`executeBatchConfig()` 메서드에서 `execution_type` 분기:
```
executeBatchConfig(config)
├── config.execution_type === 'mapping'
│ └── 기존 executeBatchMappings() (변경 없음)
└── config.execution_type === 'node_flow'
└── NodeFlowExecutionService.executeFlow()
├── 노드 플로우 조회
├── 위상 정렬
├── 레벨별 실행
└── 결과 반환
```
수정 파일:
- `backend-node/src/services/batchSchedulerService.ts`
- `executeBatchConfig()` 에 node_flow 분기 추가
- 노드 플로우 실행 결과를 배치 로그 형식으로 변환
#### 배치 설정 API 수정
수정 파일:
- `backend-node/src/types/batchTypes.ts`
- `BatchConfig` 인터페이스에 `execution_type`, `node_flow_id`, `node_flow_context` 추가
- `CreateBatchConfigRequest`, `UpdateBatchConfigRequest` 에도 추가
- `backend-node/src/services/batchService.ts`
- `createBatchConfig()` - 새 필드 INSERT
- `updateBatchConfig()` - 새 필드 UPDATE
- `backend-node/src/controllers/batchManagementController.ts`
- 생성/수정 시 새 필드 처리
#### 노드 플로우 목록 API (배치용)
추가 파일/수정:
- `backend-node/src/routes/batchManagementRoutes.ts`
- `GET /api/batch-management/node-flows` 추가 (배치 설정 UI에서 플로우 선택용)
### 3.3 프론트엔드 변경
#### 배치 생성/편집 UI 수정
수정 파일:
- `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx`
- `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx`
변경 내용:
- "실행 타입" 선택 추가 (기존 매핑 / 노드 플로우)
- 노드 플로우 선택 시: 플로우 드롭다운 표시 (기존 매핑 설정 숨김)
- 노드 플로우 선택 시: 컨텍스트 데이터 입력 (선택사항, JSON)
```
┌─────────────────────────────────────────┐
│ 배치 설정 │
├─────────────────────────────────────────┤
│ 배치명: [자동 퇴사 처리 ] │
│ 설명: [퇴사일 경과 사용자 자동 처리] │
│ Cron: [0 0 * * * ] │
│ │
│ 실행 타입: ○ 데이터 매핑 ● 노드 플로우 │
│ │
│ ┌─ 노드 플로우 선택 ─────────────────┐ │
│ │ [▾ 자동 퇴사 처리 플로우 ] │ │
│ │ │ │
│ │ 플로우 설명: user_info에서 퇴사일..│ │
│ │ 노드 수: 4개 │ │
│ └────────────────────────────────────┘ │
│ │
│ [취소] [저장] │
└─────────────────────────────────────────┘
```
#### 배치 목록 UI - Ops 대시보드 리디자인
현재 배치 목록은 단순 테이블인데, Vercel/Railway 스타일의 **운영 대시보드**로 전면 리디자인한다.
노드 플로우 연동과 함께 적용하면 새로운 실행 타입도 자연스럽게 표현 가능.
디자인 컨셉: **"편집기"가 아닌 "운영 대시보드"**
- 데이터 타입 관리 = 컬럼 편집기 → 3패널(리스트/그리드/설정)이 적합
- 배치 관리 = 운영 모니터링 → 테이블 + 인라인 상태 표시가 적합
- 역할이 다르면 레이아웃도 달라야 함
---
##### 전체 레이아웃
```
┌──────────────────────────────────────────────────────────────┐
│ [헤더] 배치 관리 [새로고침] [새 배치] │
│ └ 데이터 동기화 배치 작업을 모니터링하고 관리합니다 │
├──────────────────────────────────────────────────────────────┤
│ [통계 카드 4열 그리드] │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 전체 배치 │ │ 활성 배치 │ │ 오늘 실행 │ │ 오늘 실패 │ │
│ │ 8 │ │ 6 │ │ 142 │ │ 3 │ │
│ │ +2 이번달│ │ 2 비활성 │ │+12% 전일 │ │+1 전일 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
├──────────────────────────────────────────────────────────────┤
│ [툴바] │
│ 🔍 검색... [전체|활성|비활성] [전체|DB-DB|API-DB|플로우] 총 8건 │
├──────────────────────────────────────────────────────────────┤
│ [테이블 헤더] │
│ ● 배치 타입 스케줄 최근24h 마지막실행 │
├──────────────────────────────────────────────────────────────┤
│ ● 품목 마스터 동기화 DB→DB */30**** ▌▌▌▐▌▌▌ 14:30 ▶✎🗑 │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ [확장 상세 패널 - 클릭 시 토글] │ │
│ │ 내러티브 + 파이프라인 + 매핑 + 설정 + 타임라인 │ │
│ └────────────────────────────────────────────────────────┘ │
│ ● 거래처 ERP 연동 API→DB 0*/2*** ▌▌▌▌▌▌▌ 14:00 ▶✎🗑 │
│ ◉ 재고 현황 수집 API→DB 06,18** ▌▌▐▌▌▌░ 실행중 ▶✎🗑 │
│ ○ BOM 백업 DB→DB 0 3**0 ░░░░░░░ 비활성 ▶✎🗑 │
│ ... │
└──────────────────────────────────────────────────────────────┘
```
---
##### 1. 페이지 헤더
```
구조: flex, align-items: flex-end, justify-content: space-between
하단 보더: 1px solid border
하단 마진: 24px
좌측:
- 제목: "배치 관리" (text-xl font-extrabold tracking-tight)
- 부제: "데이터 동기화 배치 작업을 모니터링하고 관리합니다" (text-xs text-muted-foreground)
우측 버튼 그룹 (gap-2):
- [새로고침] 버튼: variant="outline", RefreshCw 아이콘
- [새 배치] 버튼: variant="default" (primary), Plus 아이콘
```
---
##### 2. 통계 카드 영역
```
레이아웃: grid grid-cols-4 gap-3
각 카드: rounded-xl border bg-card p-4
카드 구조:
┌──────────────────────────┐
│ [라벨] [아이콘] │ ← stat-top: flex justify-between
│ │
│ 숫자값 (28px 모노 볼드) │ ← stat-val: font-mono text-3xl font-extrabold
│ │
│ [변화량 배지] 기간 텍스트 │ ← stat-footer: flex items-center gap-1.5
└──────────────────────────┘
4개 카드 상세:
┌─────────────┬────────────┬───────────────────────────────┐
│ 카드 │ 아이콘 색상 │ 값 색상 │
├─────────────┼────────────┼───────────────────────────────┤
│ 전체 배치 │ indigo bg │ foreground (기본) │
│ 활성 배치 │ green bg │ green (--success) │
│ 오늘 실행 │ cyan bg │ cyan (--info 계열) │
│ 오늘 실패 │ red bg │ red (--destructive) │
└─────────────┴────────────┴───────────────────────────────┘
변화량 배지:
- 증가: green 배경 + green 텍스트, "+N" 또는 "+N%"
- 감소/악화: red 배경 + red 텍스트
- 크기: text-[10px] font-bold px-1.5 py-0.5 rounded
아이콘 박스: 28x28px rounded-lg, 배경색 투명도 10%
아이콘: lucide-react (LayoutGrid, CheckCircle, Activity, XCircle)
```
**데이터 소스:**
```
GET /api/batch-management/stats
→ {
totalBatches: number, // batch_configs COUNT(*)
activeBatches: number, // batch_configs WHERE is_active='Y'
todayExecutions: number, // batch_execution_logs WHERE DATE(start_time)=TODAY
todayFailures: number, // batch_execution_logs WHERE DATE(start_time)=TODAY AND status='FAILED'
// 선택사항: 전일 대비 변화량
prevDayExecutions?: number,
prevDayFailures?: number
}
```
---
##### 3. 툴바
```
레이아웃: flex items-center gap-2.5
요소 1 - 검색:
- 위치: 좌측, flex-1 max-w-[320px]
- 구조: relative div + input + Search 아이콘(absolute left)
- input: h-9, rounded-lg, border, bg-card, text-xs
- placeholder: "배치 이름으로 검색..."
- focus: ring-2 ring-primary
요소 2 - 상태 필터 (pill-group):
- 컨테이너: flex gap-0.5, bg-card, border, rounded-lg, p-0.5
- 각 pill: text-[11px] font-semibold px-3 py-1.5 rounded-md
- 활성 pill: bg-primary/10 text-primary
- 비활성 pill: text-muted-foreground, hover시 밝아짐
- 항목: [전체] [활성] [비활성]
요소 3 - 타입 필터 (pill-group):
- 동일 스타일
- 항목: [전체] [DB-DB] [API-DB] [노드 플로우] ← 노드 플로우는 신규
요소 4 - 건수 표시:
- 위치: ml-auto (우측 정렬)
- 텍스트: "총 N건" (text-[11px] text-muted-foreground, N은 font-bold)
```
---
##### 4. 배치 테이블
```
컨테이너: border rounded-xl overflow-hidden bg-card
테이블 헤더:
- 배경: bg-muted/50
- 높이: 40px
- 글자: text-[10px] font-bold text-muted-foreground uppercase tracking-wider
- 그리드 컬럼: 44px 1fr 100px 130px 160px 100px 120px
- 컬럼: [LED] [배치] [타입] [스케줄] [최근 24h] [마지막 실행] [액션]
```
---
##### 5. 배치 테이블 행 (핵심)
```
그리드: 44px 1fr 100px 130px 160px 100px 120px
높이: min-height 60px
하단 보더: 1px solid border
hover: bg-card/80 (약간 밝아짐)
선택됨: bg-primary/10 + 좌측 3px primary 박스 섀도우 (inset)
클릭 시: 상세 패널 토글
[셀 1] LED 상태 표시:
┌──────────────────────────────────────┐
│ 원형 8x8px, 센터 정렬 │
│ │
│ 활성(on): green + box-shadow glow │
│ 실행중(run): amber + 1.5s blink 애니 │
│ 비활성(off): muted-foreground (회색) │
│ 에러(err): red + box-shadow glow │
└──────────────────────────────────────┘
[셀 2] 배치 정보:
┌──────────────────────────────────────┐
│ 배치명: text-[13px] font-bold │
│ 설명: text-[10px] text-muted-fg │
│ overflow ellipsis (1줄) │
│ │
│ 비활성 배치: 배치명도 muted 색상 │
└──────────────────────────────────────┘
[셀 3] 타입 배지:
┌──────────────────────────────────────┐
│ inline-flex, text-[10px] font-bold │
│ px-2 py-0.5 rounded-[5px] │
│ │
│ DB → DB: cyan 배경/텍스트 │
│ API → DB: violet 배경/텍스트 │
│ 노드 플로우: indigo 배경/텍스트 (신규) │
└──────────────────────────────────────┘
[셀 4] Cron 스케줄:
┌──────────────────────────────────────┐
│ Cron 표현식: font-mono text-[11px] │
│ font-medium │
│ 한글 설명: text-[9px] text-muted │
│ "매 30분", "매일 01:00" │
│ │
│ 비활성: muted 색상 │
└──────────────────────────────────────┘
Cron → 한글 변환 예시:
- */30 * * * * → "매 30분"
- 0 */2 * * * → "매 2시간"
- 0 6,18 * * * → "06:00, 18:00"
- 0 1 * * * → "매일 01:00"
- 0 3 * * 0 → "매주 일 03:00"
- 0 0 1 * * → "매월 1일 00:00"
[셀 5] 스파크라인 (최근 24h):
┌──────────────────────────────────────┐
│ flex, items-end, gap-[1px], h-6 │
│ │
│ 24개 바 (시간당 1개): │
│ - 성공(ok): green, opacity 60% │
│ - 실패(fail): red, opacity 80% │
│ - 미실행(none): muted, opacity 15% │
│ │
│ 각 바: flex-1, min-w-[3px] │
│ rounded-t-[1px] │
│ 높이: 실행시간 비례 또는 고정 │
│ hover: opacity 100% │
└──────────────────────────────────────┘
데이터: 최근 24시간을 1시간 단위로 슬라이싱
각 슬롯별 가장 최근 실행의 status 사용
높이: 성공=80~95%, 실패=20~40%, 미실행=5%
[셀 6] 마지막 실행:
┌──────────────────────────────────────┐
│ 시간: font-mono text-[10px] │
│ "14:30:00" │
│ 경과: text-[9px] muted │
│ "12분 전" │
│ │
│ 실행 중: amber 색상 "실행 중..." │
│ 비활성: muted "-" + "비활성" │
└──────────────────────────────────────┘
[셀 7] 액션 버튼:
┌──────────────────────────────────────┐
│ flex gap-1, justify-end │
│ │
│ 3개 아이콘 버튼 (28x28 rounded-md): │
│ │
│ [▶] 수동 실행 │
│ hover: green 테두리+배경+아이콘 │
│ 아이콘: Play (lucide) │
│ │
│ [✎] 편집 │
│ hover: 기본 밝아짐 │
│ 아이콘: Pencil (lucide) │
│ │
│ [🗑] 삭제 │
│ hover: red 테두리+배경+아이콘 │
│ 아이콘: Trash2 (lucide) │
└──────────────────────────────────────┘
```
---
##### 6. 행 확장 상세 패널 (클릭 시 토글)
행을 클릭하면 아래로 펼쳐지는 상세 패널. 매핑 타입과 노드 플로우 타입에 따라 내용이 달라진다.
```
컨테이너:
- border (상단 border 없음, 행과 이어짐)
- rounded-b-xl
- bg-muted/30 (행보다 약간 어두운 배경)
- padding: 20px 24px
내부 구조:
┌────────────────────────────────────────────────────────────┐
│ [내러티브 박스] │
│ "ERP_SOURCE DB의 item_master 테이블에서 현재 DB의 │
│ item_info 테이블로 12개 컬럼을 매 30분마다 동기화하고 │
│ 있어요. 오늘 48회 실행, 마지막 실행은 12분 전이에요." │
├────────────────────────────────────────────────────────────┤
│ [파이프라인 플로우 다이어그램] │
│ │
│ ┌─────────────┐ 12 컬럼 UPSERT ┌─────────────┐ │
│ │ 🗄 DB아이콘 │ ─────────────────→ │ 🗄 DB아이콘 │ │
│ │ ERP_SOURCE │ WHERE USE_YN='Y' │ 현재 DB │ │
│ │ item_master │ │ item_info │ │
│ └─────────────┘ └─────────────┘ │
├──────────────────────┬─────────────────────────────────────┤
│ [좌측: 매핑 + 설정] │ [우측: 실행 이력 타임라인] │
│ │ │
│ --- 컬럼 매핑 (12) --- │ --- 실행 이력 (최근 5건) --- │
│ ITEM_CD → item_code PK│ ● 14:30:00 [성공] 1,842건 3.2s │
│ ITEM_NM → item_name │ │ │
│ ITEM_SPEC → spec... │ ● 14:00:00 [성공] 1,840건 3.1s │
│ UNIT_CD → unit_code │ │ │
│ STD_PRICE → std_price │ ✕ 13:30:00 [실패] Timeout │
│ + 7개 더 보기 │ │ │
│ │ ● 13:00:00 [성공] 1,838건 2.9s │
│ --- 설정 --- │ │ │
│ 배치 크기: 500 │ ● 12:30:00 [성공] 1,835건 3.5s │
│ 타임아웃: 30s │ │
│ 실패 시: 3회 재시도 │ │
│ 매칭 키: item_code │ │
│ 모드: [UPSERT] │ │
└──────────────────────┴─────────────────────────────────────┘
```
**6-1. 내러티브 박스 (Toss 스타일 자연어 설명)**
```
스타일:
- rounded-lg
- 배경: linear-gradient(135deg, primary/6%, info/4%)
- 보더: 1px solid primary/8%
- padding: 12px 14px
- margin-bottom: 16px
텍스트: text-[11px] text-muted-foreground leading-relaxed
강조 텍스트:
- 굵은 텍스트(b): foreground font-semibold
- 하이라이트(hl): primary font-bold
매핑 타입 예시:
"ERP_SOURCE 데이터베이스의 item_master 테이블에서 현재 DB의
item_info 테이블로 12개 컬럼을 매 30분마다 동기화하고 있어요.
오늘 48회 실행, 마지막 실행은 12분 전이에요."
노드 플로우 타입 예시:
"자동 퇴사 처리 노드 플로우를 매일 00:00에 실행하고 있어요.
user_info 테이블에서 퇴사일이 지난 사용자를 조회하여
상태를 '퇴사'로 변경합니다. 4개 노드로 구성되어 있어요."
```
**6-2. 파이프라인 플로우 다이어그램**
```
컨테이너:
- flex items-center
- rounded-lg border bg-card p-4
- margin-bottom: 16px
구조: [소스 노드] ──[커넥터]──> [타겟 노드]
소스 노드 (pipe-node src):
- 배경: cyan/6%, 보더: cyan/12%
- 아이콘: 32x32 rounded-lg, cyan/12% 배경
- DB 타입: Database 아이콘 (lucide)
- API 타입: Cloud 아이콘 (lucide) + violet 색상
- 이름: text-xs font-bold cyan 색상
- 부제: font-mono text-[10px] muted (테이블명/URL)
커넥터 (pipe-connector):
- flex-1, flex-col items-center
- 상단 라벨: text-[9px] font-bold muted ("12 컬럼 UPSERT")
- 라인: width 100%, h-[2px], gradient(cyan → green)
- 라인 끝: 삼각형 화살표 (CSS ::after)
- 하단 라벨: text-[9px] font-bold muted ("WHERE USE_YN='Y'")
타겟 노드 (pipe-node tgt):
- 배경: green/6%, 보더: green/12%
- 아이콘: green/12% 배경
- 이름: text-xs font-bold green 색상
- 부제: 테이블명
노드 플로우 타입일 때:
- 소스/타겟 대신 노드 플로우 요약 카드로 대체
- 아이콘: Workflow 아이콘 (lucide) + indigo 색상
- 이름: 플로우명
- 부제: "N개 노드 | 조건 분기 포함"
- 노드 목록: 간략 리스트 (Source → Condition → Update → Email)
```
**6-3. 하단 2열 그리드**
```
레이아웃: grid grid-cols-2 gap-5
[좌측 컬럼] 매핑 + 설정:
섹션 1 - 컬럼 매핑:
헤더: flex items-center gap-1.5
- Link 아이콘 (lucide, 13px, muted)
- "컬럼 매핑" (text-[11px] font-bold muted)
- 건수 배지 (ml-auto, text-[9px] font-bold, primary/10% bg, primary 색)
매핑 행 (map-row):
- flex items-center gap-1.5
- rounded-md border bg-card px-2.5 py-1.5
- margin-bottom: 2px
구조: [소스 컬럼] → [타겟 컬럼] [태그]
소스: font-mono font-semibold text-[11px] cyan
화살표: "→" muted
타겟: font-mono font-semibold text-[11px] green
태그: text-[8px] font-bold px-1.5 py-0.5 rounded-sm
PK = green 배경 + dark 텍스트
5개까지 표시 후 "+ N개 더 보기" 접기/펼치기
노드 플로우 타입일 때:
매핑 대신 "노드 구성" 섹션으로 대체
각 행: [노드 아이콘] [노드 타입] [노드 설명]
예: 🔍 테이블 소스 | user_info 조회
🔀 조건 분기 | 퇴사일 <= NOW()
✏️ UPDATE | status → '퇴사'
📧 이메일 | 관리자 알림
섹션 2 - 설정 (cprop 리스트):
헤더: Settings 아이콘 + "설정"
각 행 (cprop):
- flex justify-between py-1.5
- 하단 보더: 1px solid white/3%
- 키: text-[11px] muted
- 값: text-[11px] font-semibold, mono체는 font-mono text-[10px]
- 특수 값: UPSERT 배지 (green/10% bg, green 색, text-[10px] font-bold)
매핑 타입 설정:
- 배치 크기: 500
- 타임아웃: 30s
- 실패 시 재시도: 3회 (green)
- 매칭 키: item_code (mono)
- 모드: [UPSERT] (배지)
노드 플로우 타입 설정:
- 플로우 ID: 42
- 노드 수: 4개
- 실행 타임아웃: 60s
- 컨텍스트: { ... } (mono, 접기 가능)
[우측 컬럼] 실행 이력 타임라인:
헤더: Clock 아이콘 + "실행 이력" + "최근 5건" 배지 (green)
타임라인 (timeline):
flex-col, gap-0
각 항목 (tl-item):
- flex items-start gap-3
- padding: 10px 0
- 하단 보더: 1px solid white/3%
좌측 - 점+선 (tl-dot-wrap):
- flex-col items-center, width 16px
- 점 (tl-dot): 8x8 rounded-full
성공(ok): green
실패(fail): red
실행중(run): amber + blink 애니메이션
- 선 (tl-line): width 1px, bg border, min-h 12px
마지막 항목에는 선 없음
우측 - 내용 (tl-body):
- 시간: font-mono text-[10px] font-semibold
- 상태 배지: text-[9px] font-bold px-1.5 py-0.5 rounded
성공: green/10% bg + green 색
실패: red/10% bg + red 색
- 메시지: text-[10px] muted, margin-top 2px
성공: "1,842건 처리 / 3.2s 소요"
실패: "Connection timeout: ERP_SOURCE 응답 없음"
```
---
##### 7. 반응형 대응
```
1024px 이하 (태블릿):
- 통계 카드: grid-cols-2
- 테이블 그리드: 36px 1fr 80px 110px 120px 80px (액션 숨김)
- 상세 패널 2열 그리드 → 1열
640px 이하 (모바일):
- 컨테이너 padding: 16px
- 통계 카드: grid-cols-2 gap-2
- 테이블 헤더: 숨김
- 테이블 행: grid-cols-1, 카드형태 (padding 16px, gap 8px)
```
---
##### 8. 필요한 백엔드 API
```
1. GET /api/batch-management/stats
→ {
totalBatches: number,
activeBatches: number,
todayExecutions: number,
todayFailures: number,
prevDayExecutions?: number,
prevDayFailures?: number
}
쿼리: batch_configs COUNT + batch_execution_logs 오늘/어제 집계
2. GET /api/batch-management/batch-configs/:id/sparkline
→ [{ hour: 0~23, status: 'success'|'failed'|'none', count: number }]
쿼리: batch_execution_logs WHERE batch_config_id=$1
AND start_time >= NOW() - INTERVAL '24 hours'
GROUP BY EXTRACT(HOUR FROM start_time)
3. GET /api/batch-management/batch-configs/:id/recent-logs?limit=5
→ [{ start_time, end_time, execution_status, total_records,
success_records, failed_records, error_message, duration_ms }]
쿼리: batch_execution_logs WHERE batch_config_id=$1
ORDER BY start_time DESC LIMIT $2
4. GET /api/batch-management/batch-configs (기존 수정)
→ 각 배치에 sparkline 요약 + last_execution 포함하여 반환
목록 페이지에서 개별 sparkline API를 N번 호출하지 않도록
한번에 가져오기 (LEFT JOIN + 서브쿼리)
```
---
## 4. 변경 파일 목록
### DB
| 파일 | 변경 | 설명 |
|------|------|------|
| `db/migrations/XXXX_batch_node_flow_integration.sql` | 신규 | ALTER TABLE batch_configs |
### 백엔드
| 파일 | 변경 | 설명 |
|------|------|------|
| `backend-node/src/services/batchSchedulerService.ts` | 수정 | executeBatchConfig에 node_flow 분기 |
| `backend-node/src/types/batchTypes.ts` | 수정 | BatchConfig 타입에 새 필드 추가 |
| `backend-node/src/services/batchService.ts` | 수정 | create/update에 새 필드 처리 |
| `backend-node/src/controllers/batchManagementController.ts` | 수정 | 새 필드 API + stats/sparkline/recent-logs API |
| `backend-node/src/routes/batchManagementRoutes.ts` | 수정 | node-flows/stats/sparkline 엔드포인트 추가 |
### 프론트엔드
| 파일 | 변경 | 설명 |
|------|------|------|
| `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx` | **리디자인** | Ops 대시보드 스타일로 전면 재작성 |
| `frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 |
| `frontend/app/(main)/admin/automaticMng/batchmngList/edit/[id]/page.tsx` | 수정 | 실행 타입 선택 + 플로우 선택 |
---
## 5. 핵심 구현 상세
### 5.1 BatchSchedulerService 변경 (가장 중요)
```typescript
// batchSchedulerService.ts - executeBatchConfig 메서드 수정
static async executeBatchConfig(config: any) {
const startTime = new Date();
let executionLog: any = null;
try {
// ... 실행 로그 생성 (기존 코드 유지) ...
let result;
// 실행 타입에 따라 분기
if (config.execution_type === 'node_flow' && config.node_flow_id) {
// 노드 플로우 실행
result = await this.executeNodeFlow(config);
} else {
// 기존 매핑 실행 (하위 호환)
result = await this.executeBatchMappings(config);
}
// ... 실행 로그 업데이트 (기존 코드 유지) ...
return result;
} catch (error) {
// ... 에러 처리 (기존 코드 유지) ...
}
}
/**
* 노드 플로우 실행 (신규)
*/
private static async executeNodeFlow(config: any) {
const { NodeFlowExecutionService } = await import('./nodeFlowExecutionService');
const context = {
sourceData: [],
dataSourceType: 'none',
nodeResults: new Map(),
executionOrder: [],
buttonContext: {
buttonId: `batch_${config.id}`,
companyCode: config.company_code,
userId: config.created_by || 'batch_system',
formData: config.node_flow_context || {},
},
};
const flowResult = await NodeFlowExecutionService.executeFlow(
config.node_flow_id,
context
);
// 노드 플로우 결과를 배치 로그 형식으로 변환
return {
totalRecords: flowResult.totalNodes || 0,
successRecords: flowResult.successNodes || 0,
failedRecords: flowResult.failedNodes || 0,
};
}
```
### 5.2 실행 결과 매핑
노드 플로우 결과 → 배치 로그 변환:
| 노드 플로우 결과 | 배치 로그 필드 | 설명 |
|------------------|---------------|------|
| 전체 노드 수 | total_records | 실행 대상 노드 수 |
| 성공 노드 수 | success_records | 성공적으로 실행된 노드 |
| 실패 노드 수 | failed_records | 실패한 노드 |
| 에러 메시지 | error_message | 첫 번째 실패 노드의 에러 |
### 5.3 보안 고려사항
- 배치에서 실행되는 노드 플로우도 **company_code** 필터링 적용
- 배치 설정의 company_code와 노드 플로우의 company_code가 일치해야 함
- 최고 관리자(`*`)는 모든 플로우 실행 가능
- 실행 로그에 `batch_system`으로 사용자 기록
---
## 6. 구현 순서
### Phase 1: DB + 백엔드 코어 (1일)
1. 마이그레이션 SQL 작성 및 실행
2. `batchTypes.ts` 타입 수정
3. `batchService.ts` create/update 수정
4. `batchSchedulerService.ts` 핵심 분기 로직 추가
5. `batchManagementRoutes.ts` 노드 플로우 목록 API 추가
6. 수동 실행 테스트 (`POST /batch-configs/:id/execute`)
### Phase 2: 백엔드 대시보드 API (0.5일)
1. `GET /api/batch-management/stats` - 전체/활성/오늘실행/오늘실패 집계 API
2. `GET /api/batch-management/batch-configs/:id/sparkline` - 최근 24h 실행 결과 (시간대별 성공/실패/미실행)
3. `GET /api/batch-management/batch-configs/:id/recent-logs?limit=5` - 최근 N건 실행 이력
4. 기존 목록 API에 sparkline 요약 데이터 포함 옵션 추가
### Phase 3: 프론트엔드 - 배치 목록 Ops 대시보드 (1.5일)
상세 UI 명세는 위 "3.3 배치 목록 UI - Ops 대시보드 리디자인" 섹션 참조.
1. **페이지 헤더**: 제목 + 부제 + 새로고침/새배치 버튼 (명세 항목 1)
2. **통계 카드 영역**: 4개 카드 + stats API 연동 (명세 항목 2)
3. **툴바**: 검색 + 상태/타입 필터 pill-group + 건수 표시 (명세 항목 3)
4. **배치 테이블**: 7열 그리드 헤더 + 행 (명세 항목 4~5)
5. **행 확장 상세 패널**: 내러티브 + 파이프라인 + 매핑/플로우 + 설정 + 타임라인 (명세 항목 6)
6. **반응형**: 1024px/640px 브레이크포인트 (명세 항목 7)
7. 배치 생성/편집 모달에 실행 타입 선택 + 노드 플로우 드롭다운
### Phase 4: 테스트 및 검증 (0.5일)
1. 테스트용 노드 플로우 생성 (간단한 UPDATE)
2. 배치 설정에 연결
3. 수동 실행 테스트
4. Cron 스케줄 자동 실행 테스트
5. 실행 로그 확인
6. 대시보드 통계/스파크라인 정확성 확인
---
## 7. 리스크 및 대응
### 7.1 노드 플로우 실행 시간 초과
- **리스크**: 복잡한 플로우가 오래 걸려서 다음 스케줄과 겹칠 수 있음
- **대응**: 실행 중인 배치는 중복 실행 방지 (락 메커니즘) - Phase 2 이후 고려
### 7.2 노드 플로우 삭제 시 배치 깨짐
- **리스크**: 연결된 노드 플로우가 삭제되면 배치 실행 실패
- **대응**:
- 플로우 존재 여부 체크 후 실행
- 실패 시 로그에 "플로우를 찾을 수 없습니다" 기록
- (향후) 플로우 삭제 시 연결된 배치가 있으면 경고
### 7.3 멀티 인스턴스 환경
- **리스크**: 서버가 여러 대일 때 같은 배치가 중복 실행
- **대응**: 현재 단일 인스턴스 운영이므로 당장은 문제 없음. 향후 Redis 기반 분산 락 고려
---
## 8. 기대 효과
1. **시간 기반 비즈니스 자동화**: 수동 작업 없이 조건 충족 시 자동 처리
2. **기존 인프라 재활용**: 검증된 배치 스케줄러(1,200+건 성공) + 강력한 노드 플로우 엔진
3. **최소 코드 변경**: DB 컬럼 3개 + 백엔드 분기 1개 + 프론트 UI 확장
4. **운영 가시성 극대화**: Ops 대시보드로 배치 상태/건강도를 한눈에 파악 (스파크라인, LED, 타임라인)
5. **확장성**: 향후 이벤트 트리거(데이터 변경 감지) 등으로 확장 가능
---
## 9. 설계 의도 - 왜 기존 화면과 다른 레이아웃인가
| 비교 항목 | 데이터 타입 관리 (편집기) | 배치 관리 (대시보드) |
|-----------|------------------------|-------------------|
| 역할 | 컬럼 메타데이터 편집 | 운영 상태 모니터링 |
| 레이아웃 | 3패널 (리스트/그리드/설정) | 테이블 + 인라인 모니터링 |
| 주요 행위 | 필드 추가/삭제/수정 | 상태 확인, 수동 실행, 이력 조회 |
| 시각적 요소 | 폼, 드래그앤드롭 | LED, 스파크라인, 타임라인 |
| 참고 UI | IDE, Figma 속성 패널 | Vercel Functions, Railway |
### 디자인 키포인트 6가지
1. **스파크라인 = 건강 상태 한눈에**: Vercel의 Function 목록처럼 각 배치 행에 최근 24h 실행 결과를 미니 바 차트로 표현. 숫자 읽을 필요 없이 패턴으로 건강 상태 파악.
2. **Expandable Row 패턴**: 3패널 대신 클릭하면 행이 확장되어 상세 정보 표시. 파이프라인 플로우 + 매핑 + 타임라인이 한 번에. Railway/GitHub Actions의 Job 상세 패턴.
3. **LED 상태 표시**: 카드의 Badge(활성/비활성) 대신 LED 점으로 상태 표현. 초록=활성, 주황깜빡임=실행중, 회색=비활성. 운영실 모니터 느낌.
4. **파이프라인 플로우 다이어그램**: 소스 → 화살표 → 타겟을 수평 파이프라인으로 시각화. DB-DB는 DB 아이콘 둘, API-DB는 클라우드+DB. 데이터 흐름이 직관적.
5. **내러티브 박스**: 설정값을 나열하는 대신 자연어로 요약. "A에서 B로 N개 컬럼을 매 30분마다 동기화하고 있어요" 식. Toss 스타일 UX Writing.
6. **타임라인 실행 이력**: 테이블 로그 대신 세로 타임라인(점+선). 성공/실패가 시각적으로 즉시 구분. 문제 발생 시점 빠르게 특정 가능.
### 디자인 원본
HTML 프리뷰 파일: `_local/batch-management-v3-preview.html` (브라우저에서 열어 시각적 확인 가능)
@@ -0,0 +1,3 @@
{
"lastSentAt": "2026-03-24T01:08:38.875Z"
}
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,13 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { useParams, useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTabStore } from "@/stores/tabStore";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
@@ -15,17 +16,58 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
import { RefreshCw, Save, ArrowLeft, Plus, Trash2, Database, Workflow, Clock, Info, Layers, Link, Search } from "lucide-react";
import { toast } from "sonner";
import {
BatchAPI,
BatchConfig,
BatchMapping,
ConnectionInfo,
type NodeFlowInfo,
type BatchExecutionType,
} from "@/lib/api/batch";
import { BatchManagementAPI } from "@/lib/api/batchManagement";
const SCHEDULE_PRESETS = [
{ label: "5분마다", cron: "*/5 * * * *", preview: "5분마다 실행돼요" },
{ label: "30분마다", cron: "*/30 * * * *", preview: "30분마다 실행돼요" },
{ label: "매시간", cron: "0 * * * *", preview: "매시간 정각에 실행돼요" },
{ label: "매일 오전 7시", cron: "0 7 * * *", preview: "매일 오전 7시에 실행돼요" },
{ label: "매일 오전 9시", cron: "0 9 * * *", preview: "매일 오전 9시에 실행돼요" },
{ label: "매일 자정", cron: "0 0 * * *", preview: "매일 밤 12시에 실행돼요" },
{ label: "매주 월요일", cron: "0 9 * * 1", preview: "매주 월요일 오전 9시에 실행돼요" },
{ label: "매월 1일", cron: "0 9 1 * *", preview: "매월 1일 오전 9시에 실행돼요" },
];
function buildCustomCron(repeat: string, dow: string, hour: string, minute: string): string {
if (repeat === "daily") return `${minute} ${hour} * * *`;
if (repeat === "weekly") return `${minute} ${hour} * * ${dow}`;
if (repeat === "monthly") return `${minute} ${hour} 1 * *`;
return `${minute} ${hour} * * *`;
}
function customCronPreview(repeat: string, dow: string, hour: string, minute: string): string {
const dowNames: Record<string, string> = { "1": "월요일", "2": "화요일", "3": "수요일", "4": "목요일", "5": "금요일", "6": "토요일", "0": "일요일" };
const h = Number(hour);
const ampm = h < 12 ? "오전" : "오후";
const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
const time = `${ampm} ${displayH}${minute !== "0" ? ` ${minute}` : ""}`;
if (repeat === "daily") return `매일 ${time}에 실행돼요`;
if (repeat === "weekly") return `매주 ${dowNames[dow] || dow} ${time}에 실행돼요`;
if (repeat === "monthly") return `매월 1일 ${time}에 실행돼요`;
return `매일 ${time}에 실행돼요`;
}
function parseCronToScheduleState(cron: string): { mode: "preset" | "custom"; presetIndex: number; repeat: string; dow: string; hour: string; minute: string } {
const presetIdx = SCHEDULE_PRESETS.findIndex(p => p.cron === cron);
if (presetIdx >= 0) return { mode: "preset", presetIndex: presetIdx, repeat: "daily", dow: "1", hour: "9", minute: "0" };
const parts = cron.split(" ");
if (parts.length < 5) return { mode: "preset", presetIndex: 3, repeat: "daily", dow: "1", hour: "9", minute: "0" };
const [m, h, dom, , dw] = parts;
const repeat = dw !== "*" ? "weekly" : dom !== "*" ? "monthly" : "daily";
return { mode: "custom", presetIndex: -1, repeat, dow: dw !== "*" ? dw : "1", hour: h !== "*" ? h : "9", minute: m.startsWith("*/") ? "0" : m };
}
interface BatchColumnInfo {
column_name: string;
data_type: string;
@@ -49,15 +91,33 @@ const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' |
export default function BatchEditPage() {
const params = useParams();
const router = useRouter();
const { openTab } = useTabStore();
const batchId = parseInt(params.id as string);
// 기본 상태
const [loading, setLoading] = useState(false);
const [batchConfig, setBatchConfig] = useState<BatchConfig | null>(null);
const [batchName, setBatchName] = useState("");
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
const [description, setDescription] = useState("");
const [isActive, setIsActive] = useState("Y");
// 스케줄 관련
const [scheduleMode, setScheduleMode] = useState<"preset" | "custom">("preset");
const [selectedPresetIndex, setSelectedPresetIndex] = useState(3);
const [customRepeat, setCustomRepeat] = useState("daily");
const [customDow, setCustomDow] = useState("1");
const [customHour, setCustomHour] = useState("9");
const [customMinute, setCustomMinute] = useState("0");
const cronSchedule = useMemo(() => {
if (scheduleMode === "preset" && selectedPresetIndex >= 0) return SCHEDULE_PRESETS[selectedPresetIndex].cron;
return buildCustomCron(customRepeat, customDow, customHour, customMinute);
}, [scheduleMode, selectedPresetIndex, customRepeat, customDow, customHour, customMinute]);
const schedulePreview = useMemo(() => {
if (scheduleMode === "preset" && selectedPresetIndex >= 0) return SCHEDULE_PRESETS[selectedPresetIndex].preview;
return customCronPreview(customRepeat, customDow, customHour, customMinute);
}, [scheduleMode, selectedPresetIndex, customRepeat, customDow, customHour, customMinute]);
const [saveMode, setSaveMode] = useState<"INSERT" | "UPSERT">("INSERT");
const [conflictKey, setConflictKey] = useState("");
const [authServiceName, setAuthServiceName] = useState("");
@@ -83,6 +143,13 @@ export default function BatchEditPage() {
// 배치 타입 감지
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
// 실행 타입 (mapping 또는 node_flow)
const [executionType, setExecutionType] = useState<BatchExecutionType>("mapping");
const [nodeFlows, setNodeFlows] = useState<NodeFlowInfo[]>([]);
const [selectedFlowId, setSelectedFlowId] = useState<number | null>(null);
const [nodeFlowContext, setNodeFlowContext] = useState("");
const [flowSearch, setFlowSearch] = useState("");
// REST API 미리보기 상태
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
const [fromApiFields, setFromApiFields] = useState<string[]>([]);
@@ -217,13 +284,30 @@ export default function BatchEditPage() {
setBatchConfig(config);
setBatchName(config.batch_name);
setCronSchedule(config.cron_schedule);
setDescription(config.description || "");
// 스케줄 파싱
const schedState = parseCronToScheduleState(config.cron_schedule);
setScheduleMode(schedState.mode);
setSelectedPresetIndex(schedState.presetIndex);
setCustomRepeat(schedState.repeat);
setCustomDow(schedState.dow);
setCustomHour(schedState.hour);
setCustomMinute(schedState.minute);
setIsActive(config.is_active || "Y");
setSaveMode((config as any).save_mode || "INSERT");
setConflictKey((config as any).conflict_key || "");
setAuthServiceName((config as any).auth_service_name || "");
setDataArrayPath((config as any).data_array_path || "");
// 실행 타입 복원
const configExecType = (config as any).execution_type as BatchExecutionType | undefined;
if (configExecType === "node_flow") {
setExecutionType("node_flow");
setSelectedFlowId((config as any).node_flow_id || null);
setNodeFlowContext((config as any).node_flow_context ? JSON.stringify((config as any).node_flow_context, null, 2) : "");
BatchAPI.getNodeFlows().then(setNodeFlows);
}
// 인증 토큰 모드 설정
if ((config as any).auth_service_name) {
@@ -539,11 +623,49 @@ export default function BatchEditPage() {
// 배치 설정 저장
const saveBatchConfig = async () => {
// restapi-to-db인 경우 mappingList 사용, 아닌 경우 mappings 사용
if (!batchName || !cronSchedule) {
toast.error("배치명과 실행 스케줄은 필수입니다.");
return;
}
// 노드 플로우 타입 저장
if (executionType === "node_flow") {
if (!selectedFlowId) {
toast.error("노드 플로우를 선택해주세요.");
return;
}
let parsedContext: Record<string, any> | undefined;
if (nodeFlowContext.trim()) {
try { parsedContext = JSON.parse(nodeFlowContext); } catch { toast.error("컨텍스트 JSON 형식이 올바르지 않습니다."); return; }
}
setLoading(true);
try {
await BatchAPI.updateBatchConfig(batchId, {
batchName,
description,
cronSchedule,
isActive: isActive as "Y" | "N",
mappings: [],
executionType: "node_flow",
nodeFlowId: selectedFlowId,
nodeFlowContext: parsedContext,
});
toast.success("배치 설정이 저장되었습니다!");
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
} catch (error) {
console.error("배치 저장 실패:", error);
toast.error("배치 저장에 실패했습니다.");
} finally {
setLoading(false);
}
return;
}
// 매핑 타입 저장 - restapi-to-db인 경우 mappingList 사용, 아닌 경우 mappings 사용
const effectiveMappings = batchType === "restapi-to-db" ? mappingList : mappings;
if (!batchName || !cronSchedule || effectiveMappings.length === 0) {
toast.error("필수 항목을 모두 입력해주세요.");
if (effectiveMappings.length === 0) {
toast.error("매핑을 최소 하나 이상 설정해주세요.");
return;
}
@@ -592,7 +714,7 @@ export default function BatchEditPage() {
});
toast.success("배치 설정이 성공적으로 수정되었습니다.");
router.push("/admin/batchmng");
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
} catch (error) {
console.error("배치 설정 수정 실패:", error);
@@ -602,98 +724,277 @@ export default function BatchEditPage() {
}
};
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
const selectedFlow = nodeFlows.find(f => f.flow_id === selectedFlowId);
if (loading && !batchConfig) {
return (
<div className="container mx-auto p-6">
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-8 h-8 animate-spin" />
<span className="ml-2"> ...</span>
<div className="mx-auto max-w-5xl p-4 sm:p-6">
<div className="flex h-64 items-center justify-center gap-2">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
</div>
);
}
return (
<div className="container mx-auto space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="flex items-center gap-4 border-b pb-4">
<Button
variant="outline"
onClick={() => router.push("/admin/batchmng")}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-3xl font-bold"> </h1>
<div className="mx-auto h-full max-w-[640px] space-y-7 overflow-y-auto p-4 sm:p-6">
{/* 헤더 */}
<div>
<button onClick={goBack} className="mb-2 flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<ArrowLeft className="h-3.5 w-3.5" />
</button>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold tracking-tight"> </h1>
{batchType && (
<Badge variant="outline" className="h-5 text-[10px]">
{batchType === "db-to-db" && "DB → DB"}
{batchType === "restapi-to-db" && "API → DB"}
{batchType === "db-to-restapi" && "DB → API"}
</Badge>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">#{batchId} </p>
</div>
<Button size="sm" onClick={saveBatchConfig} disabled={loading} className="h-8 gap-1 text-xs">
{loading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{loading ? "저장 중..." : "저장하기"}
</Button>
</div>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{batchType && (
<Badge variant="outline">
{batchType === "db-to-db" && "DB -> DB"}
{batchType === "restapi-to-db" && "REST API -> DB"}
{batchType === "db-to-restapi" && "DB -> REST API"}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<h2 className="mb-3 text-sm font-bold"> </h2>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="batchName" className="text-xs font-medium"> <span className="text-destructive">*</span></Label>
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="예: 매출 데이터 동기화" className="h-10 text-sm" />
</div>
<div className="space-y-1.5">
<Label htmlFor="description" className="text-xs font-medium"></Label>
<Textarea id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="이 배치가 어떤 일을 하는지 적어주세요" rows={2} className="resize-none text-sm" />
</div>
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<Label htmlFor="batchName"> *</Label>
<Input
id="batchName"
value={batchName}
onChange={(e) => setBatchName(e.target.value)}
placeholder="배치명을 입력하세요"
/>
<p className="text-sm font-medium"> </p>
<p className="text-[11px] text-muted-foreground">{isActive === "Y" ? "스케줄에 따라 자동으로 실행돼요" : "배치가 꺼져 있어요"}</p>
</div>
<div>
<Label htmlFor="cronSchedule"> (Cron) *</Label>
<Input
id="cronSchedule"
value={cronSchedule}
onChange={(e) => setCronSchedule(e.target.value)}
placeholder="0 12 * * *"
/>
<Switch checked={isActive === "Y"} onCheckedChange={checked => setIsActive(checked ? "Y" : "N")} />
</div>
</div>
</div>
{/* 실행 스케줄 */}
<div>
<h2 className="mb-1 text-sm font-bold"> ?</h2>
<p className="mb-3 text-[12px] text-muted-foreground"> . .</p>
<div className="rounded-xl border bg-card p-5">
<div className="mb-4 flex flex-wrap gap-2">
{SCHEDULE_PRESETS.map((preset, i) => (
<button
key={preset.cron}
onClick={() => { setScheduleMode("preset"); setSelectedPresetIndex(i); }}
className={`rounded-full border px-3.5 py-1.5 text-[12px] font-medium transition-all ${
scheduleMode === "preset" && selectedPresetIndex === i
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50 hover:text-primary"
}`}
>
{preset.label}
</button>
))}
<button
onClick={() => setScheduleMode("custom")}
className={`rounded-full border border-dashed px-3.5 py-1.5 text-[12px] font-medium transition-all ${
scheduleMode === "custom"
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:border-primary/50 hover:text-primary"
}`}
>
</button>
</div>
{scheduleMode === "custom" && (
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="space-y-1">
<span className="text-[11px] font-medium text-muted-foreground"></span>
<Select value={customRepeat} onValueChange={setCustomRepeat}>
<SelectTrigger className="h-9 w-[100px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="daily"></SelectItem>
<SelectItem value="weekly"></SelectItem>
<SelectItem value="monthly"></SelectItem>
</SelectContent>
</Select>
</div>
{customRepeat === "weekly" && (
<div className="space-y-1">
<span className="text-[11px] font-medium text-muted-foreground"></span>
<Select value={customDow} onValueChange={setCustomDow}>
<SelectTrigger className="h-9 w-[100px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
<SelectItem value="3"></SelectItem>
<SelectItem value="4"></SelectItem>
<SelectItem value="5"></SelectItem>
<SelectItem value="6"></SelectItem>
<SelectItem value="0"></SelectItem>
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1">
<span className="text-[11px] font-medium text-muted-foreground"></span>
<Select value={customHour} onValueChange={setCustomHour}>
<SelectTrigger className="h-9 w-[90px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{Array.from({ length: 24 }).map((_, h) => (
<SelectItem key={h} value={String(h)}>
{h < 12 ? `오전 ${h === 0 ? 12 : h}` : `오후 ${h === 12 ? 12 : h - 12}`}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<span className="text-[11px] font-medium text-muted-foreground"></span>
<Select value={customMinute} onValueChange={setCustomMinute}>
<SelectTrigger className="h-9 w-[80px] text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="0">0</SelectItem>
<SelectItem value="15">15</SelectItem>
<SelectItem value="30">30</SelectItem>
<SelectItem value="45">45</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="배치에 대한 설명을 입력하세요"
rows={3}
/>
<div className="flex items-center gap-2 rounded-lg bg-primary/5 px-4 py-3">
<Clock className="h-4 w-4 shrink-0 text-primary" />
<span className="text-[13px] font-medium text-primary">{schedulePreview}</span>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="isActive"
checked={isActive === "Y"}
onCheckedChange={(checked) => setIsActive(checked ? "Y" : "N")}
/>
<Label htmlFor="isActive"></Label>
</div>
</CardContent>
</Card>
{/* 실행 타입 선택 */}
<div>
<h2 className="mb-3 text-sm font-bold"> </h2>
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => setExecutionType("mapping")}
className={`group relative flex items-center gap-3 rounded-xl border-2 p-4 text-left transition-all ${executionType === "mapping" ? "border-primary bg-primary/5" : "border-border hover:border-muted-foreground/30 hover:bg-muted/50"}`}
>
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${executionType === "mapping" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
<Database className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold"> </div>
<div className="text-[11px] text-muted-foreground"> </div>
</div>
{executionType === "mapping" && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
</button>
<button
onClick={() => { setExecutionType("node_flow"); if (nodeFlows.length === 0) BatchAPI.getNodeFlows().then(setNodeFlows); }}
className={`group relative flex items-center gap-3 rounded-xl border-2 p-4 text-left transition-all ${executionType === "node_flow" ? "border-primary bg-primary/5" : "border-border hover:border-muted-foreground/30 hover:bg-muted/50"}`}
>
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${executionType === "node_flow" ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
<Workflow className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="text-sm font-semibold"> </div>
<div className="text-[11px] text-muted-foreground"> </div>
</div>
{executionType === "node_flow" && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
</button>
</div>
</div>
{/* FROM/TO 섹션 가로 배치 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* 노드 플로우 설정 */}
{executionType === "node_flow" && (
<div>
<h2 className="mb-1 text-sm font-bold"> ?</h2>
<p className="mb-3 text-[12px] text-muted-foreground"> </p>
{nodeFlows.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed">
<p className="text-xs text-muted-foreground"> </p>
</div>
) : (
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={flowSearch}
onChange={e => setFlowSearch(e.target.value)}
placeholder="플로우 이름으로 검색하세요"
className="h-8 pl-9 text-xs"
/>
</div>
<div className="max-h-[240px] space-y-2 overflow-y-auto">
{nodeFlows
.filter(flow => !flowSearch || flow.flow_name.toLowerCase().includes(flowSearch.toLowerCase()) || (flow.description || "").toLowerCase().includes(flowSearch.toLowerCase()))
.map(flow => (
<button
key={flow.flow_id}
onClick={() => setSelectedFlowId(flow.flow_id === selectedFlowId ? null : flow.flow_id)}
className={`flex w-full items-center gap-3 rounded-lg border p-3.5 text-left transition-all ${
selectedFlowId === flow.flow_id
? "border-primary bg-primary/5"
: "border-border hover:border-primary/30"
}`}
>
<div className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${selectedFlowId === flow.flow_id ? "bg-primary/10 text-primary" : "bg-indigo-500/10 text-indigo-500"}`}>
<Workflow className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">{flow.flow_name}</p>
<p className="text-[11px] text-muted-foreground">
{flow.description || "설명 없음"} &middot; {flow.node_count}
</p>
</div>
{selectedFlowId === flow.flow_id && (
<svg className="h-4 w-4 shrink-0 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 6 9 17l-5-5"/></svg>
)}
</button>
))}
{nodeFlows.filter(flow => !flowSearch || flow.flow_name.toLowerCase().includes(flowSearch.toLowerCase()) || (flow.description || "").toLowerCase().includes(flowSearch.toLowerCase())).length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground"> </p>
)}
</div>
</div>
)}
{selectedFlow && (
<div className="mt-4 space-y-1.5">
<Label className="text-xs font-medium"> <span className="text-muted-foreground">()</span></Label>
<Textarea value={nodeFlowContext} onChange={e => setNodeFlowContext(e.target.value)} placeholder='예: {"target_status": "퇴사"}' rows={3} className="resize-none font-mono text-xs" />
<p className="text-[11px] text-muted-foreground"> JSON . .</p>
</div>
)}
</div>
)}
{/* FROM/TO 섹션 가로 배치 (매핑 타입일 때만) */}
{executionType === "mapping" && (
<>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* FROM 설정 */}
<Card>
<CardHeader>
<CardTitle>FROM ()</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 rounded-lg border border-emerald-500/20 p-4 sm:p-5">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded bg-emerald-500/10 text-emerald-500">
<Database className="h-3.5 w-3.5" />
</div>
<span className="text-sm font-medium">FROM ()</span>
</div>
{batchType === "db-to-db" && (
<>
<div>
@@ -1000,21 +1301,22 @@ export default function BatchEditPage() {
{batchType === "db-to-restapi" && mappings.length > 0 && (
<>
<div>
<Label> </Label>
<Input value={mappings[0]?.from_table_name || ""} readOnly />
<div className="space-y-1.5">
<Label className="text-xs"> </Label>
<Input value={mappings[0]?.from_table_name || ""} readOnly className="h-9 text-sm" />
</div>
</>
)}
</CardContent>
</Card>
</div>
{/* TO 설정 */}
<Card>
<CardHeader>
<CardTitle>TO ()</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 rounded-lg border border-sky-500/20 p-4 sm:p-5">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded bg-sky-500/10 text-sky-500">
<Database className="h-3.5 w-3.5" />
</div>
<span className="text-sm font-medium">TO ()</span>
</div>
{batchType === "db-to-db" && (
<>
<div>
@@ -1188,8 +1490,7 @@ export default function BatchEditPage() {
UPSERT .
</p>
</div>
</CardContent>
</Card>
</div>
</div>
{/* API 데이터 미리보기 버튼 */}
@@ -1206,19 +1507,19 @@ export default function BatchEditPage() {
</div>
)}
{/* 컬럼 매핑 섹션 - 좌우 분리 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
{/* 컬럼 매핑 섹션 */}
<div className="space-y-3 rounded-lg border p-4 sm:p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<Link className="h-4 w-4 text-muted-foreground" />
{batchType === "db-to-db" && "컬럼 매핑"}
{batchType === "restapi-to-db" && "컬럼 매핑 설정"}
{batchType === "db-to-restapi" && "DB 컬럼 -> API 필드 매핑"}
</CardTitle>
{batchType === "db-to-restapi" && "DB API 필드 매핑"}
</div>
{batchType === "restapi-to-db" && (
<p className="text-muted-foreground text-sm">DB API .</p>
<p className="text-xs text-muted-foreground">DB API </p>
)}
</CardHeader>
<CardContent>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* 왼쪽: 샘플 데이터 */}
<div className="flex flex-col">
@@ -1526,24 +1827,21 @@ export default function BatchEditPage() {
)}
</div>
</div>
</CardContent>
</Card>
</div>
</>
)}
{/* 하단 버튼 */}
<div className="flex justify-end space-x-2 border-t pt-6">
<Button variant="outline" onClick={() => router.push("/admin/batchmng")}>
</Button>
<div className="flex justify-end gap-2 border-t pt-5">
<Button variant="outline" size="sm" onClick={goBack} className="h-9 text-xs"></Button>
<Button
size="sm"
onClick={saveBatchConfig}
disabled={loading || (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0)}
disabled={loading || (executionType === "node_flow" ? !selectedFlowId : (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0))}
className="h-9 gap-1 text-xs"
>
{loading ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{loading ? "저장 중..." : "배치 설정 저장"}
{loading ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
{loading ? "저장 중..." : "저장하기"}
</Button>
</div>
</div>
@@ -1,370 +1,708 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Plus,
Search,
import { Switch } from "@/components/ui/switch";
import {
Plus,
Search,
RefreshCw,
Database
CheckCircle,
Play,
Pencil,
Trash2,
Clock,
Link,
Settings,
Database,
Cloud,
Workflow,
ChevronDown,
AlertCircle,
BarChart3,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useRouter } from "next/navigation";
import {
BatchAPI,
BatchConfig,
BatchMapping,
type BatchConfig,
type BatchMapping,
type BatchStats,
type SparklineData,
type RecentLog,
} from "@/lib/api/batch";
import BatchCard from "@/components/admin/BatchCard";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { useTabStore } from "@/stores/tabStore";
function cronToKorean(cron: string): string {
const parts = cron.split(" ");
if (parts.length < 5) return cron;
const [min, hour, dom, , dow] = parts;
if (min.startsWith("*/")) return `${min.slice(2)}분마다`;
if (hour.startsWith("*/")) return `${hour.slice(2)}시간마다`;
if (hour.includes(","))
return hour
.split(",")
.map((h) => `${h.padStart(2, "0")}:${min.padStart(2, "0")}`)
.join(", ");
if (dom === "1" && hour !== "*")
return `매월 1일 ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
if (dow !== "*" && hour !== "*") {
const days = ["일", "월", "화", "수", "목", "금", "토"];
return `매주 ${days[Number(dow)] || dow}요일 ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`;
}
if (hour !== "*" && min !== "*") {
const h = Number(hour);
const ampm = h < 12 ? "오전" : "오후";
const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `매일 ${ampm} ${displayH}${min !== "0" && min !== "00" ? ` ${min}` : ""}`;
}
return cron;
}
function getNextExecution(cron: string, isActive: boolean): string {
if (!isActive) return "꺼져 있어요";
const parts = cron.split(" ");
if (parts.length < 5) return "";
const [min, hour] = parts;
if (min.startsWith("*/")) {
const interval = Number(min.slice(2));
const now = new Date();
const nextMin = Math.ceil(now.getMinutes() / interval) * interval;
if (nextMin >= 60) return `${now.getHours() + 1}:00`;
return `${now.getHours()}:${String(nextMin).padStart(2, "0")}`;
}
if (hour !== "*" && min !== "*") {
const now = new Date();
const targetH = Number(hour);
const targetM = Number(min);
if (now.getHours() < targetH || (now.getHours() === targetH && now.getMinutes() < targetM)) {
return `오늘 ${String(targetH).padStart(2, "0")}:${String(targetM).padStart(2, "0")}`;
}
return `내일 ${String(targetH).padStart(2, "0")}:${String(targetM).padStart(2, "0")}`;
}
return "";
}
function timeAgo(dateStr: string | Date | undefined): string {
if (!dateStr) return "";
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "방금 전";
if (mins < 60) return `${mins}분 전`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}시간 전`;
return `${Math.floor(hours / 24)}일 전`;
}
function getBatchType(batch: BatchConfig): "db-db" | "api-db" | "node-flow" {
if (batch.execution_type === "node_flow") return "node-flow";
const mappings = batch.batch_mappings || [];
if (mappings.some((m) => m.from_connection_type === "restapi" || (m as any).from_api_url))
return "api-db";
return "db-db";
}
const TYPE_STYLES = {
"db-db": { label: "DB → DB", className: "bg-cyan-500/10 text-cyan-600 border-cyan-500/20" },
"api-db": { label: "API → DB", className: "bg-violet-500/10 text-violet-600 border-violet-500/20" },
"node-flow": { label: "노드 플로우", className: "bg-indigo-500/10 text-indigo-600 border-indigo-500/20" },
};
type StatusFilter = "all" | "active" | "inactive";
function Sparkline({ data }: { data: SparklineData[] }) {
if (!data || data.length === 0) {
return (
<div className="flex h-8 items-end gap-[2px]">
{Array.from({ length: 24 }).map((_, i) => (
<div key={i} className="min-w-[4px] flex-1 rounded-t-sm bg-muted-foreground/10" style={{ height: "8%" }} />
))}
</div>
);
}
return (
<div className="flex h-8 items-end gap-[2px]">
{data.map((slot, i) => {
const hasFail = slot.failed > 0;
const hasSuccess = slot.success > 0;
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%";
const colorClass = hasFail
? "bg-destructive/70 hover:bg-destructive"
: hasSuccess
? "bg-emerald-500/50 hover:bg-emerald-500"
: "bg-muted-foreground/10";
return (
<div
key={i}
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
style={{ height }}
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
/>
);
})}
</div>
);
}
function ExecutionTimeline({ logs }: { logs: RecentLog[] }) {
if (!logs || logs.length === 0) {
return <p className="py-6 text-center text-xs text-muted-foreground"> </p>;
}
return (
<div className="flex flex-col">
{logs.map((log, i) => {
const isSuccess = log.status === "SUCCESS";
const isFail = log.status === "FAILED";
const isLast = i === logs.length - 1;
return (
<div key={log.id} className="flex items-start gap-3 py-2.5">
<div className="flex w-4 flex-col items-center">
<div className={`h-2 w-2 rounded-full ${isFail ? "bg-destructive" : isSuccess ? "bg-emerald-500" : "bg-amber-500 animate-pulse"}`} />
{!isLast && <div className="mt-1 min-h-[12px] w-px bg-border/50" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] font-medium">
{log.started_at ? new Date(log.started_at).toLocaleTimeString("ko-KR") : "-"}
</span>
<span className={`rounded px-1.5 py-0.5 text-[9px] font-bold ${isFail ? "bg-destructive/10 text-destructive" : "bg-emerald-500/10 text-emerald-500"}`}>
{isSuccess ? "성공" : isFail ? "실패" : log.status}
</span>
</div>
<p className="mt-0.5 truncate text-[10px] text-muted-foreground">
{isFail ? log.error_message || "알 수 없는 오류" : `${(log.total_records || 0).toLocaleString()}건 / ${((log.duration_ms || 0) / 1000).toFixed(1)}`}
</p>
</div>
</div>
);
})}
</div>
);
}
function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig; sparkline: SparklineData[]; recentLogs: RecentLog[] }) {
const batchType = getBatchType(batch);
const mappings = batch.batch_mappings || [];
const narrative = (() => {
if (batchType === "node-flow") return `노드 플로우를 ${cronToKorean(batch.cron_schedule)}에 실행해요.`;
if (mappings.length === 0) return "매핑 정보가 없어요.";
const from = mappings[0].from_table_name || "소스";
const to = mappings[0].to_table_name || "대상";
return `${from}${to} 테이블로 ${mappings.length}개 컬럼을 ${cronToKorean(batch.cron_schedule)}에 복사해요.`;
})();
return (
<div className="border-t bg-muted/20 px-6 py-5">
<p className="mb-4 text-xs text-muted-foreground">{narrative}</p>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div className="space-y-5">
<div>
<div className="mb-2 flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground"> 24</span>
</div>
<Sparkline data={sparkline} />
</div>
{batchType !== "node-flow" && mappings.length > 0 && (
<div>
<div className="mb-2 flex items-center gap-1.5">
<Link className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground"> </span>
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[9px]">{mappings.length}</Badge>
</div>
<div className="space-y-0.5">
{mappings.slice(0, 5).map((m, i) => (
<div key={i} className="flex items-center gap-1.5 rounded px-2 py-1 text-[11px]">
<span className="font-mono font-medium text-cyan-500">{m.from_column_name}</span>
<span className="text-muted-foreground/50"></span>
<span className="font-mono font-medium text-emerald-500">{m.to_column_name}</span>
{batch.conflict_key === m.to_column_name && (
<Badge variant="outline" className="ml-auto h-3.5 px-1 text-[8px] text-emerald-500 border-emerald-500/30">PK</Badge>
)}
</div>
))}
{mappings.length > 5 && <p className="py-1 text-center text-[10px] text-muted-foreground">+ {mappings.length - 5} </p>}
</div>
</div>
)}
{batchType === "node-flow" && batch.node_flow_id && (
<div className="flex items-center gap-3 rounded-lg bg-indigo-500/5 p-3">
<Workflow className="h-5 w-5 text-indigo-500" />
<div>
<p className="text-xs font-medium"> #{batch.node_flow_id}</p>
<p className="text-[10px] text-muted-foreground"> </p>
</div>
</div>
)}
<div>
<div className="mb-2 flex items-center gap-1.5">
<Settings className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground"></span>
</div>
<div className="space-y-0">
{batch.save_mode && (
<div className="flex items-center justify-between py-1">
<span className="text-[11px] text-muted-foreground"> </span>
<Badge variant="secondary" className="h-4 px-1.5 text-[9px]">{batch.save_mode}</Badge>
</div>
)}
{batch.conflict_key && (
<div className="flex items-center justify-between py-1">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="font-mono text-[10px]">{batch.conflict_key}</span>
</div>
)}
</div>
</div>
</div>
<div>
<div className="mb-2 flex items-center gap-1.5">
<BarChart3 className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[11px] font-medium text-muted-foreground"> </span>
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[9px]"> 5</Badge>
</div>
<ExecutionTimeline logs={recentLogs} />
</div>
</div>
</div>
);
}
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
if (!stats) return null;
return (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground"> 24 </span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-destructive" />
</span>
</div>
</div>
<div className="flex h-10 items-end gap-[3px]">
{Array.from({ length: 24 }).map((_, i) => {
const hasExec = Math.random() > 0.3;
const hasFail = hasExec && Math.random() < 0.08;
const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6;
return (
<div
key={i}
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/8"}`}
style={{ height: `${h}%` }}
/>
);
})}
</div>
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
<span>12 </span>
<span>6 </span>
<span></span>
</div>
</div>
);
}
export default function BatchManagementPage() {
const router = useRouter();
// 상태 관리
const { openTab } = useTabStore();
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
const [stats, setStats] = useState<BatchStats | null>(null);
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
// 페이지 로드 시 배치 목록 조회
useEffect(() => {
loadBatchConfigs();
}, [currentPage, searchTerm]);
// 배치 설정 목록 조회
const loadBatchConfigs = async () => {
const loadBatchConfigs = useCallback(async () => {
setLoading(true);
try {
const response = await BatchAPI.getBatchConfigs({
page: currentPage,
limit: 10,
search: searchTerm || undefined,
});
if (response.success && response.data) {
setBatchConfigs(response.data);
if (response.pagination) {
setTotalPages(response.pagination.totalPages);
}
const [configsResponse, statsData] = await Promise.all([
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
BatchAPI.getBatchStats(),
]);
if (configsResponse.success && configsResponse.data) {
setBatchConfigs(configsResponse.data);
// 각 배치의 스파크라인을 백그라운드로 로드
const ids = configsResponse.data.map(b => b.id!).filter(Boolean);
Promise.all(ids.map(id => BatchAPI.getBatchSparkline(id).then(data => ({ id, data })))).then(results => {
const cache: Record<number, SparklineData[]> = {};
results.forEach(r => { cache[r.id] = r.data; });
setSparklineCache(prev => ({ ...prev, ...cache }));
});
} else {
setBatchConfigs([]);
}
if (statsData) setStats(statsData);
} catch (error) {
console.error("배치 목록 조회 실패:", error);
toast.error("배치 목록을 불러오는데 실패했습니다.");
toast.error("배치 목록을 불러올 수 없어요");
setBatchConfigs([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]);
const handleRowClick = async (batchId: number) => {
if (expandedBatch === batchId) { setExpandedBatch(null); return; }
setExpandedBatch(batchId);
if (!sparklineCache[batchId]) {
const [spark, logs] = await Promise.all([
BatchAPI.getBatchSparkline(batchId),
BatchAPI.getBatchRecentLogs(batchId, 5),
]);
setSparklineCache((prev) => ({ ...prev, [batchId]: spark }));
setRecentLogsCache((prev) => ({ ...prev, [batchId]: logs }));
}
};
// 배치 수동 실행
const executeBatch = async (batchId: number) => {
const toggleBatchActive = async (batchId: number, currentActive: string) => {
const newActive = currentActive === "Y" ? "N" : "Y";
setTogglingBatch(batchId);
try {
await BatchAPI.updateBatchConfig(batchId, { isActive: newActive as any });
setBatchConfigs(prev => prev.map(b => b.id === batchId ? { ...b, is_active: newActive as "Y" | "N" } : b));
toast.success(newActive === "Y" ? "배치를 켰어요" : "배치를 껐어요");
} catch {
toast.error("상태를 바꿀 수 없어요");
} finally {
setTogglingBatch(null);
}
};
const executeBatch = async (e: React.MouseEvent, batchId: number) => {
e.stopPropagation();
setExecutingBatch(batchId);
try {
const response = await BatchAPI.executeBatchConfig(batchId);
if (response.success) {
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
toast.success(`실행 완료! ${response.data?.totalRecords || 0}건 처리했어요`);
setSparklineCache((prev) => { const c = { ...prev }; delete c[batchId]; return c; });
setRecentLogsCache((prev) => { const c = { ...prev }; delete c[batchId]; return c; });
loadBatchConfigs();
} else {
toast.error("배치 실행에 실패했습니다.");
toast.error("배치 실행에 실패했어요");
}
} catch (error) {
console.error("배치 실행 실패:", error);
showErrorToast("배치 실행에 실패했습니다", error, {
guidance: "배치 설정을 확인하고 다시 시도해 주세요.",
});
showErrorToast("배치 실행 실패", error, { guidance: "설정을 확인하고 다시 시도해 주세요." });
} finally {
setExecutingBatch(null);
}
};
// 배치 활성화/비활성화 토글
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
try {
const newStatus = currentStatus === 'Y' ? 'N' : 'Y';
console.log("📝 새로운 상태:", newStatus);
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
console.log("✅ API 호출 성공:", result);
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`);
loadBatchConfigs(); // 목록 새로고침
} catch (error) {
console.error("❌ 배치 상태 변경 실패:", error);
toast.error("배치 상태 변경에 실패했습니다.");
}
};
// 배치 삭제
const deleteBatch = async (batchId: number, batchName: string) => {
if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) {
return;
}
const deleteBatch = async (e: React.MouseEvent, batchId: number, batchName: string) => {
e.stopPropagation();
if (!confirm(`'${batchName}' 배치를 삭제할까요?`)) return;
try {
await BatchAPI.deleteBatchConfig(batchId);
toast.success("배치 삭제되었습니다.");
loadBatchConfigs(); // 목록 새로고침
} catch (error) {
console.error("배치 삭제 실패:", error);
toast.error("배치 삭제에 실패했습니다.");
toast.success("배치 삭제했어요");
loadBatchConfigs();
} catch {
toast.error("배치 삭제 실패했어요");
}
};
// 검색 처리
const handleSearch = (value: string) => {
setSearchTerm(value);
setCurrentPage(1); // 검색 시 첫 페이지로 이동
};
// 매핑 정보 요약 생성
const getMappingSummary = (mappings: BatchMapping[]) => {
if (!mappings || mappings.length === 0) {
return "매핑 없음";
}
const tableGroups = new Map<string, number>();
mappings.forEach(mapping => {
const key = `${mapping.from_table_name}${mapping.to_table_name}`;
tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
});
const summaries = Array.from(tableGroups.entries()).map(([key, count]) =>
`${key} (${count}개 컬럼)`
);
return summaries.join(", ");
};
// 배치 추가 버튼 클릭 핸들러
const handleCreateBatch = () => {
setIsBatchTypeModalOpen(true);
};
// 배치 타입 선택 핸들러
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => {
console.log("배치 타입 선택:", type);
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db" | "node-flow") => {
setIsBatchTypeModalOpen(false);
if (type === 'db-to-db') {
// 기존 DB → DB 배치 생성 페이지로 이동
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create');
router.push('/admin/batchmng/create');
} else if (type === 'restapi-to-db') {
// 새로운 REST API 배치 페이지로 이동
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new');
try {
router.push('/admin/batch-management-new');
console.log("라우터 push 실행 완료");
} catch (error) {
console.error("라우터 push 오류:", error);
// 대안: window.location 사용
window.location.href = '/admin/batch-management-new';
}
if (type === "db-to-db") {
sessionStorage.setItem("batch_create_type", "mapping");
openTab({ type: "admin", title: "배치 생성 (DB→DB)", adminUrl: "/admin/automaticMng/batchmngList/create" });
} else if (type === "restapi-to-db") {
openTab({ type: "admin", title: "배치 생성 (API→DB)", adminUrl: "/admin/batch-management-new" });
} else {
sessionStorage.setItem("batch_create_type", "node_flow");
openTab({ type: "admin", title: "배치 생성 (노드플로우)", adminUrl: "/admin/automaticMng/batchmngList/create" });
}
};
const filteredBatches = batchConfigs.filter((batch) => {
if (searchTerm && !batch.batch_name.toLowerCase().includes(searchTerm.toLowerCase()) && !(batch.description || "").toLowerCase().includes(searchTerm.toLowerCase())) return false;
if (statusFilter === "active" && batch.is_active !== "Y") return false;
if (statusFilter === "inactive" && batch.is_active !== "N") return false;
return true;
});
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
const inactiveBatches = batchConfigs.length - activeBatches;
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
return (
<div className="flex min-h-screen flex-col bg-background">
<div className="space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> .</p>
</div>
<div className="mx-auto w-full max-w-[720px] space-y-4 px-4 py-6 sm:px-6">
{/* 검색 및 액션 영역 */}
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 검색 영역 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="w-full sm:w-[400px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="배치명 또는 설명으로 검색..."
value={searchTerm}
onChange={(e) => handleSearch(e.target.value)}
className="h-10 pl-10 text-sm"
/>
</div>
</div>
<Button
variant="outline"
onClick={loadBatchConfigs}
disabled={loading}
className="h-10 gap-2 text-sm font-medium"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
{/* 헤더 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold tracking-tight"> </h1>
<p className="text-xs text-muted-foreground"> </p>
</div>
{/* 액션 버튼 영역 */}
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
{" "}
<span className="font-semibold text-foreground">
{batchConfigs.length.toLocaleString()}
</span>{" "}
</div>
<Button
onClick={handleCreateBatch}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
<div className="flex items-center gap-2">
<button onClick={loadBatchConfigs} disabled={loading} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</button>
<Button size="sm" onClick={() => setIsBatchTypeModalOpen(true)} className="h-8 gap-1 text-xs">
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* 배치 목록 */}
{batchConfigs.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<Database className="h-12 w-12 text-muted-foreground" />
<div className="space-y-2">
<h3 className="text-lg font-semibold"> </h3>
<p className="text-sm text-muted-foreground">
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
</p>
</div>
{!searchTerm && (
<Button
onClick={handleCreateBatch}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button>
{/* 통계 요약 스트립 */}
{stats && (
<div className="flex items-center gap-0 rounded-lg border bg-card">
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"></span>
<span className="text-lg font-bold">{batchConfigs.length}</span>
</div>
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="text-lg font-bold text-primary">{activeBatches}</span>
</div>
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
{execDiff !== 0 && (
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
{execDiff > 0 ? "+" : ""}{execDiff}
</span>
)}
</div>
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"></span>
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
{stats.todayFailures}
</span>
{failDiff !== 0 && (
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
{failDiff > 0 ? "+" : ""}{failDiff}
</span>
)}
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{batchConfigs.map((batch) => (
<BatchCard
key={batch.id}
batch={batch}
executingBatch={executingBatch}
onExecute={executeBatch}
onToggleStatus={(batchId, currentStatus) => {
toggleBatchStatus(batchId, currentStatus);
}}
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
onDelete={deleteBatch}
getMappingSummary={getMappingSummary}
/>
))}
</div>
)}
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="h-10 text-sm font-medium"
>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
onClick={() => setCurrentPage(pageNum)}
className="h-10 min-w-[40px] text-sm"
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="h-10 text-sm font-medium"
>
</Button>
{/* 24시간 차트 */}
<GlobalSparkline stats={stats} />
{/* 검색 + 필터 */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[180px] flex-1">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="배치 이름으로 검색하세요" value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="h-8 pl-9 text-xs" />
</div>
)}
<div className="flex gap-0.5 rounded-lg border bg-muted/30 p-0.5">
{([
{ value: "all", label: `전체 ${batchConfigs.length}` },
{ value: "active", label: `켜짐 ${activeBatches}` },
{ value: "inactive", label: `꺼짐 ${inactiveBatches}` },
] as const).map((item) => (
<button
key={item.value}
className={`rounded-md px-2.5 py-1 text-[11px] font-semibold transition-colors ${statusFilter === item.value ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setStatusFilter(item.value)}
>
{item.label}
</button>
))}
</div>
</div>
{/* 배치 리스트 */}
<div className="space-y-1.5">
{loading && batchConfigs.length === 0 && (
<div className="flex h-40 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!loading && filteredBatches.length === 0 && (
<div className="flex h-40 flex-col items-center justify-center gap-2">
<Database className="h-6 w-6 text-muted-foreground/40" />
<p className="text-xs text-muted-foreground">{searchTerm ? "검색 결과가 없어요" : "등록된 배치가 없어요"}</p>
</div>
)}
{filteredBatches.map((batch) => {
const batchId = batch.id!;
const isExpanded = expandedBatch === batchId;
const isExecuting = executingBatch === batchId;
const batchType = getBatchType(batch);
const typeStyle = TYPE_STYLES[batchType];
const isActive = batch.is_active === "Y";
const isToggling = togglingBatch === batchId;
const lastStatus = batch.last_status;
const lastAt = batch.last_executed_at;
const isFailed = lastStatus === "FAILED";
const isSuccess = lastStatus === "SUCCESS";
return (
<div key={batchId} className={`overflow-hidden rounded-lg border transition-all ${isExpanded ? "ring-1 ring-primary/20" : "hover:border-muted-foreground/20"} ${!isActive ? "opacity-55" : ""}`}>
{/* 행 */}
<div className="flex cursor-pointer items-center gap-3 px-4 py-3.5 sm:gap-4" onClick={() => handleRowClick(batchId)}>
{/* 토글 */}
<div onClick={(e) => e.stopPropagation()} className="shrink-0">
<Switch
checked={isActive}
onCheckedChange={() => toggleBatchActive(batchId, batch.is_active || "N")}
disabled={isToggling}
className="scale-[0.7]"
/>
</div>
{/* 배치 이름 + 설명 */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{batch.batch_name}</p>
<p className="truncate text-[11px] text-muted-foreground">{batch.description || ""}</p>
</div>
{/* 타입 뱃지 */}
<span className={`hidden shrink-0 rounded border px-2 py-0.5 text-[10px] font-semibold sm:inline-flex ${typeStyle.className}`}>
{typeStyle.label}
</span>
{/* 스케줄 */}
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 90 }}>
<p className="text-[12px] font-medium">{cronToKorean(batch.cron_schedule)}</p>
<p className="text-[10px] text-muted-foreground">
{getNextExecution(batch.cron_schedule, isActive)
? `다음: ${getNextExecution(batch.cron_schedule, isActive)}`
: ""}
</p>
</div>
{/* 인라인 미니 스파크라인 */}
<div className="hidden shrink-0 sm:block" style={{ width: 64 }}>
<Sparkline data={sparklineCache[batchId] || []} />
</div>
{/* 마지막 실행 */}
<div className="hidden shrink-0 text-right sm:block" style={{ minWidth: 70 }}>
{isExecuting ? (
<p className="text-[11px] font-semibold text-amber-500"> ...</p>
) : lastAt ? (
<>
<div className="flex items-center justify-end gap-1">
{isFailed ? (
<AlertCircle className="h-3 w-3 text-destructive" />
) : isSuccess ? (
<CheckCircle className="h-3 w-3 text-emerald-500" />
) : null}
<span className={`text-[11px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
{isFailed ? "실패" : "성공"}
</span>
</div>
<p className="text-[10px] text-muted-foreground">{timeAgo(lastAt)}</p>
</>
) : (
<p className="text-[11px] text-muted-foreground">&mdash;</p>
)}
</div>
{/* 액션 */}
<div className="flex shrink-0 items-center gap-0.5">
<button
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-emerald-500/10 hover:text-emerald-500"
onClick={(e) => executeBatch(e, batchId)}
disabled={isExecuting}
title="지금 실행하기"
>
{isExecuting ? <RefreshCw className="h-3.5 w-3.5 animate-spin" /> : <Play className="h-3.5 w-3.5" />}
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={(e) => { e.stopPropagation(); openTab({ type: "admin", title: `배치 편집 #${batchId}`, adminUrl: `/admin/automaticMng/batchmngList/edit/${batchId}` }); }}
title="수정하기"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
onClick={(e) => deleteBatch(e, batchId, batch.batch_name)}
title="삭제하기"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
<ChevronDown className={`ml-0.5 h-3.5 w-3.5 text-muted-foreground transition-transform ${isExpanded ? "rotate-180" : ""}`} />
</div>
</div>
{/* 모바일 메타 */}
<div className="flex items-center gap-2 px-4 pb-2 sm:hidden">
<span className={`rounded border px-1.5 py-0.5 text-[9px] font-semibold ${typeStyle.className}`}>{typeStyle.label}</span>
<span className="text-[10px] text-muted-foreground">{cronToKorean(batch.cron_schedule)}</span>
{lastAt && (
<span className={`ml-auto text-[10px] font-semibold ${isFailed ? "text-destructive" : "text-emerald-500"}`}>
{isFailed ? "실패" : "성공"} {timeAgo(lastAt)}
</span>
)}
</div>
{/* 확장 패널 */}
{isExpanded && (
<BatchDetailPanel batch={batch} sparkline={sparklineCache[batchId] || []} recentLogs={recentLogsCache[batchId] || []} />
)}
</div>
);
})}
</div>
{/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-lg border bg-card p-6 shadow-lg">
<div className="space-y-6">
<h2 className="text-xl font-semibold text-center"> </h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* DB → DB */}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-lg" onClick={(e) => e.stopPropagation()}>
<h2 className="mb-1 text-base font-bold"> ?</h2>
<p className="mb-5 text-xs text-muted-foreground"> </p>
<div className="space-y-2">
{[
{ type: "db-to-db" as const, icon: Database, iconColor: "text-cyan-500", title: "DB → DB", desc: "테이블 데이터를 다른 테이블로 복사해요" },
{ type: "restapi-to-db" as const, icon: Cloud, iconColor: "text-violet-500", title: "API → DB", desc: "외부 API에서 데이터를 가져와 저장해요" },
{ type: "node-flow" as const, icon: Workflow, iconColor: "text-indigo-500", title: "노드 플로우", desc: "만들어 둔 플로우를 자동으로 실행해요" },
].map((opt) => (
<button
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
onClick={() => handleBatchTypeSelect('db-to-db')}
key={opt.type}
className="flex w-full items-center gap-3.5 rounded-lg border p-4 text-left transition-all hover:border-primary/30 hover:bg-primary/5"
onClick={() => handleBatchTypeSelect(opt.type)}
>
<div className="flex items-center gap-2">
<Database className="h-8 w-8 text-primary" />
<span className="text-muted-foreground"></span>
<Database className="h-8 w-8 text-primary" />
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<opt.icon className={`h-[18px] w-[18px] ${opt.iconColor}`} />
</div>
<div className="space-y-1 text-center">
<div className="text-lg font-medium">DB DB</div>
<div className="text-sm text-muted-foreground"> </div>
<div>
<p className="text-sm font-semibold">{opt.title}</p>
<p className="text-[11px] text-muted-foreground">{opt.desc}</p>
</div>
</button>
{/* REST API → DB */}
<button
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
onClick={() => handleBatchTypeSelect('restapi-to-db')}
>
<div className="flex items-center gap-2">
<span className="text-2xl">🌐</span>
<span className="text-muted-foreground"></span>
<Database className="h-8 w-8 text-primary" />
</div>
<div className="space-y-1 text-center">
<div className="text-lg font-medium">REST API DB</div>
<div className="text-sm text-muted-foreground">REST API에서 </div>
</div>
</button>
</div>
<div className="flex justify-center pt-2">
<Button
variant="outline"
onClick={() => setIsBatchTypeModalOpen(false)}
className="h-10 text-sm font-medium"
>
</Button>
</div>
))}
</div>
<button onClick={() => setIsBatchTypeModalOpen(false)} className="mt-4 w-full rounded-md border py-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
</button>
</div>
</div>
)}
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}
}
@@ -2,6 +2,7 @@
import React, { useState, useEffect, useMemo, memo } from "react";
import { useRouter } from "next/navigation";
import { useTabStore } from "@/stores/tabStore";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -62,6 +63,7 @@ interface DbToRestApiMappingCardProps {
export default function BatchManagementNewPage() {
const router = useRouter();
const { openTab } = useTabStore();
// 기본 상태
const [batchName, setBatchName] = useState("");
@@ -463,7 +465,7 @@ export default function BatchManagementNewPage() {
if (result.success) {
toast.success(result.message || "REST API 배치 설정이 저장되었습니다.");
setTimeout(() => {
router.push("/admin/batchmng");
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
}, 1000);
} else {
toast.error(result.message || "배치 저장에 실패했습니다.");
@@ -554,7 +556,7 @@ export default function BatchManagementNewPage() {
if (result.success) {
toast.success(result.message || "DB → REST API 배치 설정이 저장되었습니다.");
setTimeout(() => {
router.push("/admin/batchmng");
openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
}, 1000);
} else {
toast.error(result.message || "배치 저장에 실패했습니다.");
@@ -571,79 +573,68 @@ export default function BatchManagementNewPage() {
toast.error("지원하지 않는 배치 타입입니다.");
};
const goBack = () => openTab({ type: "admin", title: "배치 관리", adminUrl: "/admin/automaticMng/batchmngList" });
return (
<div className="container mx-auto space-y-6 p-6">
{/* 페이지 헤더 */}
<div className="border-b pb-4">
<h1 className="text-3xl font-bold"> </h1>
<div className="mx-auto max-w-5xl space-y-6 p-4 sm:p-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={goBack} className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
</button>
<div>
<h1 className="text-lg font-semibold sm:text-xl"> </h1>
<p className="text-xs text-muted-foreground">REST API / DB </p>
</div>
</div>
</div>
{/* 배치 타입 선택 */}
<div className="grid grid-cols-2 gap-3">
{batchTypeOptions.map((option) => (
<button
key={option.value}
onClick={() => setBatchType(option.value)}
className={`group relative flex items-center gap-3 rounded-lg border p-4 text-left transition-all ${
batchType === option.value
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border hover:border-muted-foreground/30 hover:bg-muted/50"
}`}
>
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${batchType === option.value ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"}`}>
{option.value === "restapi-to-db" ? <Globe className="h-5 w-5" /> : <Database className="h-5 w-5" />}
</div>
<div className="min-w-0">
<div className="text-sm font-medium">{option.label}</div>
<div className="text-[11px] text-muted-foreground">{option.description}</div>
</div>
{batchType === option.value && <div className="absolute right-3 top-3 h-2 w-2 rounded-full bg-primary" />}
</button>
))}
</div>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 배치 타입 선택 */}
<div>
<Label> *</Label>
<div className="mt-2 grid grid-cols-1 gap-3 md:grid-cols-2">
{batchTypeOptions.map((option) => (
<div
key={option.value}
className={`cursor-pointer rounded-lg border p-3 transition-all ${
batchType === option.value ? "border-primary bg-primary/10" : "border-border hover:border-input"
}`}
onClick={() => setBatchType(option.value)}
>
<div className="flex items-center space-x-2">
{option.value === "restapi-to-db" ? (
<Globe className="h-4 w-4 text-primary" />
) : (
<Database className="h-4 w-4 text-emerald-600" />
)}
<div>
<div className="text-sm font-medium">{option.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{option.description}</div>
</div>
</div>
</div>
))}
</div>
<div className="space-y-4 rounded-lg border p-4 sm:p-5">
<div className="flex items-center gap-2 text-sm font-medium">
<Eye className="h-4 w-4 text-muted-foreground" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="batchName" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="batchName" value={batchName} onChange={e => setBatchName(e.target.value)} placeholder="배치명을 입력하세요" className="h-9 text-sm" />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label htmlFor="batchName"> *</Label>
<Input
id="batchName"
value={batchName}
onChange={(e) => setBatchName(e.target.value)}
placeholder="배치명을 입력하세요"
/>
</div>
<div>
<Label htmlFor="cronSchedule"> *</Label>
<Input
id="cronSchedule"
value={cronSchedule}
onChange={(e) => setCronSchedule(e.target.value)}
placeholder="0 12 * * *"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cronSchedule" className="text-xs"> <span className="text-destructive">*</span></Label>
<Input id="cronSchedule" value={cronSchedule} onChange={e => setCronSchedule(e.target.value)} placeholder="0 12 * * *" className="h-9 font-mono text-sm" />
</div>
<div>
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="배치에 대한 설명을 입력하세요"
/>
</div>
</CardContent>
</Card>
</div>
<div className="space-y-1.5">
<Label htmlFor="description" className="text-xs"></Label>
<Textarea id="description" value={description} onChange={e => setDescription(e.target.value)} placeholder="배치에 대한 설명을 입력하세요" rows={2} className="resize-none text-sm" />
</div>
</div>
{/* FROM/TO 설정 - 가로 배치 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
@@ -1426,13 +1417,14 @@ export default function BatchManagementNewPage() {
)}
{/* 하단 액션 버튼 */}
<div className="flex items-center justify-end gap-2 border-t pt-6">
<Button onClick={loadConnections} variant="outline" className="gap-2">
<RefreshCw className="h-4 w-4" />
<div className="flex items-center justify-end gap-2 border-t pt-4">
<Button onClick={goBack} variant="outline" size="sm" className="h-8 gap-1 text-xs"></Button>
<Button onClick={loadConnections} variant="outline" size="sm" className="h-8 gap-1 text-xs">
<RefreshCw className="h-3.5 w-3.5" />
</Button>
<Button onClick={handleSave} className="gap-2">
<Save className="h-4 w-4" />
<Button onClick={handleSave} size="sm" className="h-8 gap-1 text-xs">
<Save className="h-3.5 w-3.5" />
</Button>
</div>
@@ -0,0 +1,72 @@
"use client";
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
const config: ReportConfig = {
key: "equipment_report_v2",
title: "설비 리포트",
description: "설비 가동/보전 다중 조건 비교 분석",
apiEndpoint: "/report/equipment/data",
metrics: [
{ id: "runTime", name: "가동시간", unit: "H", color: "#3b82f6" },
{ id: "downTime", name: "비가동시간", unit: "H", color: "#ef4444" },
{ id: "opRate", name: "가동률", unit: "%", color: "#10b981", isRate: true },
{ id: "faultCnt", name: "고장횟수", unit: "회", color: "#f59e0b" },
{ id: "mtbf", name: "MTBF", unit: "H", color: "#8b5cf6" },
{ id: "mttr", name: "MTTR", unit: "H", color: "#ec4899" },
{ id: "maintCost", name: "보전비용", unit: "만원", color: "#06b6d4" },
{ id: "prodQty", name: "생산수량", unit: "EA", color: "#84cc16" },
],
groupByOptions: [
{ id: "equipment", name: "설비별" },
{ id: "equipType", name: "설비유형별" },
{ id: "line", name: "라인별" },
{ id: "manager", name: "담당자별" },
{ id: "monthly", name: "월별" },
{ id: "quarterly", name: "분기별" },
{ id: "weekly", name: "주별" },
{ id: "daily", name: "일별" },
],
defaultGroupBy: "equipment",
defaultMetrics: ["runTime"],
thresholds: [
{ id: "fault", label: "고장횟수 ≥", defaultValue: 3, unit: "회" },
{ id: "opRate", label: "가동률 ≤", defaultValue: 80, unit: "%" },
],
filterFieldDefs: [
{ id: "equipment", name: "설비", type: "select", optionKey: "equipment" },
{ id: "equipType", name: "설비유형", type: "select", optionKey: "equipTypes" },
{ id: "line", name: "라인", type: "select", optionKey: "lines" },
{ id: "manager", name: "담당자", type: "select", optionKey: "managers" },
{ id: "opRate", name: "가동률", type: "number" },
{ id: "faultCnt", name: "고장횟수", type: "number" },
],
drilldownColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "equipment", name: "설비" },
{ id: "equipType", name: "설비유형" },
{ id: "line", name: "라인" },
{ id: "status", name: "상태", format: "badge" },
{ id: "runTime", name: "가동시간", align: "right", format: "number" },
{ id: "downTime", name: "비가동시간", align: "right", format: "number" },
{ id: "opRate", name: "가동률(%)", align: "right", format: "number" },
{ id: "faultCnt", name: "고장횟수", align: "right", format: "number" },
],
rawDataColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "equipment_code", name: "설비코드" },
{ id: "equipment", name: "설비명" },
{ id: "equipType", name: "설비유형" },
{ id: "line", name: "라인" },
{ id: "manager", name: "담당자" },
{ id: "status", name: "상태", format: "badge" },
{ id: "runTime", name: "가동시간", align: "right", format: "number" },
{ id: "downTime", name: "비가동시간", align: "right", format: "number" },
{ id: "faultCnt", name: "고장횟수", align: "right", format: "number" },
],
emptyMessage: "설비 데이터가 없습니다",
};
export default function EquipmentReportPage() {
return <ReportEngine config={config} />;
}
@@ -0,0 +1,67 @@
"use client";
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
const config: ReportConfig = {
key: "inventory_report_v2",
title: "재고 리포트",
description: "재고 현황 다중 조건 비교 분석",
apiEndpoint: "/report/inventory/data",
metrics: [
{ id: "currentQty", name: "현재고", unit: "EA", color: "#3b82f6" },
{ id: "safetyQty", name: "안전재고", unit: "EA", color: "#10b981" },
{ id: "inQty", name: "입고수량", unit: "EA", color: "#f59e0b" },
{ id: "outQty", name: "출고수량", unit: "EA", color: "#ef4444" },
{ id: "turnover", name: "회전율", unit: "회", color: "#8b5cf6", isRate: true },
{ id: "stockValue", name: "재고금액", unit: "만원", color: "#ec4899" },
{ id: "shortageQty", name: "부족수량", unit: "EA", color: "#06b6d4" },
],
groupByOptions: [
{ id: "item", name: "품목별" },
{ id: "warehouse", name: "창고별" },
{ id: "category", name: "카테고리별" },
{ id: "monthly", name: "월별" },
{ id: "quarterly", name: "분기별" },
{ id: "weekly", name: "주별" },
{ id: "daily", name: "일별" },
],
defaultGroupBy: "item",
defaultMetrics: ["currentQty"],
thresholds: [
{ id: "safety", label: "안전재고 이하 경고", defaultValue: 0, unit: "EA" },
{ id: "turnover", label: "회전율 ≤", defaultValue: 2, unit: "회" },
],
filterFieldDefs: [
{ id: "item", name: "품목", type: "select", optionKey: "items" },
{ id: "warehouse", name: "창고", type: "select", optionKey: "warehouses" },
{ id: "category", name: "카테고리", type: "select", optionKey: "categories" },
{ id: "currentQty", name: "현재고", type: "number" },
{ id: "turnover", name: "회전율", type: "number" },
],
drilldownColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "item", name: "품목" },
{ id: "warehouse", name: "창고" },
{ id: "currentQty", name: "현재고", align: "right", format: "number" },
{ id: "safetyQty", name: "안전재고", align: "right", format: "number" },
{ id: "inQty", name: "입고", align: "right", format: "number" },
{ id: "outQty", name: "출고", align: "right", format: "number" },
{ id: "shortageQty", name: "부족", align: "right", format: "number" },
],
rawDataColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "item_code", name: "품목코드" },
{ id: "item", name: "품목명" },
{ id: "warehouse", name: "창고" },
{ id: "category", name: "카테고리" },
{ id: "currentQty", name: "현재고", align: "right", format: "number" },
{ id: "safetyQty", name: "안전재고", align: "right", format: "number" },
{ id: "inQty", name: "입고", align: "right", format: "number" },
{ id: "outQty", name: "출고", align: "right", format: "number" },
],
emptyMessage: "재고 데이터가 없습니다",
};
export default function InventoryReportPage() {
return <ReportEngine config={config} />;
}
@@ -0,0 +1,73 @@
"use client";
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
const config: ReportConfig = {
key: "mold_report_v2",
title: "금형 리포트",
description: "금형 수명/관리 다중 조건 비교 분석",
apiEndpoint: "/report/mold/data",
metrics: [
{ id: "shotCnt", name: "타수", unit: "회", color: "#3b82f6" },
{ id: "guaranteeShot", name: "보증타수", unit: "회", color: "#10b981" },
{ id: "lifeRate", name: "수명률", unit: "%", color: "#f59e0b", isRate: true },
{ id: "repairCnt", name: "수리횟수", unit: "회", color: "#ef4444" },
{ id: "repairCost", name: "수리비용", unit: "만원", color: "#8b5cf6" },
{ id: "prodQty", name: "생산수량", unit: "EA", color: "#ec4899" },
{ id: "defectRate", name: "불량률", unit: "%", color: "#06b6d4", isRate: true },
{ id: "cavityUse", name: "캐비티사용률", unit: "%", color: "#84cc16", isRate: true },
],
groupByOptions: [
{ id: "mold", name: "금형별" },
{ id: "moldType", name: "금형유형별" },
{ id: "item", name: "적용품목별" },
{ id: "maker", name: "제조사별" },
{ id: "monthly", name: "월별" },
{ id: "quarterly", name: "분기별" },
{ id: "weekly", name: "주별" },
{ id: "daily", name: "일별" },
],
defaultGroupBy: "mold",
defaultMetrics: ["shotCnt"],
thresholds: [
{ id: "life", label: "보증타수 도달률 ≥", defaultValue: 90, unit: "%" },
{ id: "cost", label: "수리비용 ≥", defaultValue: 100, unit: "만원" },
],
filterFieldDefs: [
{ id: "mold", name: "금형", type: "select", optionKey: "molds" },
{ id: "moldType", name: "금형유형", type: "select", optionKey: "moldTypes" },
{ id: "item", name: "적용품목", type: "select", optionKey: "items" },
{ id: "maker", name: "제조사", type: "select", optionKey: "makers" },
{ id: "shotCnt", name: "타수", type: "number" },
{ id: "lifeRate", name: "수명률", type: "number" },
],
drilldownColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "mold", name: "금형" },
{ id: "moldType", name: "금형유형" },
{ id: "item", name: "적용품목" },
{ id: "status", name: "상태", format: "badge" },
{ id: "shotCnt", name: "타수", align: "right", format: "number" },
{ id: "guaranteeShot", name: "보증타수", align: "right", format: "number" },
{ id: "lifeRate", name: "수명률(%)", align: "right", format: "number" },
{ id: "repairCnt", name: "수리횟수", align: "right", format: "number" },
],
rawDataColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "mold_code", name: "금형코드" },
{ id: "mold", name: "금형명" },
{ id: "moldType", name: "금형유형" },
{ id: "maker", name: "제조사" },
{ id: "status", name: "상태", format: "badge" },
{ id: "shotCnt", name: "타수", align: "right", format: "number" },
{ id: "guaranteeShot", name: "보증타수", align: "right", format: "number" },
{ id: "lifeRate", name: "수명률(%)", align: "right", format: "number" },
{ id: "repairCnt", name: "수리횟수", align: "right", format: "number" },
{ id: "repairCost", name: "수리비용", align: "right", format: "number" },
],
emptyMessage: "금형 데이터가 없습니다",
};
export default function MoldReportPage() {
return <ReportEngine config={config} />;
}
@@ -0,0 +1,80 @@
"use client";
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
const config: ReportConfig = {
key: "production_report_v2",
title: "생산 리포트",
description: "생산 실적 다중 조건 비교 분석",
apiEndpoint: "/report/production/data",
metrics: [
{ id: "prodQty", name: "생산량", unit: "EA", color: "#3b82f6" },
{ id: "planQty", name: "계획수량", unit: "EA", color: "#10b981" },
{ id: "defectQty", name: "불량수량", unit: "EA", color: "#ef4444" },
{ id: "defectRate", name: "불량률", unit: "%", color: "#f59e0b", isRate: true },
{ id: "opRate", name: "가동률", unit: "%", color: "#8b5cf6", isRate: true },
{ id: "achRate", name: "달성률", unit: "%", color: "#ec4899", isRate: true },
{ id: "runTime", name: "가동시간", unit: "H", color: "#06b6d4" },
{ id: "downTime", name: "비가동시간", unit: "H", color: "#84cc16" },
],
groupByOptions: [
{ id: "process", name: "공정별" },
{ id: "equipment", name: "설비별" },
{ id: "item", name: "품목별" },
{ id: "worker", name: "작업자별" },
{ id: "monthly", name: "월별" },
{ id: "quarterly", name: "분기별" },
{ id: "weekly", name: "주별" },
{ id: "daily", name: "일별" },
],
defaultGroupBy: "process",
defaultMetrics: ["prodQty"],
thresholds: [
{ id: "defect", label: "불량률 ≥", defaultValue: 3, unit: "%" },
{ id: "opRate", label: "가동률 ≤", defaultValue: 85, unit: "%" },
],
filterFieldDefs: [
{ id: "process", name: "공정", type: "select", optionKey: "processes" },
{ id: "equipment", name: "설비", type: "select", optionKey: "equipment" },
{ id: "item", name: "품목", type: "select", optionKey: "items" },
{ id: "worker", name: "작업자", type: "select", optionKey: "workers" },
{ id: "prodQty", name: "생산량", type: "number" },
{ id: "defectRate", name: "불량률", type: "number" },
],
drilldownColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "process", name: "공정" },
{ id: "equipment", name: "설비" },
{ id: "item", name: "품목" },
{ id: "worker", name: "작업자" },
{ id: "prodQty", name: "생산량", align: "right", format: "number" },
{ id: "planQty", name: "계획수량", align: "right", format: "number" },
{ id: "defectQty", name: "불량수량", align: "right", format: "number" },
{ id: "defectRate", name: "불량률(%)", align: "right", format: "number" },
],
rawDataColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "process", name: "공정" },
{ id: "equipment", name: "설비" },
{ id: "item", name: "품목" },
{ id: "worker", name: "작업자" },
{ id: "prodQty", name: "생산량", align: "right", format: "number" },
{ id: "planQty", name: "계획수량", align: "right", format: "number" },
{ id: "defectQty", name: "불량수량", align: "right", format: "number" },
{ id: "runTime", name: "가동시간", align: "right", format: "number" },
{ id: "downTime", name: "비가동시간", align: "right", format: "number" },
],
enrichRow: (d) => ({
...d,
defectRate: d.prodQty > 0 ? parseFloat((d.defectQty / d.prodQty * 100).toFixed(1)) : 0,
opRate: (d.runTime + d.downTime) > 0
? parseFloat(((d.runTime / (d.runTime + d.downTime)) * 100).toFixed(1))
: 0,
achRate: d.planQty > 0 ? parseFloat((d.prodQty / d.planQty * 100).toFixed(1)) : 0,
}),
emptyMessage: "생산 데이터가 없습니다",
};
export default function ProductionReportPage() {
return <ReportEngine config={config} />;
}
@@ -0,0 +1,76 @@
"use client";
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
const config: ReportConfig = {
key: "purchase_report_v2",
title: "구매 리포트",
description: "구매/발주 다중 조건 비교 분석",
apiEndpoint: "/report/purchase/data",
metrics: [
{ id: "orderAmt", name: "발주금액", unit: "원", color: "#3b82f6" },
{ id: "receiveAmt", name: "입고금액", unit: "원", color: "#10b981" },
{ id: "orderQty", name: "발주수량", unit: "EA", color: "#f59e0b" },
{ id: "receiveQty", name: "입고수량", unit: "EA", color: "#8b5cf6" },
{ id: "receiveRate", name: "입고율", unit: "%", color: "#ef4444", isRate: true },
{ id: "unitPrice", name: "단가", unit: "원", color: "#ec4899" },
{ id: "orderCnt", name: "발주건수", unit: "건", color: "#f97316" },
],
groupByOptions: [
{ id: "supplier", name: "공급업체별" },
{ id: "item", name: "품목별" },
{ id: "manager", name: "구매담당별" },
{ id: "status", name: "상태별" },
{ id: "monthly", name: "월별" },
{ id: "quarterly", name: "분기별" },
{ id: "weekly", name: "주별" },
{ id: "daily", name: "일별" },
],
defaultGroupBy: "supplier",
defaultMetrics: ["orderAmt"],
thresholds: [
{ id: "delay", label: "납기지연 ≥", defaultValue: 3, unit: "일" },
{ id: "price", label: "단가변동률 ≥", defaultValue: 10, unit: "%" },
],
filterFieldDefs: [
{ id: "supplier", name: "공급업체", type: "select", optionKey: "suppliers" },
{ id: "item", name: "품목", type: "select", optionKey: "items" },
{ id: "manager", name: "구매담당", type: "select", optionKey: "managers" },
{ id: "status", name: "상태", type: "select", optionKey: "statuses" },
{ id: "orderAmt", name: "발주금액", type: "number" },
{ id: "orderQty", name: "발주수량", type: "number" },
],
drilldownColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "purchase_no", name: "발주번호" },
{ id: "supplier", name: "공급업체" },
{ id: "item", name: "품목" },
{ id: "status", name: "상태", format: "badge" },
{ id: "orderQty", name: "발주수량", align: "right", format: "number" },
{ id: "receiveQty", name: "입고수량", align: "right", format: "number" },
{ id: "unitPrice", name: "단가", align: "right", format: "number" },
{ id: "orderAmt", name: "발주금액", align: "right", format: "number" },
],
rawDataColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "purchase_no", name: "발주번호" },
{ id: "supplier", name: "공급업체" },
{ id: "item_code", name: "품목코드" },
{ id: "item", name: "품목명" },
{ id: "status", name: "상태", format: "badge" },
{ id: "orderQty", name: "발주수량", align: "right", format: "number" },
{ id: "receiveQty", name: "입고수량", align: "right", format: "number" },
{ id: "unitPrice", name: "단가", align: "right", format: "number" },
{ id: "orderAmt", name: "발주금액", align: "right", format: "number" },
{ id: "manager", name: "담당자" },
],
enrichRow: (d) => ({
...d,
receiveRate: d.orderQty > 0 ? parseFloat((d.receiveQty / d.orderQty * 100).toFixed(1)) : 0,
}),
emptyMessage: "구매 데이터가 없습니다",
};
export default function PurchaseReportPage() {
return <ReportEngine config={config} />;
}
@@ -0,0 +1,77 @@
"use client";
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
const config: ReportConfig = {
key: "quality_report_v2",
title: "품질 리포트",
description: "품질/검사 다중 조건 비교 분석",
apiEndpoint: "/report/quality/data",
metrics: [
{ id: "defectQty", name: "불량수량", unit: "EA", color: "#ef4444" },
{ id: "defectRate", name: "불량률", unit: "%", color: "#f59e0b", isRate: true },
{ id: "inspQty", name: "검사수량", unit: "EA", color: "#3b82f6" },
{ id: "passQty", name: "합격수량", unit: "EA", color: "#10b981" },
{ id: "passRate", name: "합격률", unit: "%", color: "#8b5cf6", isRate: true },
{ id: "reworkQty", name: "재작업수량", unit: "EA", color: "#ec4899" },
{ id: "scrapQty", name: "폐기수량", unit: "EA", color: "#06b6d4" },
{ id: "claimCnt", name: "클레임건수", unit: "건", color: "#84cc16" },
],
groupByOptions: [
{ id: "item", name: "품목별" },
{ id: "defectType", name: "불량유형별" },
{ id: "process", name: "공정별" },
{ id: "inspector", name: "검사자별" },
{ id: "monthly", name: "월별" },
{ id: "quarterly", name: "분기별" },
{ id: "weekly", name: "주별" },
{ id: "daily", name: "일별" },
],
defaultGroupBy: "item",
defaultMetrics: ["defectQty"],
thresholds: [
{ id: "defectRate", label: "불량률 ≥", defaultValue: 5, unit: "%" },
{ id: "defectQty", label: "불량수량 ≥", defaultValue: 20, unit: "EA" },
],
filterFieldDefs: [
{ id: "item", name: "품목", type: "select", optionKey: "items" },
{ id: "defectType", name: "불량유형", type: "select", optionKey: "defectTypes" },
{ id: "process", name: "공정", type: "select", optionKey: "processes" },
{ id: "inspector", name: "검사자", type: "select", optionKey: "inspectors" },
{ id: "defectQty", name: "불량수량", type: "number" },
{ id: "defectRate", name: "불량률", type: "number" },
],
drilldownColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "item", name: "품목" },
{ id: "defectType", name: "불량유형" },
{ id: "process", name: "공정" },
{ id: "inspector", name: "검사자" },
{ id: "inspQty", name: "검사수량", align: "right", format: "number" },
{ id: "defectQty", name: "불량수량", align: "right", format: "number" },
{ id: "defectRate", name: "불량률(%)", align: "right", format: "number" },
{ id: "passRate", name: "합격률(%)", align: "right", format: "number" },
],
rawDataColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "item", name: "품목" },
{ id: "defectType", name: "불량유형" },
{ id: "process", name: "공정" },
{ id: "inspector", name: "검사자" },
{ id: "inspQty", name: "검사수량", align: "right", format: "number" },
{ id: "passQty", name: "합격수량", align: "right", format: "number" },
{ id: "defectQty", name: "불량수량", align: "right", format: "number" },
{ id: "reworkQty", name: "재작업", align: "right", format: "number" },
{ id: "scrapQty", name: "폐기", align: "right", format: "number" },
],
enrichRow: (d) => ({
...d,
defectRate: d.inspQty > 0 ? parseFloat((d.defectQty / d.inspQty * 100).toFixed(1)) : 0,
passRate: d.inspQty > 0 ? parseFloat((d.passQty / d.inspQty * 100).toFixed(1)) : 0,
}),
emptyMessage: "품질 데이터가 없습니다",
};
export default function QualityReportPage() {
return <ReportEngine config={config} />;
}
@@ -0,0 +1,67 @@
"use client";
import ReportEngine, { ReportConfig } from "@/components/admin/report/ReportEngine";
const config: ReportConfig = {
key: "sales_report_v2",
title: "영업 리포트",
description: "다중 조건 비교 분석",
apiEndpoint: "/sales-report/data",
metrics: [
{ id: "orderAmt", name: "수주금액", unit: "원", color: "#3b82f6" },
{ id: "orderQty", name: "수주수량", unit: "EA", color: "#10b981" },
{ id: "shipQty", name: "출하수량", unit: "EA", color: "#ef4444" },
{ id: "unitPrice", name: "단가", unit: "원", color: "#8b5cf6" },
{ id: "orderCount", name: "수주건수", unit: "건", color: "#f59e0b" },
],
groupByOptions: [
{ id: "customer", name: "거래처별" },
{ id: "item", name: "품목별" },
{ id: "status", name: "상태별" },
{ id: "monthly", name: "월별" },
{ id: "quarterly", name: "분기별" },
{ id: "weekly", name: "주별" },
{ id: "daily", name: "일별" },
],
defaultGroupBy: "customer",
defaultMetrics: ["orderAmt"],
thresholds: [
{ id: "low", label: "목표 미달 ≤", defaultValue: 80, unit: "%" },
{ id: "high", label: "목표 초과 ≥", defaultValue: 120, unit: "%" },
],
filterFieldDefs: [
{ id: "customer", name: "거래처", type: "select", optionKey: "customers" },
{ id: "item", name: "품목", type: "select", optionKey: "items" },
{ id: "status", name: "상태", type: "select", optionKey: "statuses" },
{ id: "orderAmt", name: "수주금액", type: "number" },
{ id: "orderQty", name: "수주수량", type: "number" },
],
drilldownColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "order_no", name: "수주번호" },
{ id: "customer", name: "거래처" },
{ id: "item", name: "품목" },
{ id: "status", name: "상태", format: "badge" },
{ id: "orderQty", name: "수주수량", align: "right", format: "number" },
{ id: "unitPrice", name: "단가", align: "right", format: "number" },
{ id: "orderAmt", name: "수주금액", align: "right", format: "number" },
{ id: "shipQty", name: "출하수량", align: "right", format: "number" },
],
rawDataColumns: [
{ id: "date", name: "날짜", format: "date" },
{ id: "order_no", name: "수주번호" },
{ id: "customer", name: "거래처" },
{ id: "part_code", name: "품목코드" },
{ id: "item", name: "품목명" },
{ id: "status", name: "상태", format: "badge" },
{ id: "orderQty", name: "수주수량", align: "right", format: "number" },
{ id: "unitPrice", name: "단가", align: "right", format: "number" },
{ id: "orderAmt", name: "수주금액", align: "right", format: "number" },
{ id: "shipQty", name: "출하수량", align: "right", format: "number" },
],
emptyMessage: "수주 데이터가 없습니다",
};
export default function SalesReportPage() {
return <ReportEngine config={config} />;
}
@@ -1,76 +1,66 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import DataFlowList from "@/components/dataflow/DataFlowList";
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { Button } from "@/components/ui/button";
import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ArrowLeft } from "lucide-react";
type Step = "list" | "editor";
export default function DataFlowPage() {
const { user } = useAuth();
const router = useRouter();
const [currentStep, setCurrentStep] = useState<Step>("list");
const [loadingFlowId, setLoadingFlowId] = useState<number | null>(null);
// 플로우 불러오기 핸들러
const handleLoadFlow = async (flowId: number | null) => {
if (flowId === null) {
// 새 플로우 생성
setLoadingFlowId(null);
setCurrentStep("editor");
return;
}
try {
// 기존 플로우 불러오기
setLoadingFlowId(flowId);
setCurrentStep("editor");
toast.success("플로우를 불러왔습니다.");
toast.success("플로우를 불러왔어요");
} catch (error: any) {
console.error("플로우 불러오기 실패:", error);
showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." });
console.error("플로우 불러오기 실패:", error);
showErrorToast("플로우 불러오는 데 실패했어요", error, {
guidance: "네트워크 연결을 확인해 주세요.",
});
}
};
// 목록으로 돌아가기
const handleBackToList = () => {
setCurrentStep("list");
setLoadingFlowId(null);
};
// 에디터 모드일 때는 전체 화면 사용
const isEditorMode = currentStep === "editor";
// 에디터 모드일 때는 레이아웃 없이 전체 화면 사용
if (isEditorMode) {
if (currentStep === "editor") {
return (
<div className="bg-background fixed inset-0 z-50">
<div className="flex h-full flex-col">
{/* 에디터 헤더 */}
<div className="bg-background flex items-center gap-4 border-b p-4">
<Button variant="outline" size="sm" onClick={handleBackToList} className="flex items-center gap-2">
<div className="bg-background flex items-center gap-4 border-b px-5 py-3">
<Button
variant="ghost"
size="sm"
onClick={handleBackToList}
className="text-muted-foreground hover:text-foreground flex items-center gap-2 text-sm"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground mt-1 text-sm">
</p>
</div>
</div>
{/* 플로우 에디터 */}
<div className="flex-1 overflow-hidden">
<FlowEditor key={loadingFlowId || "new"} initialFlowId={loadingFlowId} />
<FlowEditor
key={loadingFlowId || "new"}
initialFlowId={loadingFlowId}
/>
</div>
</div>
</div>
@@ -78,20 +68,10 @@ export default function DataFlowPage() {
}
return (
<div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-4 sm:p-6">
{/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground text-sm"> </p>
</div>
{/* 플로우 목록 */}
<div className="h-full overflow-y-auto">
<div className="mx-auto w-full max-w-[1400px] space-y-6 p-4 sm:p-6 pb-20">
<DataFlowList onLoadFlow={handleLoadFlow} />
</div>
{/* Scroll to Top 버튼 */}
<ScrollToTop />
</div>
);
}
@@ -34,8 +34,7 @@ import { commonCodeApi } from "@/lib/api/commonCode";
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
import { ddlApi } from "@/lib/api/ddl";
import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue";
import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { NumberingRuleConfig } from "@/types/numbering-rule";
import { saveNumberingRuleToTest } from "@/lib/api/numberingRule";
import { CreateTableModal } from "@/components/admin/CreateTableModal";
import { AddColumnModal } from "@/components/admin/AddColumnModal";
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
@@ -102,10 +101,7 @@ export default function TableManagementPage() {
// 🆕 Category 타입용: 2레벨 메뉴 목록
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
// 🆕 Numbering 타입용: 채번규칙 목록
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [numberingRulesLoading, setNumberingRulesLoading] = useState(false);
const [numberingComboboxOpen, setNumberingComboboxOpen] = useState<Record<string, boolean>>({});
// 채번 타입은 옵션설정 > 채번설정에서 관리 (별도 선택 불필요)
// 로그 뷰어 상태
const [logViewerOpen, setLogViewerOpen] = useState(false);
@@ -281,24 +277,6 @@ export default function TableManagementPage() {
};
// 🆕 채번규칙 목록 로드
const loadNumberingRules = async () => {
setNumberingRulesLoading(true);
try {
const response = await getNumberingRules();
if (response.success && response.data) {
setNumberingRules(response.data);
} else {
console.warn("⚠️ 채번규칙 로드 실패:", response);
setNumberingRules([]);
}
} catch (error) {
console.error("❌ 채번규칙 로드 에러:", error);
setNumberingRules([]);
} finally {
setNumberingRulesLoading(false);
}
};
// 테이블 목록 로드
const loadTables = async () => {
setLoading(true);
@@ -344,9 +322,7 @@ export default function TableManagementPage() {
// 컬럼 데이터에 기본값 설정
const processedColumns = (data.columns || data).map((col: any) => {
// detailSettings에서 hierarchyRole, numberingRuleId 추출
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
let numberingRuleId: string | undefined = undefined;
if (col.detailSettings && typeof col.detailSettings === "string") {
try {
const parsed = JSON.parse(col.detailSettings);
@@ -357,9 +333,6 @@ export default function TableManagementPage() {
) {
hierarchyRole = parsed.hierarchyRole;
}
if (parsed.numberingRuleId) {
numberingRuleId = parsed.numberingRuleId;
}
} catch {
// JSON 파싱 실패 시 무시
}
@@ -369,7 +342,6 @@ export default function TableManagementPage() {
...col,
inputType: col.inputType || "text",
isUnique: col.isUnique || "NO",
numberingRuleId,
categoryMenus: col.categoryMenus || [],
hierarchyRole,
categoryRef: col.categoryRef || null,
@@ -1000,7 +972,6 @@ export default function TableManagementPage() {
loadTables();
loadCommonCodeCategories();
loadSecondLevelMenus();
loadNumberingRules();
}, []);
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
@@ -1633,7 +1604,7 @@ export default function TableManagementPage() {
tables={tables}
referenceTableColumns={referenceTableColumns}
secondLevelMenus={secondLevelMenus}
numberingRules={numberingRules}
numberingRules={[]}
onColumnChange={(field, value) => {
if (!selectedColumn) return;
if (field === "inputType") {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,839 @@
"use client";
import React, { useState, useMemo, useCallback, useEffect } from "react";
import {
Search,
RotateCcw,
Plus,
Pencil,
Trash2,
Calendar,
Upload,
PointerIcon,
Ruler,
ClipboardList,
FileText,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
getDesignRequestList,
createDesignRequest,
updateDesignRequest,
deleteDesignRequest,
} from "@/lib/api/design";
// ========== 타입 ==========
interface HistoryItem {
id?: string;
step: string;
history_date: string;
user_name: string;
description: string;
}
interface DesignRequest {
id: string;
request_no: string;
source_type: string;
request_date: string;
due_date: string;
design_type: string;
priority: string;
status: string;
approval_step: string;
target_name: string;
customer: string;
req_dept: string;
requester: string;
designer: string;
order_no: string;
spec: string;
change_type: string;
drawing_no: string;
urgency: string;
reason: string;
content: string;
apply_timing: string;
review_memo: string;
project_id: string;
ecn_no: string;
created_date: string;
updated_date: string;
writer: string;
company_code: string;
history: HistoryItem[];
impact: string[];
}
// ========== 스타일 맵 ==========
const STATUS_STYLES: Record<string, string> = {
: "bg-muted text-foreground",
: "bg-muted text-foreground",
: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
: "bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300",
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
: "bg-muted text-muted-foreground",
};
const TYPE_STYLES: Record<string, string> = {
: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
};
const PRIORITY_STYLES: Record<string, string> = {
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
: "bg-muted text-foreground",
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
};
const STATUS_PROGRESS: Record<string, number> = {
신규접수: 0,
접수대기: 0,
검토중: 20,
설계진행: 50,
설계검토: 80,
출도완료: 100,
반려: 0,
종료: 100,
};
function getProgressColor(p: number) {
if (p >= 100) return "bg-emerald-500";
if (p >= 60) return "bg-amber-500";
if (p >= 20) return "bg-blue-500";
return "bg-muted";
}
function getProgressTextColor(p: number) {
if (p >= 100) return "text-emerald-500";
if (p >= 60) return "text-amber-500";
if (p >= 20) return "text-blue-500";
return "text-muted-foreground";
}
const INITIAL_FORM = {
request_no: "",
request_date: "",
due_date: "",
design_type: "",
priority: "보통",
target_name: "",
customer: "",
req_dept: "",
requester: "",
designer: "",
order_no: "",
spec: "",
drawing_no: "",
content: "",
};
// ========== 메인 컴포넌트 ==========
export default function DesignRequestPage() {
const [requests, setRequests] = useState<DesignRequest[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [filterStatus, setFilterStatus] = useState("");
const [filterType, setFilterType] = useState("");
const [filterPriority, setFilterPriority] = useState("");
const [filterKeyword, setFilterKeyword] = useState("");
const [modalOpen, setModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(INITIAL_FORM);
const today = useMemo(() => new Date(), []);
// 데이터 조회
const fetchRequests = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { source_type: "dr" };
if (filterStatus && filterStatus !== "__all__") params.status = filterStatus;
if (filterType && filterType !== "__all__") {
// design_type은 서버에서 직접 필터링하지 않으므로 클라이언트에서 처리
}
if (filterPriority && filterPriority !== "__all__") params.priority = filterPriority;
if (filterKeyword) params.search = filterKeyword;
const res = await getDesignRequestList(params);
if (res.success && res.data) {
setRequests(res.data);
} else {
setRequests([]);
}
} catch {
setRequests([]);
} finally {
setLoading(false);
}
}, [filterStatus, filterPriority, filterKeyword]);
useEffect(() => {
fetchRequests();
}, [fetchRequests]);
// 클라이언트 사이드 필터링 (design_type은 서버에서 지원하지 않으므로)
const filteredRequests = useMemo(() => {
let list = requests;
if (filterType && filterType !== "__all__") {
list = list.filter((item) => item.design_type === filterType);
}
return list;
}, [requests, filterType]);
const selectedItem = useMemo(() => {
if (!selectedId) return null;
return requests.find((r) => r.id === selectedId) || null;
}, [selectedId, requests]);
const statusCounts = useMemo(() => {
return {
접수대기: requests.filter((r) => r.status === "접수대기" || r.status === "신규접수").length,
설계진행: requests.filter((r) => r.status === "설계진행").length,
출도완료: requests.filter((r) => r.status === "출도완료").length,
};
}, [requests]);
const handleResetFilter = useCallback(() => {
setFilterStatus("");
setFilterType("");
setFilterPriority("");
setFilterKeyword("");
}, []);
// 채번: 기존 데이터 기반으로 다음 번호 생성
const generateNextNo = useCallback(() => {
const year = new Date().getFullYear();
const existing = requests.filter((r) => r.request_no?.startsWith(`DR-${year}-`));
const maxNum = existing.reduce((max, r) => {
const parts = r.request_no?.split("-");
const num = parts?.length >= 3 ? parseInt(parts[2]) : 0;
return num > max ? num : max;
}, 0);
return `DR-${year}-${String(maxNum + 1).padStart(4, "0")}`;
}, [requests]);
const handleOpenRegister = useCallback(() => {
setIsEditMode(false);
setEditingId(null);
setForm({
...INITIAL_FORM,
request_no: generateNextNo(),
request_date: new Date().toISOString().split("T")[0],
});
setModalOpen(true);
}, [generateNextNo]);
const handleOpenEdit = useCallback(() => {
if (!selectedItem) return;
setIsEditMode(true);
setEditingId(selectedItem.id);
setForm({
request_no: selectedItem.request_no || "",
request_date: selectedItem.request_date || "",
due_date: selectedItem.due_date || "",
design_type: selectedItem.design_type || "",
priority: selectedItem.priority || "보통",
target_name: selectedItem.target_name || "",
customer: selectedItem.customer || "",
req_dept: selectedItem.req_dept || "",
requester: selectedItem.requester || "",
designer: selectedItem.designer || "",
order_no: selectedItem.order_no || "",
spec: selectedItem.spec || "",
drawing_no: selectedItem.drawing_no || "",
content: selectedItem.content || "",
});
setModalOpen(true);
}, [selectedItem]);
const handleSave = useCallback(async () => {
if (!form.target_name.trim()) { alert("설비/제품명을 입력하세요."); return; }
if (!form.design_type) { alert("의뢰 유형을 선택하세요."); return; }
if (!form.due_date) { alert("납기를 입력하세요."); return; }
if (!form.spec.trim()) { alert("요구사양을 입력하세요."); return; }
setSaving(true);
try {
const payload = {
request_no: form.request_no,
source_type: "dr",
request_date: form.request_date,
due_date: form.due_date,
design_type: form.design_type,
priority: form.priority,
target_name: form.target_name,
customer: form.customer,
req_dept: form.req_dept,
requester: form.requester,
designer: form.designer,
order_no: form.order_no,
spec: form.spec,
drawing_no: form.drawing_no,
content: form.content,
};
let res;
if (isEditMode && editingId) {
res = await updateDesignRequest(editingId, payload);
} else {
res = await createDesignRequest({
...payload,
status: "신규접수",
history: [{
step: "신규접수",
history_date: form.request_date || new Date().toISOString().split("T")[0],
user_name: form.requester || "시스템",
description: `${form.req_dept || ""}에서 설계의뢰 등록`,
}],
});
}
if (res.success) {
setModalOpen(false);
await fetchRequests();
if (isEditMode && editingId) {
setSelectedId(editingId);
} else if (res.data?.id) {
setSelectedId(res.data.id);
}
} else {
alert(`저장 실패: ${res.message || "알 수 없는 오류"}`);
}
} catch (err: any) {
alert(`저장 중 오류가 발생했습니다: ${err.message}`);
} finally {
setSaving(false);
}
}, [form, isEditMode, editingId, fetchRequests]);
const handleDelete = useCallback(async () => {
if (!selectedId || !selectedItem) return;
const displayNo = selectedItem.request_no || selectedId;
if (!confirm(`${displayNo} 설계의뢰를 삭제하시겠습니까?`)) return;
try {
const res = await deleteDesignRequest(selectedId);
if (res.success) {
setSelectedId(null);
await fetchRequests();
} else {
alert(`삭제 실패: ${res.message || "알 수 없는 오류"}`);
}
} catch (err: any) {
alert(`삭제 중 오류가 발생했습니다: ${err.message}`);
}
}, [selectedId, selectedItem, fetchRequests]);
const getDueDateInfo = useCallback(
(dueDate: string) => {
if (!dueDate) return { text: "-", color: "text-muted-foreground" };
const due = new Date(dueDate);
const diff = Math.ceil((due.getTime() - today.getTime()) / 86400000);
if (diff < 0) return { text: `${Math.abs(diff)}일 초과`, color: "text-destructive" };
if (diff === 0) return { text: "오늘", color: "text-amber-500" };
if (diff <= 7) return { text: `${diff}일 남음`, color: "text-amber-500" };
return { text: `${diff}일 남음`, color: "text-emerald-500" };
},
[today]
);
const getProgress = useCallback((status: string) => {
return STATUS_PROGRESS[status] ?? 0;
}, []);
return (
<div className="flex h-full flex-col gap-2 p-3">
{/* 검색 섹션 */}
<div className="flex shrink-0 flex-wrap items-center gap-2 rounded-lg border bg-card px-3 py-2">
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="상태 전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["신규접수", "접수대기", "검토중", "설계진행", "설계검토", "출도완료", "반려", "종료"].map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="유형 전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["신규설계", "유사설계", "개조설계"].map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterPriority} onValueChange={setFilterPriority}>
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="우선순위 전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["긴급", "높음", "보통", "낮음"].map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={filterKeyword}
onChange={(e) => setFilterKeyword(e.target.value)}
placeholder="의뢰번호 / 설비명 / 고객명 검색"
className="h-7 w-[240px] pl-7 text-xs"
/>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleResetFilter}>
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => fetchRequests()}>
<Search className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 메인 영역 */}
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
{/* 왼쪽: 목록 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
<span className="text-sm font-bold">
<Ruler className="mr-1 inline h-4 w-4" />
(<span className="text-primary">{filteredRequests.length}</span>)
</span>
<Button size="sm" className="h-7 text-xs" onClick={handleOpenRegister}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<ScrollArea className="flex-1">
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px] text-[11px]"></TableHead>
<TableHead className="w-[70px] text-center text-[11px]"></TableHead>
<TableHead className="w-[70px] text-center text-[11px]"></TableHead>
<TableHead className="w-[60px] text-center text-[11px]"></TableHead>
<TableHead className="text-[11px]">/</TableHead>
<TableHead className="w-[90px] text-[11px]"></TableHead>
<TableHead className="w-[70px] text-[11px]"></TableHead>
<TableHead className="w-[85px] text-[11px]"></TableHead>
<TableHead className="w-[65px] text-center text-[11px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRequests.length === 0 && (
<TableRow>
<TableCell colSpan={9} className="py-12 text-center">
<div className="flex flex-col items-center gap-1 text-muted-foreground">
<Ruler className="h-8 w-8" />
<span className="text-sm"> </span>
</div>
</TableCell>
</TableRow>
)}
{filteredRequests.map((item) => {
const progress = getProgress(item.status);
return (
<TableRow
key={item.id}
className={cn("cursor-pointer", selectedId === item.id && "bg-accent")}
onClick={() => setSelectedId(item.id)}
>
<TableCell className="text-[11px] font-semibold text-primary">{item.request_no || "-"}</TableCell>
<TableCell className="text-center">
{item.design_type ? (
<Badge className={cn("text-[9px]", TYPE_STYLES[item.design_type])}>{item.design_type}</Badge>
) : "-"}
</TableCell>
<TableCell className="text-center">
<Badge className={cn("text-[9px]", STATUS_STYLES[item.status])}>{item.status}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge className={cn("text-[9px]", PRIORITY_STYLES[item.priority])}>{item.priority}</Badge>
</TableCell>
<TableCell className="text-xs font-medium">{item.target_name || "-"}</TableCell>
<TableCell className="text-[11px]">{item.customer || "-"}</TableCell>
<TableCell className="text-[11px]">{item.designer || "-"}</TableCell>
<TableCell className="text-[11px]">{item.due_date || "-"}</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full transition-all", getProgressColor(progress))} style={{ width: `${progress}%` }} />
</div>
<span className={cn("text-[10px] font-semibold", getProgressTextColor(progress))}>{progress}%</span>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 상세 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
<span className="text-sm font-bold">
<ClipboardList className="mr-1 inline h-4 w-4" />
</span>
{selectedItem && (
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-6 text-[10px]" onClick={handleOpenEdit}>
<Pencil className="mr-0.5 h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="h-6 text-[10px] text-destructive hover:text-destructive" onClick={handleDelete}>
<Trash2 className="mr-0.5 h-3 w-3" />
</Button>
</div>
)}
</div>
<ScrollArea className="flex-1">
<div className="p-3">
{/* 상태 카드 */}
<div className="mb-3 grid grid-cols-3 gap-2">
<Card
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
onClick={() => setFilterStatus("접수대기")}
>
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-blue-500">{statusCounts.}</div>
</Card>
<Card
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
onClick={() => setFilterStatus("설계진행")}
>
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-amber-500">{statusCounts.}</div>
</Card>
<Card
className="cursor-pointer rounded-lg border px-3 py-2 shadow-none transition-colors hover:bg-accent/50"
onClick={() => setFilterStatus("출도완료")}
>
<div className="text-[10px] text-muted-foreground"></div>
<div className="text-xl font-bold text-emerald-500">{statusCounts.}</div>
</Card>
</div>
{/* 상세 내용 */}
{!selectedItem ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<PointerIcon className="h-8 w-8" />
<span className="text-sm"> </span>
</div>
) : (
<div className="space-y-4">
{/* 기본 정보 */}
<div>
<div className="mb-2 text-xs font-bold">
<FileText className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 rounded-lg border bg-muted/10 p-3">
<InfoRow label="의뢰번호" value={<span className="font-semibold text-primary">{selectedItem.request_no || "-"}</span>} />
<InfoRow label="상태" value={<Badge className={cn("text-[10px]", STATUS_STYLES[selectedItem.status])}>{selectedItem.status}</Badge>} />
<InfoRow label="유형" value={selectedItem.design_type ? <Badge className={cn("text-[10px]", TYPE_STYLES[selectedItem.design_type])}>{selectedItem.design_type}</Badge> : "-"} />
<InfoRow label="우선순위" value={<Badge className={cn("text-[10px]", PRIORITY_STYLES[selectedItem.priority])}>{selectedItem.priority}</Badge>} />
<InfoRow label="설비/제품명" value={selectedItem.target_name || "-"} />
<InfoRow label="고객명" value={selectedItem.customer || "-"} />
<InfoRow label="의뢰부서 / 의뢰자" value={`${selectedItem.req_dept || "-"} / ${selectedItem.requester || "-"}`} />
<InfoRow label="설계담당" value={selectedItem.designer || "미배정"} />
<InfoRow label="의뢰일자" value={selectedItem.request_date || "-"} />
<InfoRow
label="납기"
value={
selectedItem.due_date ? (
<span>
{selectedItem.due_date}{" "}
<span className={cn("text-[11px]", getDueDateInfo(selectedItem.due_date).color)}>
({getDueDateInfo(selectedItem.due_date).text})
</span>
</span>
) : "-"
}
/>
<InfoRow label="수주번호" value={selectedItem.order_no || "-"} />
<InfoRow
label="진행률"
value={
(() => {
const progress = getProgress(selectedItem.status);
return (
<div className="flex items-center gap-2">
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full", getProgressColor(progress))} style={{ width: `${progress}%` }} />
</div>
<span className={cn("text-xs font-bold", getProgressTextColor(progress))}>{progress}%</span>
</div>
);
})()
}
/>
</div>
</div>
{/* 요구사양 */}
<div>
<div className="mb-2 text-xs font-bold">
<FileText className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="rounded-lg border bg-muted/10 p-3">
<pre className="whitespace-pre-wrap font-sans text-xs leading-relaxed">{selectedItem.spec || "-"}</pre>
{selectedItem.drawing_no && (
<div className="mt-2 text-xs">
<span className="text-muted-foreground"> : </span>
<span className="text-primary">{selectedItem.drawing_no}</span>
</div>
)}
{selectedItem.content && (
<div className="mt-1 text-xs">
<span className="text-muted-foreground">: </span>{selectedItem.content}
</div>
)}
</div>
</div>
{/* 진행 이력 */}
{selectedItem.history && selectedItem.history.length > 0 && (
<div>
<div className="mb-2 text-xs font-bold">
<Calendar className="mr-1 inline h-3.5 w-3.5" />
</div>
<div className="space-y-0">
{selectedItem.history.map((h, idx) => {
const isLast = idx === selectedItem.history.length - 1;
const isDone = h.step === "출도완료" || h.step === "종료";
return (
<div key={h.id || idx} className="flex gap-3">
<div className="flex flex-col items-center">
<div
className={cn(
"mt-1 h-2.5 w-2.5 shrink-0 rounded-full border-2",
isLast && !isDone
? "border-blue-500 bg-blue-500"
: isDone || !isLast
? "border-emerald-500 bg-emerald-500"
: "border-muted-foreground bg-muted-foreground"
)}
/>
{!isLast && <div className="w-px flex-1 bg-border" />}
</div>
<div className="pb-3">
<Badge className={cn("text-[9px]", STATUS_STYLES[h.step])}>{h.step}</Badge>
<div className="mt-0.5 text-xs">{h.description}</div>
<div className="text-[10px] text-muted-foreground">{h.history_date} · {h.user_name}</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>
</ScrollArea>
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* 등록/수정 모달 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px]">
<DialogHeader>
<DialogTitle className="text-lg">
{isEditMode ? <><Pencil className="mr-1.5 inline h-5 w-5" /> </> : <><Plus className="mr-1.5 inline h-5 w-5" /> </>}
</DialogTitle>
<DialogDescription className="text-sm">
{isEditMode ? "설계의뢰 정보를 수정합니다." : "새 설계의뢰를 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="flex gap-6">
{/* 좌측: 기본 정보 */}
<div className="w-[420px] shrink-0 space-y-4">
<div className="text-sm font-bold">
<FileText className="mr-1 inline h-4 w-4" />
</div>
<div>
<Label className="text-sm"></Label>
<Input value={form.request_no} readOnly className="h-9 text-sm" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm"></Label>
<Input type="date" value={form.request_date} onChange={(e) => setForm((p) => ({ ...p, request_date: e.target.value }))} className="h-9 text-sm" />
</div>
<div>
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input type="date" value={form.due_date} onChange={(e) => setForm((p) => ({ ...p, due_date: e.target.value }))} className="h-9 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Select value={form.design_type} onValueChange={(v) => setForm((p) => ({ ...p, design_type: v }))}>
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["신규설계", "유사설계", "개조설계"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Select value={form.priority} onValueChange={(v) => setForm((p) => ({ ...p, priority: v }))}>
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-sm">/ <span className="text-destructive">*</span></Label>
<Input value={form.target_name} onChange={(e) => setForm((p) => ({ ...p, target_name: e.target.value }))} placeholder="설비 또는 제품명 입력" className="h-9 text-sm" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm"></Label>
<Select value={form.req_dept} onValueChange={(v) => setForm((p) => ({ ...p, req_dept: v }))}>
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["영업팀", "기획팀", "생산팀", "품질팀"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-sm"></Label>
<Input value={form.requester} onChange={(e) => setForm((p) => ({ ...p, requester: e.target.value }))} placeholder="의뢰자명" className="h-9 text-sm" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-sm"></Label>
<Input value={form.customer} onChange={(e) => setForm((p) => ({ ...p, customer: e.target.value }))} placeholder="고객/거래처명" className="h-9 text-sm" />
</div>
<div>
<Label className="text-sm"></Label>
<Input value={form.order_no} onChange={(e) => setForm((p) => ({ ...p, order_no: e.target.value }))} placeholder="관련 수주번호" className="h-9 text-sm" />
</div>
</div>
<div>
<Label className="text-sm"></Label>
<Select value={form.designer} onValueChange={(v) => setForm((p) => ({ ...p, designer: v }))}>
<SelectTrigger className="h-9 text-sm" size="sm"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{["이설계", "박도면", "최기구", "김전장"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
{/* 우측: 상세 내용 */}
<div className="flex min-w-0 flex-1 flex-col gap-4">
<div className="text-sm font-bold">
<FileText className="mr-1 inline h-4 w-4" />
</div>
<div className="flex-1">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Textarea
value={form.spec}
onChange={(e) => setForm((p) => ({ ...p, spec: e.target.value }))}
placeholder={"고객 요구사양 또는 설비 사양을 상세히 기술하세요\n\n예시:\n- 작업 대상: SUS304 Φ20 파이프\n- 가공 방식: 자동 절단 + 면취\n- 생산 속도: 60EA/분\n- 치수 공차: ±0.1mm"}
className="min-h-[180px] text-sm"
/>
</div>
<div>
<Label className="text-sm"> </Label>
<Input value={form.drawing_no} onChange={(e) => setForm((p) => ({ ...p, drawing_no: e.target.value }))} placeholder="유사 설비명 또는 참조 도면번호" className="h-9 text-sm" />
</div>
<div>
<Label className="text-sm"></Label>
<Textarea value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} placeholder="기타 참고 사항" className="min-h-[70px] text-sm" rows={3} />
</div>
<div>
<div className="text-sm font-bold">
<Upload className="mr-1 inline h-4 w-4" />
</div>
<div className="mt-1.5 cursor-pointer rounded-lg border-2 border-dashed p-5 text-center transition-colors hover:border-primary hover:bg-accent/50">
<Upload className="mx-auto h-6 w-6 text-muted-foreground" />
<div className="mt-1.5 text-sm text-muted-foreground"> (, , )</div>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setModalOpen(false)} className="h-10 px-6 text-sm" disabled={saving}></Button>
<Button onClick={handleSave} className="h-10 px-6 text-sm" disabled={saving}>
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
{saving ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ========== 정보 행 서브컴포넌트 ==========
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-start gap-1">
<span className="min-w-[80px] shrink-0 text-[11px] text-muted-foreground">{label}</span>
<span className="text-xs font-medium">{value}</span>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+728
View File
@@ -0,0 +1,728 @@
"use client";
/**
*
*
* 좌측: 설비 (equipment_mng)
* 우측: ( / / )
*
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Wrench, ClipboardCheck, Package, Copy, Info,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { ImageUpload } from "@/components/common/ImageUpload";
const EQUIP_TABLE = "equipment_mng";
const INSPECTION_TABLE = "equipment_inspection_item";
const CONSUMABLE_TABLE = "equipment_consumable";
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "equipment_code", label: "설비코드", width: "w-[110px]" },
{ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]" },
{ key: "equipment_type", label: "설비유형", width: "w-[90px]" },
{ key: "manufacturer", label: "제조사", width: "w-[100px]" },
{ key: "installation_location", label: "설치장소", width: "w-[100px]" },
{ key: "operation_status", label: "가동상태", width: "w-[80px]" },
];
const INSPECTION_COLUMNS: DataGridColumn[] = [
{ key: "inspection_item", label: "점검항목", minWidth: "min-w-[120px]", editable: true },
{ key: "inspection_cycle", label: "점검주기", width: "w-[80px]" },
{ key: "inspection_method", label: "점검방법", width: "w-[80px]" },
{ key: "lower_limit", label: "하한치", width: "w-[70px]", editable: true },
{ key: "upper_limit", label: "상한치", width: "w-[70px]", editable: true },
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
{ key: "inspection_content", label: "점검내용", minWidth: "min-w-[150px]", editable: true },
];
const CONSUMABLE_COLUMNS: DataGridColumn[] = [
{ key: "image_path", label: "이미지", width: "w-[50px]", renderType: "image", sortable: false, filterable: false },
{ key: "consumable_name", label: "소모품명", minWidth: "min-w-[120px]", editable: true },
{ key: "replacement_cycle", label: "교체주기", width: "w-[90px]", editable: true },
{ key: "unit", label: "단위", width: "w-[60px]", editable: true },
{ key: "specification", label: "규격", width: "w-[100px]", editable: true },
{ key: "manufacturer", label: "제조사", width: "w-[100px]", editable: true },
];
export default function EquipmentInfoPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측
const [equipments, setEquipments] = useState<any[]>([]);
const [equipLoading, setEquipLoading] = useState(false);
const [equipCount, setEquipCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedEquipId, setSelectedEquipId] = useState<string | null>(null);
// 우측 탭
const [rightTab, setRightTab] = useState<"info" | "inspection" | "consumable">("info");
const [inspections, setInspections] = useState<any[]>([]);
const [inspectionLoading, setInspectionLoading] = useState(false);
const [consumables, setConsumables] = useState<any[]>([]);
const [consumableLoading, setConsumableLoading] = useState(false);
// 카테고리
const [catOptions, setCatOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 모달
const [equipModalOpen, setEquipModalOpen] = useState(false);
const [equipEditMode, setEquipEditMode] = useState(false);
const [equipForm, setEquipForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 기본정보 탭 편집 폼
const [infoForm, setInfoForm] = useState<Record<string, any>>({});
const [infoSaving, setInfoSaving] = useState(false);
const [inspectionModalOpen, setInspectionModalOpen] = useState(false);
const [inspectionForm, setInspectionForm] = useState<Record<string, any>>({});
const [consumableModalOpen, setConsumableModalOpen] = useState(false);
const [consumableForm, setConsumableForm] = useState<Record<string, any>>({});
const [consumableItemOptions, setConsumableItemOptions] = useState<any[]>([]);
// 점검항목 복사
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySourceEquip, setCopySourceEquip] = useState("");
const [copyItems, setCopyItems] = useState<any[]>([]);
const [copyChecked, setCopyChecked] = useState<Set<string>>(new Set());
const [copyLoading, setCopyLoading] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
// 카테고리 로드
useEffect(() => {
const load = async () => {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// equipment_mng 카테고리
for (const col of ["equipment_type", "operation_status"]) {
try {
const res = await apiClient.get(`/table-categories/${EQUIP_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
// inspection 카테고리
for (const col of ["inspection_cycle", "inspection_method"]) {
try {
const res = await apiClient.get(`/table-categories/${INSPECTION_TABLE}/${col}/values`);
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
} catch { /* skip */ }
}
setCatOptions(optMap);
};
load();
}, []);
const resolve = (col: string, code: string) => {
if (!code) return "";
return catOptions[col]?.find((o) => o.code === code)?.label || code;
};
// 설비 조회
const fetchEquipments = useCallback(async () => {
setEquipLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setEquipments(raw.map((r: any) => ({
...r,
equipment_type: resolve("equipment_type", r.equipment_type),
operation_status: resolve("operation_status", r.operation_status),
})));
setEquipCount(res.data?.data?.total || raw.length);
} catch { toast.error("설비 목록 조회 실패"); } finally { setEquipLoading(false); }
}, [searchFilters, catOptions]);
useEffect(() => { fetchEquipments(); }, [fetchEquipments]);
const selectedEquip = equipments.find((e) => e.id === selectedEquipId);
// 기본정보 탭 폼 초기화 (설비 선택 변경 시)
useEffect(() => {
if (selectedEquip) setInfoForm({ ...selectedEquip });
else setInfoForm({});
}, [selectedEquipId]); // eslint-disable-line react-hooks/exhaustive-deps
// 기본정보 저장
const handleInfoSave = async () => {
if (!infoForm.id) return;
setInfoSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = infoForm;
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("저장되었습니다.");
fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); }
finally { setInfoSaving(false); }
};
// 우측: 점검항목 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setInspections([]); return; }
const fetch = async () => {
setInspectionLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
setInspections(raw.map((r: any) => ({
...r,
inspection_cycle: resolve("inspection_cycle", r.inspection_cycle),
inspection_method: resolve("inspection_method", r.inspection_method),
})));
} catch { setInspections([]); } finally { setInspectionLoading(false); }
};
fetch();
}, [selectedEquip?.equipment_code, catOptions]);
// 우측: 소모품 조회
useEffect(() => {
if (!selectedEquip?.equipment_code) { setConsumables([]); return; }
const fetch = async () => {
setConsumableLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: selectedEquip.equipment_code }] },
autoFilter: true,
});
setConsumables(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setConsumables([]); } finally { setConsumableLoading(false); }
};
fetch();
}, [selectedEquip?.equipment_code]);
// 새로고침 헬퍼
const refreshRight = () => {
const eid = selectedEquipId;
setSelectedEquipId(null);
setTimeout(() => setSelectedEquipId(eid), 50);
};
// 설비 등록/수정
const openEquipRegister = () => { setEquipForm({}); setEquipEditMode(false); setEquipModalOpen(true); };
const openEquipEdit = () => { if (!selectedEquip) return; setEquipForm({ ...selectedEquip }); setEquipEditMode(true); setEquipModalOpen(true); };
const handleEquipSave = async () => {
if (!equipForm.equipment_name) { toast.error("설비명은 필수입니다."); return; }
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, ...fields } = equipForm;
if (equipEditMode && id) {
await apiClient.put(`/table-management/tables/${EQUIP_TABLE}/edit`, { originalData: { id }, updatedData: fields });
toast.success("수정되었습니다.");
} else {
await apiClient.post(`/table-management/tables/${EQUIP_TABLE}/add`, fields);
toast.success("등록되었습니다.");
}
setEquipModalOpen(false); fetchEquipments();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
const handleEquipDelete = async () => {
if (!selectedEquipId) return;
const ok = await confirm("설비를 삭제하시겠습니까?", { description: "관련 점검항목, 소모품도 함께 삭제됩니다.", variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/table-management/tables/${EQUIP_TABLE}/delete`, { data: [{ id: selectedEquipId }] });
toast.success("삭제되었습니다."); setSelectedEquipId(null); fetchEquipments();
} catch { toast.error("삭제 실패"); }
};
// 점검항목 추가
const handleInspectionSave = async () => {
if (!inspectionForm.inspection_item) { toast.error("점검항목명은 필수입니다."); return; }
setSaving(true);
try {
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...inspectionForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setInspectionModalOpen(false); refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 소모품 추가
// 소모품 품목 로드 (item_info에서 type 또는 division 라벨이 "소모품"인 것)
const loadConsumableItems = async () => {
try {
const flatten = (vals: any[]): any[] => {
const r: any[] = [];
for (const v of vals) { r.push(v); if (v.children?.length) r.push(...flatten(v.children)); }
return r;
};
// type과 division 카테고리 모두에서 "소모품" 코드 찾기
const [typeRes, divRes] = await Promise.all([
apiClient.get(`/table-categories/item_info/type/values`),
apiClient.get(`/table-categories/item_info/division/values`),
]);
const consumableType = flatten(typeRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
const consumableDiv = flatten(divRes.data?.data || []).find((t: any) => t.valueLabel === "소모품");
if (!consumableType && !consumableDiv) { setConsumableItemOptions([]); return; }
// 두 필터 결과를 합산 (중복 제거)
const filters: any[] = [];
if (consumableType) filters.push({ columnName: "type", operator: "equals", value: consumableType.valueCode });
if (consumableDiv) filters.push({ columnName: "division", operator: "equals", value: consumableDiv.valueCode });
const results = await Promise.all(filters.map((f) =>
apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [f] },
autoFilter: true,
})
));
const allItems = new Map<string, any>();
for (const res of results) {
const rows = res.data?.data?.data || res.data?.data?.rows || [];
for (const row of rows) allItems.set(row.id, row);
}
setConsumableItemOptions(Array.from(allItems.values()));
} catch { setConsumableItemOptions([]); }
};
const handleConsumableSave = async () => {
if (!consumableForm.consumable_name) { toast.error("소모품명은 필수입니다."); return; }
setSaving(true);
try {
await apiClient.post(`/table-management/tables/${CONSUMABLE_TABLE}/add`, {
...consumableForm, equipment_code: selectedEquip?.equipment_code,
});
toast.success("추가되었습니다."); setConsumableModalOpen(false); refreshRight();
} catch (err: any) { toast.error(err.response?.data?.message || "저장 실패"); } finally { setSaving(false); }
};
// 점검항목 복사: 소스 설비 선택 시 점검항목 로드
const loadCopyItems = async (equipCode: string) => {
setCopySourceEquip(equipCode);
setCopyChecked(new Set());
if (!equipCode) { setCopyItems([]); return; }
setCopyLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "equipment_code", operator: "equals", value: equipCode }] },
autoFilter: true,
});
setCopyItems(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setCopyItems([]); } finally { setCopyLoading(false); }
};
const handleCopyApply = async () => {
const selected = copyItems.filter((i) => copyChecked.has(i.id));
if (selected.length === 0) { toast.error("복사할 항목을 선택해주세요."); return; }
setSaving(true);
try {
for (const item of selected) {
const { id, created_date, updated_date, writer, company_code, equipment_code, ...fields } = item;
await apiClient.post(`/table-management/tables/${INSPECTION_TABLE}/add`, {
...fields, equipment_code: selectedEquip?.equipment_code,
});
}
toast.success(`${selected.length}개 점검항목이 복사되었습니다.`);
setCopyModalOpen(false); refreshRight();
} catch { toast.error("복사 실패"); } finally { setSaving(false); }
};
// 엑셀
const handleExcelDownload = async () => {
if (equipments.length === 0) return;
await exportToExcel(equipments.map((e) => ({
설비코드: e.equipment_code, 설비명: e.equipment_name, 설비유형: e.equipment_type,
제조사: e.manufacturer, 모델명: e.model_name, 설치장소: e.installation_location,
도입일자: e.introduction_date, 가동상태: e.operation_status,
})), "설비정보.xlsx", "설비");
toast.success("다운로드 완료");
};
// 셀렉트 렌더링 헬퍼
const catSelect = (key: string, value: string, onChange: (v: string) => void, placeholder: string) => (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className="h-9"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
{(catOptions[key] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
);
return (
<div className="flex h-full flex-col gap-3 p-3">
<DynamicSearchFilter tableName={EQUIP_TABLE} filterId="equipment-info" onFilterChange={setSearchFilters} dataCount={equipCount}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" disabled={excelDetecting}
onClick={async () => {
setExcelDetecting(true);
try {
const r = await autoDetectMultiTableConfig(EQUIP_TABLE);
if (r.success && r.data) { setExcelChainConfig(r.data); setExcelUploadOpen(true); }
else toast.error("테이블 구조 분석 실패");
} catch { toast.error("오류"); } finally { setExcelDetecting(false); }
}}>
{excelDetecting ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <FileSpreadsheet className="w-3.5 h-3.5 mr-1" />}
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 설비 목록 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Wrench className="w-4 h-4" /> <Badge variant="secondary" className="font-normal">{equipCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={openEquipRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={openEquipEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="destructive" size="sm" disabled={!selectedEquipId} onClick={handleEquipDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
</div>
</div>
<DataGrid gridId="equip-left" columns={LEFT_COLUMNS} data={equipments} loading={equipLoading}
selectedId={selectedEquipId} onSelect={setSelectedEquipId} onRowDoubleClick={() => openEquipEdit()}
emptyMessage="등록된 설비가 없습니다" />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 탭 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-2 border-b bg-muted/10 shrink-0">
<div className="flex items-center gap-1">
{([["info", "기본정보", Info], ["inspection", "점검항목", ClipboardCheck], ["consumable", "소모품", Package]] as const).map(([tab, label, Icon]) => (
<button key={tab} onClick={() => setRightTab(tab)}
className={cn("px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1",
rightTab === tab ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted text-muted-foreground")}>
<Icon className="w-3.5 h-3.5" />{label}
{tab === "inspection" && inspections.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{inspections.length}</Badge>}
{tab === "consumable" && consumables.length > 0 && <Badge variant="secondary" className="ml-1 text-[10px] px-1">{consumables.length}</Badge>}
</button>
))}
{selectedEquip && <Badge variant="outline" className="font-normal ml-2 text-xs">{selectedEquip.equipment_name}</Badge>}
</div>
<div className="flex gap-1.5">
{rightTab === "inspection" && (
<>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setInspectionForm({}); setInspectionModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setCopySourceEquip(""); setCopyItems([]); setCopyChecked(new Set()); setCopyModalOpen(true); }}>
<Copy className="w-3.5 h-3.5 mr-1" />
</Button>
</>
)}
{rightTab === "consumable" && (
<Button variant="outline" size="sm" disabled={!selectedEquipId} onClick={() => { setConsumableForm({}); loadConsumableItems(); setConsumableModalOpen(true); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
)}
</div>
</div>
{!selectedEquipId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm"> </div>
) : rightTab === "info" ? (
<div className="p-4 overflow-auto">
<div className="flex justify-end mb-3">
<Button size="sm" onClick={handleInfoSave} disabled={infoSaving}>
{infoSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-sm text-muted-foreground"></Label>
<Input value={infoForm.equipment_code || ""} className="h-9 bg-muted/50" disabled />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.equipment_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, equipment_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("equipment_type", infoForm.equipment_type, (v) => setInfoForm((p) => ({ ...p, equipment_type: v })), "설비유형")}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.installation_location || ""} onChange={(e) => setInfoForm((p) => ({ ...p, installation_location: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.manufacturer || ""} onChange={(e) => setInfoForm((p) => ({ ...p, manufacturer: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={infoForm.model_name || ""} onChange={(e) => setInfoForm((p) => ({ ...p, model_name: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={infoForm.introduction_date || ""} onChange={(v) => setInfoForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{catSelect("operation_status", infoForm.operation_status, (v) => setInfoForm((p) => ({ ...p, operation_status: v })), "가동상태")}
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<Input value={infoForm.remarks || ""} onChange={(e) => setInfoForm((p) => ({ ...p, remarks: e.target.value }))} className="h-9" />
</div>
<div className="space-y-1.5 col-span-2">
<Label className="text-sm"></Label>
<ImageUpload value={infoForm.image_path} onChange={(v) => setInfoForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={infoForm.id} columnName="image_path" />
</div>
</div>
</div>
) : rightTab === "inspection" ? (
<DataGrid gridId="equip-inspection" columns={INSPECTION_COLUMNS} data={inspections} loading={inspectionLoading}
showRowNumber={false} tableName={INSPECTION_TABLE} emptyMessage="점검항목이 없습니다"
onCellEdit={() => refreshRight()} />
) : (
<DataGrid gridId="equip-consumable" columns={CONSUMABLE_COLUMNS} data={consumables} loading={consumableLoading}
showRowNumber={false} tableName={CONSUMABLE_TABLE} emptyMessage="소모품이 없습니다"
onCellEdit={() => refreshRight()} />
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 설비 등록/수정 모달 */}
<FullscreenDialog open={equipModalOpen} onOpenChange={setEquipModalOpen}
title={equipEditMode ? "설비 수정" : "설비 등록"} description={equipEditMode ? "설비 정보를 수정합니다." : "새로운 설비를 등록합니다."}
defaultMaxWidth="max-w-2xl"
footer={<><Button variant="outline" onClick={() => setEquipModalOpen(false)}></Button>
<Button onClick={handleEquipSave} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} </Button></>}>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.equipment_code || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_code: e.target.value }))} placeholder="설비코드" className="h-9" disabled={equipEditMode} /></div>
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={equipForm.equipment_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, equipment_name: e.target.value }))} placeholder="설비명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("equipment_type", equipForm.equipment_type, (v) => setEquipForm((p) => ({ ...p, equipment_type: v })), "설비유형")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("operation_status", equipForm.operation_status, (v) => setEquipForm((p) => ({ ...p, operation_status: v })), "가동상태")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.installation_location || ""} onChange={(e) => setEquipForm((p) => ({ ...p, installation_location: e.target.value }))} placeholder="설치장소" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.manufacturer || ""} onChange={(e) => setEquipForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={equipForm.model_name || ""} onChange={(e) => setEquipForm((p) => ({ ...p, model_name: e.target.value }))} placeholder="모델명" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<FormDatePicker value={equipForm.introduction_date || ""} onChange={(v) => setEquipForm((p) => ({ ...p, introduction_date: v }))} placeholder="도입일자" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<Input value={equipForm.remarks || ""} onChange={(e) => setEquipForm((p) => ({ ...p, remarks: e.target.value }))} placeholder="비고" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={equipForm.image_path} onChange={(v) => setEquipForm((p) => ({ ...p, image_path: v }))}
tableName={EQUIP_TABLE} recordId={equipForm.id} columnName="image_path" /></div>
</div>
</FullscreenDialog>
{/* 점검항목 추가 모달 */}
<Dialog open={inspectionModalOpen} onOpenChange={setInspectionModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle><DialogDescription>{selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-1.5"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={inspectionForm.inspection_item || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_item: e.target.value }))} placeholder="점검항목" className="h-9" /></div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("inspection_cycle", inspectionForm.inspection_cycle, (v) => setInspectionForm((p) => ({ ...p, inspection_cycle: v })), "점검주기")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
{catSelect("inspection_method", inspectionForm.inspection_method, (v) => setInspectionForm((p) => ({ ...p, inspection_method: v })), "점검방법")}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.lower_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, lower_limit: e.target.value }))} placeholder="하한치" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.upper_limit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, upper_limit: e.target.value }))} placeholder="상한치" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.unit || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={inspectionForm.inspection_content || ""} onChange={(e) => setInspectionForm((p) => ({ ...p, inspection_content: e.target.value }))} placeholder="점검내용" className="h-9" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setInspectionModalOpen(false)}></Button>
<Button onClick={handleInspectionSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 소모품 추가 모달 */}
<Dialog open={consumableModalOpen} onOpenChange={setConsumableModalOpen}>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle> </DialogTitle><DialogDescription>{selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
<div className="space-y-1.5 col-span-2"><Label className="text-sm"> <span className="text-destructive">*</span></Label>
{consumableItemOptions.length > 0 ? (
<Select value={consumableForm.consumable_name || ""} onValueChange={(v) => {
const item = consumableItemOptions.find((i) => (i.item_name || i.item_number) === v);
setConsumableForm((p) => ({
...p,
consumable_name: v,
specification: item?.size || p.specification || "",
unit: item?.unit || p.unit || "",
manufacturer: item?.manufacturer || p.manufacturer || "",
}));
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="소모품 선택" /></SelectTrigger>
<SelectContent>
{consumableItemOptions.map((item) => (
<SelectItem key={item.id} value={item.item_name || item.item_number}>
{item.item_name}{item.size ? ` (${item.size})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div>
<Input value={consumableForm.consumable_name || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, consumable_name: e.target.value }))}
placeholder="소모품명 직접 입력" className="h-9" />
<p className="text-xs text-muted-foreground mt-1"> </p>
</div>
)}</div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.replacement_cycle || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, replacement_cycle: e.target.value }))} placeholder="교체주기" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.unit || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, unit: e.target.value }))} placeholder="단위" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.specification || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, specification: e.target.value }))} placeholder="규격" className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-sm"></Label>
<Input value={consumableForm.manufacturer || ""} onChange={(e) => setConsumableForm((p) => ({ ...p, manufacturer: e.target.value }))} placeholder="제조사" className="h-9" /></div>
<div className="space-y-1.5 col-span-2"><Label className="text-sm"></Label>
<ImageUpload value={consumableForm.image_path} onChange={(v) => setConsumableForm((p) => ({ ...p, image_path: v }))}
tableName={CONSUMABLE_TABLE} columnName="image_path" /></div>
</div>
<DialogFooter><Button variant="outline" onClick={() => setConsumableModalOpen(false)}></Button>
<Button onClick={handleConsumableSave} disabled={saving}><Save className="w-4 h-4 mr-1.5" /> </Button></DialogFooter>
</DialogContent>
</Dialog>
{/* 점검항목 복사 모달 */}
<Dialog open={copyModalOpen} onOpenChange={setCopyModalOpen}>
<DialogContent className="max-w-2xl max-h-[70vh]">
<DialogHeader><DialogTitle> </DialogTitle>
<DialogDescription> {selectedEquip?.equipment_name} .</DialogDescription></DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={copySourceEquip} onValueChange={(v) => loadCopyItems(v)}>
<SelectTrigger className="h-9"><SelectValue placeholder="복사할 설비 선택" /></SelectTrigger>
<SelectContent>
{equipments.filter((e) => e.equipment_code !== selectedEquip?.equipment_code).map((e) => (
<SelectItem key={e.equipment_code} value={e.equipment_code}>{e.equipment_name} ({e.equipment_code})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border rounded-lg overflow-auto max-h-[300px]">
{copyLoading ? (
<div className="flex items-center justify-center py-8"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : copyItems.length === 0 ? (
<div className="text-center text-muted-foreground py-8 text-sm">{copySourceEquip ? "점검항목이 없습니다" : "설비를 선택하세요"}</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox" checked={copyItems.length > 0 && copyChecked.size === copyItems.length}
onChange={(e) => { if (e.target.checked) setCopyChecked(new Set(copyItems.map((i) => i.id))); else setCopyChecked(new Set()); }} />
</TableHead>
<TableHead></TableHead><TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px]"></TableHead><TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[70px]"></TableHead><TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyItems.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", copyChecked.has(item.id) && "bg-primary/5")}
onClick={() => setCopyChecked((prev) => { const n = new Set(prev); if (n.has(item.id)) n.delete(item.id); else n.add(item.id); return n; })}>
<TableCell className="text-center"><input type="checkbox" checked={copyChecked.has(item.id)} readOnly /></TableCell>
<TableCell className="text-sm">{item.inspection_item}</TableCell>
<TableCell className="text-xs">{resolve("inspection_cycle", item.inspection_cycle)}</TableCell>
<TableCell className="text-xs">{resolve("inspection_method", item.inspection_method)}</TableCell>
<TableCell className="text-xs">{item.lower_limit || "-"}</TableCell>
<TableCell className="text-xs">{item.upper_limit || "-"}</TableCell>
<TableCell className="text-xs">{item.unit || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{copyChecked.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setCopyModalOpen(false)}></Button>
<Button onClick={handleCopyApply} disabled={saving || copyChecked.size === 0}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Copy className="w-4 h-4 mr-1.5" />}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 (멀티테이블) */}
{excelChainConfig && (
<MultiTableExcelUploadModal open={excelUploadOpen}
onOpenChange={(open) => { setExcelUploadOpen(open); if (!open) setExcelChainConfig(null); }}
config={excelChainConfig} onSuccess={() => { fetchEquipments(); refreshRight(); }} />
)}
{ConfirmDialogComponent}
</div>
);
}
@@ -0,0 +1,597 @@
"use client";
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 { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Search,
RotateCcw,
Package,
ClipboardList,
Factory,
MapPin,
AlertTriangle,
CheckCircle2,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
getWorkOrders,
getMaterialStatus,
getWarehouses,
type WorkOrder,
type MaterialData,
type WarehouseData,
} from "@/lib/api/materialStatus";
const formatDate = (date: Date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
planned: "계획",
in_progress: "진행중",
completed: "완료",
pending: "대기",
cancelled: "취소",
};
return map[status] || status;
};
const getStatusStyle = (status: string) => {
const map: Record<string, string> = {
planned: "bg-amber-100 text-amber-700 border-amber-200",
pending: "bg-amber-100 text-amber-700 border-amber-200",
in_progress: "bg-blue-100 text-blue-700 border-blue-200",
completed: "bg-emerald-100 text-emerald-700 border-emerald-200",
cancelled: "bg-gray-100 text-gray-500 border-gray-200",
};
return map[status] || "bg-gray-100 text-gray-500 border-gray-200";
};
export default function MaterialStatusPage() {
const today = new Date();
const monthAgo = new Date(today);
monthAgo.setMonth(today.getMonth() - 1);
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
const [searchItemCode, setSearchItemCode] = useState("");
const [searchItemName, setSearchItemName] = useState("");
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
const [checkedWoIds, setCheckedWoIds] = useState<number[]>([]);
const [selectedWoId, setSelectedWoId] = useState<number | null>(null);
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
const [warehouse, setWarehouse] = useState("");
const [materialSearch, setMaterialSearch] = useState("");
const [showShortageOnly, setShowShortageOnly] = useState(false);
const [materials, setMaterials] = useState<MaterialData[]>([]);
const [materialsLoading, setMaterialsLoading] = useState(false);
// 창고 목록 초기 로드
useEffect(() => {
(async () => {
const res = await getWarehouses();
if (res.success && res.data) {
setWarehouses(res.data);
}
})();
}, []);
// 작업지시 검색
const handleSearch = useCallback(async () => {
setWorkOrdersLoading(true);
try {
const res = await getWorkOrders({
dateFrom: searchDateFrom,
dateTo: searchDateTo,
itemCode: searchItemCode || undefined,
itemName: searchItemName || undefined,
});
if (res.success && res.data) {
setWorkOrders(res.data);
setCheckedWoIds([]);
setSelectedWoId(null);
setMaterials([]);
}
} finally {
setWorkOrdersLoading(false);
}
}, [searchDateFrom, searchDateTo, searchItemCode, searchItemName]);
// 초기 로드
useEffect(() => {
handleSearch();
}, []);
const isAllChecked =
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
const handleCheckAll = useCallback(
(checked: boolean) => {
setCheckedWoIds(checked ? workOrders.map((wo) => wo.id) : []);
},
[workOrders]
);
const handleCheckWo = useCallback((id: number, checked: boolean) => {
setCheckedWoIds((prev) =>
checked ? [...prev, id] : prev.filter((i) => i !== id)
);
}, []);
const handleSelectWo = useCallback((id: number) => {
setSelectedWoId((prev) => (prev === id ? null : id));
}, []);
// 선택된 작업지시의 자재 조회
const handleLoadSelectedMaterials = useCallback(async () => {
if (checkedWoIds.length === 0) {
alert("자재를 조회할 작업지시를 선택해주세요.");
return;
}
setMaterialsLoading(true);
try {
const res = await getMaterialStatus({
planIds: checkedWoIds,
warehouseCode: warehouse || undefined,
});
if (res.success && res.data) {
setMaterials(res.data);
}
} finally {
setMaterialsLoading(false);
}
}, [checkedWoIds, warehouse]);
const handleResetSearch = useCallback(() => {
const t = new Date();
const m = new Date(t);
m.setMonth(t.getMonth() - 1);
setSearchDateFrom(formatDate(m));
setSearchDateTo(formatDate(t));
setSearchItemCode("");
setSearchItemName("");
setMaterialSearch("");
setShowShortageOnly(false);
}, []);
const filteredMaterials = useMemo(() => {
return materials.filter((m) => {
const searchLower = materialSearch.toLowerCase();
const matchesSearch =
!materialSearch ||
m.code.toLowerCase().includes(searchLower) ||
m.name.toLowerCase().includes(searchLower);
const matchesShortage = !showShortageOnly || m.current < m.required;
return matchesSearch && matchesShortage;
});
}, [materials, materialSearch, showShortageOnly]);
return (
<div className="flex h-[calc(100vh-4rem)] flex-col gap-4 bg-muted/30 p-4">
{/* 헤더 */}
<div className="shrink-0">
<div className="flex items-center gap-3">
<Package className="h-7 w-7 text-primary" />
<div>
<h1 className="text-2xl font-bold"></h1>
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
</div>
{/* 검색 영역 */}
<Card className="shrink-0">
<CardContent className="flex flex-wrap items-end gap-3 p-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<div className="flex items-center gap-2">
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)}
/>
<span className="text-muted-foreground">~</span>
<Input
type="date"
className="h-9 w-[140px]"
value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
placeholder="품목코드"
className="h-9 w-[140px]"
value={searchItemCode}
onChange={(e) => setSearchItemCode(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
placeholder="품목명"
className="h-9 w-[140px]"
value={searchItemName}
onChange={(e) => setSearchItemName(e.target.value)}
/>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleResetSearch}
>
<RotateCcw className="mr-2 h-4 w-4" />
</Button>
<Button
size="sm"
className="h-9"
onClick={handleSearch}
disabled={workOrdersLoading}
>
{workOrdersLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Search className="mr-2 h-4 w-4" />
)}
</Button>
</div>
</CardContent>
</Card>
{/* 메인 콘텐츠 (좌우 분할) */}
<div className="flex-1 overflow-hidden rounded-lg border bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 왼쪽: 작업지시 리스트 */}
<ResizablePanel defaultSize={35} minSize={25}>
<div className="flex h-full flex-col">
{/* 패널 헤더 */}
<div className="flex items-center justify-between border-b bg-muted/10 p-3 shrink-0">
<div className="flex items-center gap-2">
<Checkbox
checked={isAllChecked}
onCheckedChange={handleCheckAll}
/>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="font-normal">
{workOrders.length}
</Badge>
<Button
size="sm"
className="h-8"
onClick={handleLoadSelectedMaterials}
disabled={materialsLoading}
>
{materialsLoading ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Search className="mr-1.5 h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
{/* 작업지시 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{workOrdersLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
...
</p>
</div>
) : workOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<ClipboardList className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
workOrders.map((wo) => (
<div
key={wo.id}
className={cn(
"flex gap-3 rounded-lg border-2 p-3 transition-all cursor-pointer",
"hover:border-primary hover:shadow-md hover:-translate-y-0.5",
selectedWoId === wo.id
? "border-primary bg-primary/5 shadow-md"
: "border-border"
)}
onClick={() => handleSelectWo(wo.id)}
>
<div
className="flex items-start pt-0.5"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={checkedWoIds.includes(wo.id)}
onCheckedChange={(c) =>
handleCheckWo(wo.id, c as boolean)
}
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-primary">
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
</span>
<span
className={cn(
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
getStatusStyle(wo.status)
)}
>
{getStatusLabel(wo.status)}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">
{wo.item_name}
</span>
<span className="text-xs text-muted-foreground">
({wo.item_code})
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>:</span>
<span className="font-semibold text-foreground">
{Number(wo.plan_qty).toLocaleString()}
</span>
<span className="mx-1">|</span>
<span>:</span>
<span className="font-semibold text-foreground">
{wo.plan_date
? new Date(wo.plan_date)
.toISOString()
.slice(0, 10)
: "-"}
</span>
</div>
</div>
</div>
))
)}
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 원자재 현황 */}
<ResizablePanel defaultSize={65} minSize={35}>
<div className="flex h-full flex-col">
{/* 패널 헤더 */}
<div className="flex items-center gap-2 border-b bg-muted/10 p-3 shrink-0">
<Factory className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
</div>
{/* 필터 */}
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/5 px-4 py-3 shrink-0">
<Input
placeholder="원자재 검색"
className="h-9 min-w-[150px] flex-1"
value={materialSearch}
onChange={(e) => setMaterialSearch(e.target.value)}
/>
<Select value={warehouse} onValueChange={setWarehouse}>
<SelectTrigger className="h-9 w-[200px]">
<SelectValue placeholder="전체 창고" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{warehouses.map((wh) => (
<SelectItem
key={wh.warehouse_code}
value={wh.warehouse_code}
>
{wh.warehouse_name}
{wh.warehouse_type
? ` (${wh.warehouse_type})`
: ""}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex cursor-pointer items-center gap-2 text-sm font-medium">
<Checkbox
checked={showShortageOnly}
onCheckedChange={(c) => setShowShortageOnly(c as boolean)}
/>
<span> </span>
</label>
<span className="ml-auto text-sm font-semibold text-muted-foreground">
{filteredMaterials.length}
</span>
</div>
{/* 원자재 목록 */}
<div className="flex-1 space-y-2 overflow-auto p-3">
{materialsLoading ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
...
</p>
</div>
) : materials.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : filteredMaterials.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
filteredMaterials.map((material) => {
const shortage = material.required - material.current;
const isShortage = shortage > 0;
const percentage =
material.required > 0
? Math.min(
(material.current / material.required) * 100,
100
)
: 100;
return (
<div
key={material.code}
className={cn(
"rounded-lg border-2 p-3 transition-all hover:shadow-md hover:-translate-y-0.5",
isShortage
? "border-destructive/40 bg-destructive/2"
: "border-emerald-300/50 bg-emerald-50/20"
)}
>
{/* 메인 정보 라인 */}
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold">
{material.name}
</span>
<span className="text-xs text-muted-foreground">
({material.code})
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
:
</span>
<span className="text-xs font-semibold text-blue-600">
{material.required.toLocaleString()}
{material.unit}
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
:
</span>
<span
className={cn(
"text-xs font-semibold",
isShortage
? "text-destructive"
: "text-foreground"
)}
>
{material.current.toLocaleString()}
{material.unit}
</span>
<span className="text-xs text-muted-foreground">
|
</span>
<span className="text-xs text-muted-foreground">
{isShortage ? "부족:" : "여유:"}
</span>
<span
className={cn(
"text-xs font-semibold",
isShortage
? "text-destructive"
: "text-emerald-600"
)}
>
{Math.abs(shortage).toLocaleString()}
{material.unit}
</span>
<span className="text-xs font-semibold text-muted-foreground">
({percentage.toFixed(0)}%)
</span>
{isShortage ? (
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-destructive bg-destructive/10 px-2 py-0.5 text-[11px] font-semibold text-destructive">
<AlertTriangle className="h-3 w-3" />
</span>
) : (
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-emerald-500 bg-emerald-500/10 px-2 py-0.5 text-[11px] font-semibold text-emerald-600">
<CheckCircle2 className="h-3 w-3" />
</span>
)}
</div>
{/* 위치별 재고 */}
{material.locations.length > 0 && (
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
{material.locations.map((loc, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
{material.unit}
</span>
</span>
))}
</div>
)}
</div>
);
})
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,448 @@
"use client";
/**
*
*
* 좌측: 부서 (dept_info)
* 우측: 선택한 (user_info)
*
* 모달: 부서 (dept_info), (user_info)
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import {
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
Building2, Users,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
const DEPT_TABLE = "dept_info";
const USER_TABLE = "user_info";
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
{ key: "dept_name", label: "부서명", minWidth: "min-w-[150px]" },
{ key: "parent_dept_code", label: "상위부서", width: "w-[100px]" },
{ key: "status", label: "상태", width: "w-[70px]" },
];
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "sabun", label: "사번", width: "w-[80px]" },
{ key: "user_name", label: "이름", width: "w-[90px]" },
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
{ key: "position_name", label: "직급", width: "w-[80px]" },
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]" },
{ key: "email", label: "이메일", minWidth: "min-w-[150px]" },
];
export default function DepartmentPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 부서
const [depts, setDepts] = useState<any[]>([]);
const [deptLoading, setDeptLoading] = useState(false);
const [deptCount, setDeptCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedDeptCode, setSelectedDeptCode] = useState<string | null>(null);
// 우측: 사원
const [members, setMembers] = useState<any[]>([]);
const [memberLoading, setMemberLoading] = useState(false);
// 부서 모달
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [deptEditMode, setDeptEditMode] = useState(false);
const [deptForm, setDeptForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 사원 모달
const [userModalOpen, setUserModalOpen] = useState(false);
const [userForm, setUserForm] = useState<Record<string, any>>({});
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 부서 조회
const fetchDepts = useCallback(async () => {
setDeptLoading(true);
try {
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
const res = await apiClient.post(`/table-management/tables/${DEPT_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const data = res.data?.data?.data || res.data?.data?.rows || [];
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.dept_code === selectedDeptCode);
// 우측: 사원 조회
useEffect(() => {
if (!selectedDeptCode) { setMembers([]); return; }
const fetchMembers = async () => {
setMemberLoading(true);
try {
const res = await apiClient.post(`/table-management/tables/${USER_TABLE}/data`, {
page: 1, size: 500,
dataFilter: { enabled: true, filters: [{ columnName: "dept_code", operator: "equals", value: selectedDeptCode }] },
autoFilter: true,
});
setMembers(res.data?.data?.data || res.data?.data?.rows || []);
} catch { setMembers([]); } finally { setMemberLoading(false); }
};
fetchMembers();
}, [selectedDeptCode]);
// 부서 등록
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("삭제되었습니다.");
setSelectedDeptCode(null);
fetchDepts();
} catch { toast.error("삭제에 실패했습니다."); }
};
// 사원 추가
const openUserModal = () => {
setUserForm({ dept_code: selectedDeptCode || "" });
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 { created_date, updated_date, ...fields } = userForm;
await apiClient.post(`/table-management/tables/${USER_TABLE}/add`, fields);
toast.success("사원이 추가되었습니다.");
setUserModalOpen(false);
// 우측 새로고침
const code = selectedDeptCode;
setSelectedDeptCode(null);
setTimeout(() => setSelectedDeptCode(code), 50);
} catch (err: any) {
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (depts.length === 0) return;
const data = depts.map((d) => ({
부서코드: d.dept_code, 부서명: d.dept_name, 상위부서: d.parent_dept_code, 상태: d.status,
}));
await exportToExcel(data, "부서관리.xlsx", "부서");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<DynamicSearchFilter
tableName={DEPT_TABLE}
filterId="department"
onFilterChange={setSearchFilters}
dataCount={deptCount}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 부서 */}
<ResizablePanel defaultSize={40} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Building2 className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{deptCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button size="sm" onClick={openDeptRegister}><Plus className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="outline" size="sm" disabled={!selectedDeptCode} onClick={openDeptEdit}><Pencil className="w-3.5 h-3.5 mr-1" /> </Button>
<Button variant="destructive" size="sm" disabled={!selectedDeptCode} onClick={handleDeptDelete}><Trash2 className="w-3.5 h-3.5 mr-1" /> </Button>
</div>
</div>
<DataGrid
gridId="dept-left"
columns={LEFT_COLUMNS}
data={depts}
loading={deptLoading}
selectedId={selectedDeptCode}
onSelect={(id) => {
const dept = depts.find((d) => d.dept_code === id || d.id === id);
setSelectedDeptCode(dept?.dept_code || 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 && <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" disabled={!selectedDeptCode} onClick={openUserModal}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedDeptCode ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="dept-right"
columns={RIGHT_COLUMNS}
data={members}
loading={memberLoading}
showRowNumber={false}
tableName={USER_TABLE}
emptyMessage="소속 사원이 없습니다"
/>
)}
</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> </DialogTitle>
<DialogDescription>{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" />
</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="비밀번호" className="h-9" type="password" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.position_name || ""} onChange={(e) => setUserForm((p) => ({ ...p, position_name: e.target.value }))}
placeholder="직급" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={userForm.dept_code || ""} onValueChange={(v) => setUserForm((p) => ({ ...p, dept_code: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="부서 선택" /></SelectTrigger>
<SelectContent>
{depts.map((d) => <SelectItem key={d.dept_code} value={d.dept_code}>{d.dept_name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.cell_phone || ""} onChange={(e) => handleUserFormChange("cell_phone", e.target.value)}
placeholder="010-0000-0000" className={cn("h-9", formErrors.cell_phone && "border-destructive")} />
{formErrors.cell_phone && <p className="text-xs text-destructive">{formErrors.cell_phone}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={userForm.email || ""} onChange={(e) => handleUserFormChange("email", e.target.value)}
placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} />
{formErrors.email && <p className="text-xs text-destructive">{formErrors.email}</p>}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={userForm.regdate || ""} onChange={(v) => setUserForm((p) => ({ ...p, regdate: v }))} placeholder="입사일" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={userForm.end_date || ""} onChange={(v) => setUserForm((p) => ({ ...p, end_date: v }))} placeholder="퇴사일" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserModalOpen(false)}></Button>
<Button onClick={handleUserSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DEPT_TABLE}
userId={user?.userId}
onSuccess={() => fetchDepts()}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -0,0 +1,517 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Plus, Trash2, RotateCcw, Save, Search, Loader2, FileSpreadsheet, Download,
Package, Pencil, Copy,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
// 테이블 컬럼 정의
const TABLE_COLUMNS = [
{ key: "item_number", label: "품목코드", width: "w-[120px]" },
{ key: "item_name", label: "품명", width: "min-w-[150px]" },
{ key: "division", label: "관리품목", width: "w-[100px]" },
{ key: "type", label: "품목구분", width: "w-[100px]" },
{ key: "size", label: "규격", width: "w-[100px]" },
{ key: "unit", label: "단위", width: "w-[80px]" },
{ key: "material", label: "재질", width: "w-[100px]" },
{ key: "status", label: "상태", width: "w-[80px]" },
{ key: "selling_price", label: "판매가격", width: "w-[100px]" },
{ key: "standard_price", label: "기준단가", width: "w-[100px]" },
{ key: "weight", label: "중량", width: "w-[80px]" },
{ key: "inventory_unit", label: "재고단위", width: "w-[80px]" },
{ key: "user_type01", label: "대분류", width: "w-[100px]" },
{ key: "user_type02", label: "중분류", width: "w-[100px]" },
];
// 등록 모달 필드 정의
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "category" },
{ key: "type", label: "품목구분", type: "category" },
{ key: "size", label: "규격", type: "text" },
{ key: "unit", label: "단위", type: "category" },
{ key: "material", label: "재질", type: "category" },
{ key: "status", label: "상태", type: "category" },
{ key: "weight", label: "중량", type: "text" },
{ key: "volum", label: "부피", type: "text" },
{ key: "specific_gravity", label: "비중", type: "text" },
{ key: "inventory_unit", label: "재고단위", type: "category" },
{ key: "selling_price", label: "판매가격", type: "text" },
{ key: "standard_price", label: "기준단가", type: "text" },
{ key: "currency_code", label: "통화", type: "category" },
{ key: "user_type01", label: "대분류", type: "category" },
{ key: "user_type02", label: "중분류", type: "category" },
{ key: "meno", label: "메모", type: "textarea" },
];
const TABLE_NAME = "item_info";
export default function ItemInfoPage() {
const { user } = useAuth();
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
const [searchDivision, setSearchDivision] = useState("all");
const [searchType, setSearchType] = useState("all");
const [searchStatus, setSearchStatus] = useState("all");
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<Record<string, any>>({});
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 카테고리 옵션 (API에서 로드)
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 선택된 행
const [selectedId, setSelectedId] = useState<string | null>(null);
// 카테고리 컬럼 목록
const CATEGORY_COLUMNS = ["division", "type", "unit", "material", "status", "inventory_unit", "currency_code", "user_type01", "user_type02"];
// 카테고리 옵션 로드 (table_name + column_name 기반)
useEffect(() => {
const loadCategories = async () => {
try {
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
await Promise.all(
CATEGORY_COLUMNS.map(async (colName) => {
try {
const res = await apiClient.get(`/table-categories/${TABLE_NAME}/${colName}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[colName] = flatten(res.data.data);
}
} catch { /* skip */ }
})
);
setCategoryOptions(optMap);
} catch (err) {
console.error("카테고리 로드 실패:", err);
}
};
loadCategories();
}, []);
// 데이터 조회
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = [];
if (searchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: searchKeyword });
}
if (searchDivision !== "all") {
filters.push({ columnName: "division", operator: "equals", value: searchDivision });
}
if (searchType !== "all") {
filters.push({ columnName: "type", operator: "equals", value: searchType });
}
if (searchStatus !== "all") {
filters.push({ columnName: "status", operator: "equals", value: searchStatus });
}
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1,
size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
const raw = res.data?.data?.data || res.data?.data?.rows || [];
// 카테고리 코드→라벨 변환
const resolve = (col: string, code: string) => {
if (!code) return "";
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
};
const data = raw.map((r: any) => {
const converted = { ...r };
for (const col of CATEGORY_COLUMNS) {
if (converted[col]) converted[col] = resolve(col, converted[col]);
}
return converted;
});
setItems(data);
setTotalCount(res.data?.data?.total || raw.length);
} catch (err) {
console.error("품목 조회 실패:", err);
toast.error("품목 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchKeyword, searchDivision, searchType, searchStatus, categoryOptions]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
// 카테고리 코드 → 라벨 변환
const getCategoryLabel = (columnName: string, code: string) => {
if (!code) return "";
const opts = categoryOptions[columnName];
if (!opts) return code;
const found = opts.find((o) => o.code === code);
return found?.label || code;
};
// 등록 모달 열기
const openRegisterModal = () => {
setFormData({});
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
};
// 수정 모달 열기
const openEditModal = (item: any) => {
setFormData({ ...item });
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
};
// 복사 모달 열기
const openCopyModal = (item: any) => {
const { id, item_number, created_date, updated_date, writer, ...rest } = item;
setFormData(rest);
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
};
// 저장
const handleSave = async () => {
if (!formData.item_name) {
toast.error("품명은 필수 입력입니다.");
return;
}
setSaving(true);
try {
if (isEditMode && editId) {
// 수정
const { id, created_date, updated_date, writer, company_code, ...updateFields } = formData;
await apiClient.put(`/table-management/tables/${TABLE_NAME}/edit`, {
originalData: { id: editId },
updatedData: updateFields,
});
toast.success("수정되었습니다.");
} else {
// 등록
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, insertFields);
toast.success("등록되었습니다.");
}
setIsModalOpen(false);
fetchItems();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 삭제
const handleDelete = async () => {
if (!selectedId) {
toast.error("삭제할 품목을 선택해주세요.");
return;
}
if (!confirm("선택한 품목을 삭제하시겠습니까?")) return;
try {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, {
data: [{ id: selectedId }],
});
toast.success("삭제되었습니다.");
setSelectedId(null);
fetchItems();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
// 엑셀 다운로드
const handleExcelDownload = async () => {
if (items.length === 0) {
toast.error("다운로드할 데이터가 없습니다.");
return;
}
const exportData = items.map((item) => {
const row: Record<string, any> = {};
for (const col of TABLE_COLUMNS) {
row[col.label] = getCategoryLabel(col.key, item[col.key]) || item[col.key] || "";
}
return row;
});
await exportToExcel(exportData, "품목정보.xlsx", "품목정보");
toast.success("엑셀 다운로드 완료");
};
// 검색 초기화
const handleResetSearch = () => {
setSearchKeyword("");
setSearchDivision("all");
setSearchType("all");
setSearchStatus("all");
};
// 카테고리 셀렉트 렌더링
const renderCategorySelect = (field: typeof FORM_FIELDS[0]) => {
const options = categoryOptions[field.key] || [];
return (
<Select
value={formData[field.key] || ""}
onValueChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder={`${field.label} 선택`} />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.code} value={opt.code}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 */}
<Card className="shrink-0">
<CardContent className="p-4 flex flex-wrap items-end gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">/</Label>
<Input
placeholder="검색"
className="w-[180px] h-9"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && fetchItems()}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchDivision} onValueChange={setSearchDivision}>
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["division"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchType} onValueChange={setSearchType}>
<SelectTrigger className="w-[120px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["type"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[110px] h-9"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{(categoryOptions["status"] || []).map((o) => (
<SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
{loading && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
<Button variant="outline" size="sm" className="h-9" onClick={handleResetSearch}>
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{totalCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openCopyModal(item);
}}>
<Copy className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={!selectedId} onClick={() => {
const item = items.find((i) => i.id === selectedId);
if (item) openEditModal(item);
}}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={!selectedId} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-muted-foreground gap-2">
<Package className="w-8 h-8 opacity-50" />
<span> </span>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[40px] text-center">No</TableHead>
{TABLE_COLUMNS.map((col) => (
<TableHead key={col.key} className={col.width}>{col.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow
key={item.id}
className={cn("cursor-pointer", selectedId === item.id && "bg-primary/5")}
onClick={() => setSelectedId(item.id)}
onDoubleClick={() => openEditModal(item)}
>
<TableCell className="text-center text-xs text-muted-foreground">{idx + 1}</TableCell>
{TABLE_COLUMNS.map((col) => (
<TableCell key={col.key} className="text-sm">
{["division", "type", "unit", "material", "status", "inventory_unit", "user_type01", "user_type02", "currency_code"].includes(col.key)
? getCategoryLabel(col.key, item[col.key])
: item[col.key] || ""}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
{/* 등록/수정 모달 */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-auto">
<DialogHeader>
<DialogTitle>{isEditMode ? "품목 수정" : "품목 등록"}</DialogTitle>
<DialogDescription>
{isEditMode ? "품목 정보를 수정합니다." : "새로운 품목을 등록합니다."}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 py-4">
{FORM_FIELDS.map((field) => (
<div key={field.key} className={cn("space-y-1.5", field.type === "textarea" && "col-span-2")}>
<Label className="text-sm">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "category" ? (
renderCategorySelect(field)
) : field.type === "textarea" ? (
<Textarea
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.label}
rows={3}
/>
) : (
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={field.disabled ? field.placeholder : field.label}
disabled={field.disabled && !isEditMode}
className="h-9"
/>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={TABLE_NAME}
userId={user?.userId}
onSuccess={() => {
fetchItems();
}}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,845 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Loader2,
Settings,
Plus,
Pencil,
Trash2,
Search,
RotateCcw,
Wrench,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import {
getProcessList,
createProcess,
updateProcess,
deleteProcesses,
getProcessEquipments,
addProcessEquipment,
removeProcessEquipment,
getEquipmentList,
type ProcessMaster,
type ProcessEquipment,
type Equipment,
} from "@/lib/api/processInfo";
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
const ALL_VALUE = "__all__";
export function ProcessMasterTab() {
const [processes, setProcesses] = useState<ProcessMaster[]>([]);
const [equipmentMaster, setEquipmentMaster] = useState<Equipment[]>([]);
const [processTypeOptions, setProcessTypeOptions] = useState<{ valueCode: string; valueLabel: string }[]>([]);
const [loadingInitial, setLoadingInitial] = useState(true);
const [loadingList, setLoadingList] = useState(false);
const [loadingEquipments, setLoadingEquipments] = useState(false);
const [filterCode, setFilterCode] = useState("");
const [filterName, setFilterName] = useState("");
const [filterType, setFilterType] = useState<string>(ALL_VALUE);
const [filterUseYn, setFilterUseYn] = useState<string>(ALL_VALUE);
const [selectedProcess, setSelectedProcess] = useState<ProcessMaster | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
const [processEquipments, setProcessEquipments] = useState<ProcessEquipment[]>([]);
const [equipmentPick, setEquipmentPick] = useState<string>("");
const [addingEquipment, setAddingEquipment] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<"add" | "edit">("add");
const [savingForm, setSavingForm] = useState(false);
const [formProcessCode, setFormProcessCode] = useState("");
const [formProcessName, setFormProcessName] = useState("");
const [formProcessType, setFormProcessType] = useState<string>("");
const [formStandardTime, setFormStandardTime] = useState("");
const [formWorkerCount, setFormWorkerCount] = useState("");
const [formUseYn, setFormUseYn] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const processTypeMap = useMemo(() => {
const m = new Map<string, string>();
processTypeOptions.forEach((o) => m.set(o.valueCode, o.valueLabel));
return m;
}, [processTypeOptions]);
const getProcessTypeLabel = useCallback(
(code: string) => processTypeMap.get(code) ?? code,
[processTypeMap]
);
const loadProcesses = useCallback(async () => {
setLoadingList(true);
try {
const res = await getProcessList({
processCode: filterCode.trim() || undefined,
processName: filterName.trim() || undefined,
processType: filterType === ALL_VALUE ? undefined : filterType,
useYn: filterUseYn === ALL_VALUE ? undefined : filterUseYn,
});
if (!res.success) {
toast.error(res.message || "공정 목록을 불러오지 못했습니다.");
return;
}
setProcesses(res.data ?? []);
} finally {
setLoadingList(false);
}
}, [filterCode, filterName, filterType, filterUseYn]);
const loadInitial = useCallback(async () => {
setLoadingInitial(true);
try {
const [procRes, eqRes] = await Promise.all([getProcessList(), getEquipmentList()]);
if (!procRes.success) {
toast.error(procRes.message || "공정 목록을 불러오지 못했습니다.");
} else {
setProcesses(procRes.data ?? []);
}
if (!eqRes.success) {
toast.error(eqRes.message || "설비 목록을 불러오지 못했습니다.");
} else {
setEquipmentMaster(eqRes.data ?? []);
}
const ptRes = await getCategoryValues("process_mng", "process_type");
if (ptRes.success && "data" in ptRes && Array.isArray(ptRes.data)) {
const activeValues = ptRes.data.filter((v: any) => v.isActive !== false);
const seen = new Set<string>();
const unique = activeValues.filter((v: any) => {
if (seen.has(v.valueCode)) return false;
seen.add(v.valueCode);
return true;
});
setProcessTypeOptions(unique.map((v: any) => ({ valueCode: v.valueCode, valueLabel: v.valueLabel })));
}
} finally {
setLoadingInitial(false);
}
}, []);
useEffect(() => {
void loadInitial();
}, [loadInitial]);
useEffect(() => {
setSelectedProcess((prev) => {
if (!prev) return prev;
if (!processes.some((p) => p.id === prev.id)) return null;
return prev;
});
}, [processes]);
useEffect(() => {
setEquipmentPick("");
}, [selectedProcess?.id]);
useEffect(() => {
if (!selectedProcess) {
setProcessEquipments([]);
return;
}
let cancelled = false;
setLoadingEquipments(true);
void (async () => {
const res = await getProcessEquipments(selectedProcess.process_code);
if (cancelled) return;
if (!res.success) {
toast.error(res.message || "공정 설비를 불러오지 못했습니다.");
setProcessEquipments([]);
} else {
setProcessEquipments(res.data ?? []);
}
setLoadingEquipments(false);
})();
return () => {
cancelled = true;
};
}, [selectedProcess?.process_code]);
const allSelected = useMemo(() => {
if (processes.length === 0) return false;
return processes.every((p) => selectedIds.has(p.id));
}, [processes, selectedIds]);
const toggleAll = (checked: boolean) => {
if (checked) {
setSelectedIds(new Set(processes.map((p) => p.id)));
} else {
setSelectedIds(new Set());
}
};
const toggleOne = (id: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) next.add(id);
else next.delete(id);
return next;
});
};
const handleResetFilters = () => {
setFilterCode("");
setFilterName("");
setFilterType(ALL_VALUE);
setFilterUseYn(ALL_VALUE);
};
const handleSearch = () => {
void loadProcesses();
};
const openAdd = () => {
setFormMode("add");
setEditingId(null);
setFormProcessCode("");
setFormProcessName("");
setFormProcessType(processTypeOptions[0]?.valueCode ?? "");
setFormStandardTime("");
setFormWorkerCount("");
setFormUseYn("Y");
setFormOpen(true);
};
const openEdit = () => {
if (!selectedProcess) {
toast.message("수정할 공정을 좌측 목록에서 선택하세요.");
return;
}
setFormMode("edit");
setEditingId(selectedProcess.id);
setFormProcessCode(selectedProcess.process_code);
setFormProcessName(selectedProcess.process_name);
setFormProcessType(selectedProcess.process_type);
setFormStandardTime(selectedProcess.standard_time ?? "");
setFormWorkerCount(selectedProcess.worker_count ?? "");
setFormUseYn(selectedProcess.use_yn);
setFormOpen(true);
};
const submitForm = async () => {
if (!formProcessName.trim()) {
toast.error("공정명을 입력하세요.");
return;
}
setSavingForm(true);
try {
if (formMode === "add") {
const res = await createProcess({
process_name: formProcessName.trim(),
process_type: formProcessType,
standard_time: formStandardTime.trim() || "0",
worker_count: formWorkerCount.trim() || "0",
use_yn: formUseYn,
});
if (!res.success || !res.data) {
toast.error(res.message || "등록에 실패했습니다.");
return;
}
toast.success("공정이 등록되었습니다.");
setFormOpen(false);
await loadProcesses();
setSelectedProcess(res.data);
setSelectedIds(new Set());
} else if (editingId) {
const res = await updateProcess(editingId, {
process_name: formProcessName.trim(),
process_type: formProcessType,
standard_time: formStandardTime.trim() || "0",
worker_count: formWorkerCount.trim() || "0",
use_yn: formUseYn,
});
if (!res.success || !res.data) {
toast.error(res.message || "수정에 실패했습니다.");
return;
}
toast.success("공정이 수정되었습니다.");
setFormOpen(false);
await loadProcesses();
setSelectedProcess(res.data);
}
} finally {
setSavingForm(false);
}
};
const openDelete = () => {
if (selectedIds.size === 0) {
toast.message("삭제할 공정을 체크박스로 선택하세요.");
return;
}
setDeleteOpen(true);
};
const confirmDelete = async () => {
const ids = Array.from(selectedIds);
setDeleting(true);
try {
const res = await deleteProcesses(ids);
if (!res.success) {
toast.error(res.message || "삭제에 실패했습니다.");
return;
}
toast.success(`${ids.length}건 삭제되었습니다.`);
setDeleteOpen(false);
setSelectedIds(new Set());
if (selectedProcess && ids.includes(selectedProcess.id)) {
setSelectedProcess(null);
}
await loadProcesses();
} finally {
setDeleting(false);
}
};
const availableEquipments = useMemo(() => {
const used = new Set(processEquipments.map((e) => e.equipment_code));
return equipmentMaster.filter((e) => !used.has(e.equipment_code));
}, [equipmentMaster, processEquipments]);
const handleAddEquipment = async () => {
if (!selectedProcess) return;
if (!equipmentPick) {
toast.message("추가할 설비를 선택하세요.");
return;
}
setAddingEquipment(true);
try {
const res = await addProcessEquipment({
process_code: selectedProcess.process_code,
equipment_code: equipmentPick,
});
if (!res.success) {
toast.error(res.message || "설비 추가에 실패했습니다.");
return;
}
toast.success("설비가 등록되었습니다.");
setEquipmentPick("");
const listRes = await getProcessEquipments(selectedProcess.process_code);
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
} finally {
setAddingEquipment(false);
}
};
const handleRemoveEquipment = async (row: ProcessEquipment) => {
const res = await removeProcessEquipment(row.id);
if (!res.success) {
toast.error(res.message || "설비 제거에 실패했습니다.");
return;
}
toast.success("설비가 제거되었습니다.");
if (selectedProcess) {
const listRes = await getProcessEquipments(selectedProcess.process_code);
if (listRes.success && listRes.data) setProcessEquipments(listRes.data);
}
};
const listBusy = loadingInitial || loadingList;
return (
<div className="flex min-h-[560px] flex-1 flex-col gap-3">
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
<div className="flex shrink-0 flex-col gap-2 border-b bg-muted/30 p-3 sm:p-4">
<div className="flex flex-wrap items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground" aria-hidden />
<span className="text-sm font-semibold sm:text-base"> </span>
</div>
<div className="flex flex-wrap items-end gap-2">
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={filterCode}
onChange={(e) => setFilterCode(e.target.value)}
placeholder="코드"
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[140px] sm:text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Input
value={filterName}
onChange={(e) => setFilterName(e.target.value)}
placeholder="이름"
className="h-8 w-[120px] text-xs sm:h-10 sm:w-[160px] sm:text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="h-8 w-[120px] text-xs sm:h-10 sm:w-[130px] sm:text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
</SelectItem>
{processTypeOptions.map((o, idx) => (
<SelectItem key={`pt-filter-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
{o.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs sm:text-sm"></Label>
<Select value={filterUseYn} onValueChange={setFilterUseYn}>
<SelectTrigger className="h-8 w-[100px] text-xs sm:h-10 sm:w-[110px] sm:text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_VALUE} className="text-xs sm:text-sm">
</SelectItem>
<SelectItem value="Y" className="text-xs sm:text-sm"></SelectItem>
<SelectItem value="N" className="text-xs sm:text-sm"></SelectItem>
</SelectContent>
</Select>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={handleResetFilters}
>
<RotateCcw className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={handleSearch}
disabled={listBusy}
>
<Search className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={openAdd}
>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={openEdit}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="destructive"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={openDelete}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="p-2 sm:p-3">
{listBusy ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="mt-2 text-xs sm:text-sm"> ...</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 text-center">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="전체 선택"
className="mx-auto"
/>
</TableHead>
<TableHead className="text-xs sm:text-sm"></TableHead>
<TableHead className="text-xs sm:text-sm"></TableHead>
<TableHead className="text-xs sm:text-sm"></TableHead>
<TableHead className="text-right text-xs sm:text-sm">()</TableHead>
<TableHead className="text-right text-xs sm:text-sm"></TableHead>
<TableHead className="text-center text-xs sm:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{processes.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center text-muted-foreground">
<p className="text-xs sm:text-sm"> .</p>
</TableCell>
</TableRow>
) : (
processes.map((row) => (
<TableRow
key={row.id}
className={cn(
"cursor-pointer transition-colors",
selectedProcess?.id === row.id && "bg-accent"
)}
onClick={() => setSelectedProcess(row)}
>
<TableCell
className="text-center"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={selectedIds.has(row.id)}
onCheckedChange={(v) => toggleOne(row.id, v === true)}
aria-label={`${row.process_code} 선택`}
className="mx-auto"
/>
</TableCell>
<TableCell className="text-xs font-medium sm:text-sm">
{row.process_code}
</TableCell>
<TableCell className="text-xs sm:text-sm">{row.process_name}</TableCell>
<TableCell className="text-xs sm:text-sm">
<Badge variant="secondary" className="text-[10px] sm:text-xs">
{getProcessTypeLabel(row.process_type)}
</Badge>
</TableCell>
<TableCell className="text-right text-xs sm:text-sm">
{row.standard_time ?? "-"}
</TableCell>
<TableCell className="text-right text-xs sm:text-sm">
{row.worker_count ?? "-"}
</TableCell>
<TableCell className="text-center text-xs sm:text-sm">
<Badge
variant={row.use_yn === "N" ? "outline" : "default"}
className="text-[10px] sm:text-xs"
>
{row.use_yn === "Y" ? "사용" : "미사용"}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</div>
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={50} minSize={30}>
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/30 px-3 py-2 sm:px-4 sm:py-3">
<Wrench className="h-4 w-4 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold sm:text-base"> </p>
{selectedProcess ? (
<p className="truncate text-xs text-muted-foreground sm:text-sm">
{selectedProcess.process_name}{" "}
<span className="text-muted-foreground/80">({selectedProcess.process_code})</span>
</p>
) : (
<p className="text-xs text-muted-foreground sm:text-sm"> </p>
)}
</div>
</div>
{!selectedProcess ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 px-4 py-12 text-center text-muted-foreground">
<Settings className="h-10 w-10 opacity-40" />
<p className="text-sm font-medium text-foreground"> </p>
<p className="max-w-xs text-xs sm:text-sm">
.
</p>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-3 p-3 sm:p-4">
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-0 flex-1 space-y-1 sm:max-w-xs">
<Label className="text-xs sm:text-sm"> </Label>
<Select
key={selectedProcess.id}
value={equipmentPick || undefined}
onValueChange={setEquipmentPick}
disabled={addingEquipment || availableEquipments.length === 0}
>
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="설비를 선택하세요" />
</SelectTrigger>
<SelectContent>
{availableEquipments.map((eq) => (
<SelectItem
key={eq.id}
value={eq.equipment_code}
className="text-xs sm:text-sm"
>
{eq.equipment_code} · {eq.equipment_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
type="button"
size="sm"
className="h-8 text-xs sm:h-10 sm:text-sm"
onClick={() => void handleAddEquipment()}
disabled={addingEquipment || !equipmentPick}
>
{addingEquipment ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="mr-1 h-3.5 w-3.5" />
)}
</Button>
</div>
<div className="min-h-0 flex-1">
{loadingEquipments ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Loader2 className="h-7 w-7 animate-spin" />
<p className="mt-2 text-xs sm:text-sm"> ...</p>
</div>
) : processEquipments.length === 0 ? (
<p className="py-8 text-center text-xs text-muted-foreground sm:text-sm">
. .
</p>
) : (
<ScrollArea className="h-[min(420px,calc(100vh-20rem))] pr-3">
<ul className="space-y-2">
{processEquipments.map((pe) => (
<li key={pe.id}>
<Card className="rounded-lg border bg-card text-card-foreground shadow-sm">
<CardContent className="flex items-center gap-3 p-3 sm:p-4">
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium sm:text-sm">
{pe.equipment_code}
</p>
<p className="truncate text-xs text-muted-foreground sm:text-sm">
{pe.equipment_name || "설비명 없음"}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 shrink-0 text-xs sm:h-9 sm:text-sm"
onClick={() => void handleRemoveEquipment(pe)}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
</Button>
</CardContent>
</Card>
</li>
))}
</ul>
</ScrollArea>
)}
</div>
</div>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
{formMode === "add" ? "공정 추가" : "공정 수정"}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="pm-process-name" className="text-xs sm:text-sm">
<span className="text-destructive">*</span>
</Label>
<Input
id="pm-process-name"
value={formProcessName}
onChange={(e) => setFormProcessName(e.target.value)}
placeholder="공정명"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Select value={formProcessType} onValueChange={setFormProcessType}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{processTypeOptions.map((o, idx) => (
<SelectItem key={`pt-form-${idx}`} value={o.valueCode} className="text-xs sm:text-sm">
{o.valueLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="pm-standard-time" className="text-xs sm:text-sm">
()
</Label>
<Input
id="pm-standard-time"
value={formStandardTime}
onChange={(e) => setFormStandardTime(e.target.value)}
placeholder="0"
inputMode="numeric"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="pm-worker-count" className="text-xs sm:text-sm">
</Label>
<Input
id="pm-worker-count"
value={formWorkerCount}
onChange={(e) => setFormWorkerCount(e.target.value)}
placeholder="0"
inputMode="numeric"
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label className="text-xs sm:text-sm"></Label>
<Select value={formUseYn} onValueChange={setFormUseYn}>
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Y" className="text-xs sm:text-sm"></SelectItem>
<SelectItem value="N" className="text-xs sm:text-sm"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => setFormOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={savingForm}
>
</Button>
<Button
type="button"
onClick={() => void submitForm()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={savingForm}
>
{savingForm ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
{selectedIds.size} . - .
.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
onClick={() => setDeleteOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={deleting}
>
</Button>
<Button
type="button"
variant="destructive"
onClick={() => void confirmDelete()}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={deleting}
>
{deleting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,17 @@
"use client";
import { ProcessWorkStandardComponent } from "@/lib/registry/components/v2-process-work-standard/ProcessWorkStandardComponent";
export function ProcessWorkStandardTab() {
return (
<div className="h-[calc(100vh-12rem)]">
<ProcessWorkStandardComponent
config={{
itemListMode: "registered",
screenCode: "screen_1599",
leftPanelTitle: "등록 품목 및 공정",
}}
/>
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import React, { useState } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Settings, GitBranch, ClipboardList } from "lucide-react";
import { ProcessMasterTab } from "./ProcessMasterTab";
import { ItemRoutingTab } from "./ItemRoutingTab";
import { ProcessWorkStandardTab } from "./ProcessWorkStandardTab";
export default function ProcessInfoPage() {
const [activeTab, setActiveTab] = useState("process");
return (
<div className="flex h-[calc(100vh-4rem)] flex-col bg-muted/30">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex h-full flex-col">
<div className="shrink-0 border-b bg-background px-4">
<TabsList className="h-12 bg-transparent gap-1">
<TabsTrigger
value="process"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
>
<Settings className="mr-2 h-4 w-4" />
</TabsTrigger>
<TabsTrigger
value="routing"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
>
<GitBranch className="mr-2 h-4 w-4" />
</TabsTrigger>
<TabsTrigger
value="workstandard"
className="data-[state=active]:border-b-2 data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none rounded-none px-4"
>
<ClipboardList className="mr-2 h-4 w-4" />
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="process" className="flex-1 overflow-hidden mt-0">
<ProcessMasterTab />
</TabsContent>
<TabsContent value="routing" className="flex-1 overflow-hidden mt-0">
<ItemRoutingTab />
</TabsContent>
<TabsContent value="workstandard" className="flex-1 overflow-hidden mt-0">
<ProcessWorkStandardTab />
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,539 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils";
import {
Loader2, Save, RotateCcw, Plus, Trash2, Pencil, ClipboardCheck,
ChevronRight, GripVertical, AlertCircle,
} from "lucide-react";
import { toast } from "sonner";
import {
getWIWorkStandard, copyWorkStandard, saveWIWorkStandard, resetWIWorkStandard,
WIWorkItem, WIWorkItemDetail, WIWorkStandardProcess,
} from "@/lib/api/workInstruction";
interface WorkStandardEditModalProps {
open: boolean;
onClose: () => void;
workInstructionNo: string;
routingVersionId: string;
routingName: string;
itemName: string;
itemCode: string;
}
const PHASES = [
{ key: "PRE", label: "사전작업" },
{ key: "MAIN", label: "본작업" },
{ key: "POST", label: "후작업" },
];
const DETAIL_TYPES = [
{ value: "checklist", label: "체크리스트" },
{ value: "inspection", label: "검사항목" },
{ value: "procedure", label: "작업절차" },
{ value: "input", label: "직접입력" },
{ value: "lookup", label: "문서참조" },
{ value: "equip_inspection", label: "설비점검" },
{ value: "equip_condition", label: "설비조건" },
{ value: "production_result", label: "실적등록" },
{ value: "material_input", label: "자재투입" },
];
export function WorkStandardEditModal({
open,
onClose,
workInstructionNo,
routingVersionId,
routingName,
itemName,
itemCode,
}: WorkStandardEditModalProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [processes, setProcesses] = useState<WIWorkStandardProcess[]>([]);
const [isCustom, setIsCustom] = useState(false);
const [selectedProcessIdx, setSelectedProcessIdx] = useState(0);
const [selectedPhase, setSelectedPhase] = useState("PRE");
const [selectedWorkItemId, setSelectedWorkItemId] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
// 작업항목 추가 모달
const [addItemOpen, setAddItemOpen] = useState(false);
const [addItemTitle, setAddItemTitle] = useState("");
const [addItemRequired, setAddItemRequired] = useState("Y");
// 상세 추가 모달
const [addDetailOpen, setAddDetailOpen] = useState(false);
const [addDetailType, setAddDetailType] = useState("checklist");
const [addDetailContent, setAddDetailContent] = useState("");
const [addDetailRequired, setAddDetailRequired] = useState("N");
// 데이터 로드
const loadData = useCallback(async () => {
if (!workInstructionNo || !routingVersionId) return;
setLoading(true);
try {
const res = await getWIWorkStandard(workInstructionNo, routingVersionId);
if (res.success && res.data) {
setProcesses(res.data.processes);
setIsCustom(res.data.isCustom);
setSelectedProcessIdx(0);
setSelectedPhase("PRE");
setSelectedWorkItemId(null);
setDirty(false);
}
} catch (err) {
console.error("공정작업기준 로드 실패", err);
} finally {
setLoading(false);
}
}, [workInstructionNo, routingVersionId]);
useEffect(() => {
if (open) loadData();
}, [open, loadData]);
const currentProcess = processes[selectedProcessIdx] || null;
const currentWorkItems = useMemo(() => {
if (!currentProcess) return [];
return currentProcess.workItems.filter(wi => wi.work_phase === selectedPhase);
}, [currentProcess, selectedPhase]);
const selectedWorkItem = useMemo(() => {
if (!selectedWorkItemId || !currentProcess) return null;
return currentProcess.workItems.find(wi => wi.id === selectedWorkItemId) || null;
}, [selectedWorkItemId, currentProcess]);
// 커스텀 복사 확인 후 수정
const ensureCustom = useCallback(async () => {
if (isCustom) return true;
try {
const res = await copyWorkStandard(workInstructionNo, routingVersionId);
if (res.success) {
await loadData();
setIsCustom(true);
return true;
}
} catch (err) {
toast.error("원본 복사에 실패했습니다");
}
return false;
}, [isCustom, workInstructionNo, routingVersionId, loadData]);
// 작업항목 추가
const handleAddWorkItem = useCallback(async () => {
if (!addItemTitle.trim()) { toast.error("제목을 입력하세요"); return; }
const ok = await ensureCustom();
if (!ok || !currentProcess) return;
const newItem: WIWorkItem = {
id: `temp-${Date.now()}`,
routing_detail_id: currentProcess.routing_detail_id,
work_phase: selectedPhase,
title: addItemTitle.trim(),
is_required: addItemRequired,
sort_order: currentWorkItems.length + 1,
details: [],
};
setProcesses(prev => {
const next = [...prev];
next[selectedProcessIdx] = {
...next[selectedProcessIdx],
workItems: [...next[selectedProcessIdx].workItems, newItem],
};
return next;
});
setAddItemTitle("");
setAddItemRequired("Y");
setAddItemOpen(false);
setDirty(true);
setSelectedWorkItemId(newItem.id!);
}, [addItemTitle, addItemRequired, ensureCustom, currentProcess, selectedPhase, currentWorkItems, selectedProcessIdx]);
// 작업항목 삭제
const handleDeleteWorkItem = useCallback(async (id: string) => {
const ok = await ensureCustom();
if (!ok) return;
setProcesses(prev => {
const next = [...prev];
next[selectedProcessIdx] = {
...next[selectedProcessIdx],
workItems: next[selectedProcessIdx].workItems.filter(wi => wi.id !== id),
};
return next;
});
if (selectedWorkItemId === id) setSelectedWorkItemId(null);
setDirty(true);
}, [ensureCustom, selectedProcessIdx, selectedWorkItemId]);
// 상세 추가
const handleAddDetail = useCallback(async () => {
if (!addDetailContent.trim() && addDetailType !== "production_result" && addDetailType !== "material_input") {
toast.error("내용을 입력하세요");
return;
}
if (!selectedWorkItemId) return;
const ok = await ensureCustom();
if (!ok) return;
const content = addDetailContent.trim() ||
DETAIL_TYPES.find(d => d.value === addDetailType)?.label || addDetailType;
const newDetail: WIWorkItemDetail = {
id: `temp-detail-${Date.now()}`,
work_item_id: selectedWorkItemId,
detail_type: addDetailType,
content,
is_required: addDetailRequired,
sort_order: (selectedWorkItem?.details?.length || 0) + 1,
};
setProcesses(prev => {
const next = [...prev];
const workItems = [...next[selectedProcessIdx].workItems];
const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId);
if (wiIdx >= 0) {
workItems[wiIdx] = {
...workItems[wiIdx],
details: [...(workItems[wiIdx].details || []), newDetail],
detail_count: (workItems[wiIdx].detail_count || 0) + 1,
};
}
next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems };
return next;
});
setAddDetailContent("");
setAddDetailType("checklist");
setAddDetailRequired("N");
setAddDetailOpen(false);
setDirty(true);
}, [addDetailContent, addDetailType, addDetailRequired, selectedWorkItemId, selectedWorkItem, ensureCustom, selectedProcessIdx]);
// 상세 삭제
const handleDeleteDetail = useCallback(async (detailId: string) => {
if (!selectedWorkItemId) return;
const ok = await ensureCustom();
if (!ok) return;
setProcesses(prev => {
const next = [...prev];
const workItems = [...next[selectedProcessIdx].workItems];
const wiIdx = workItems.findIndex(wi => wi.id === selectedWorkItemId);
if (wiIdx >= 0) {
workItems[wiIdx] = {
...workItems[wiIdx],
details: (workItems[wiIdx].details || []).filter(d => d.id !== detailId),
detail_count: Math.max(0, (workItems[wiIdx].detail_count || 1) - 1),
};
}
next[selectedProcessIdx] = { ...next[selectedProcessIdx], workItems };
return next;
});
setDirty(true);
}, [selectedWorkItemId, ensureCustom, selectedProcessIdx]);
// 저장
const handleSave = useCallback(async () => {
if (!currentProcess) return;
setSaving(true);
try {
const ok = await ensureCustom();
if (!ok) return;
const res = await saveWIWorkStandard(
workInstructionNo,
currentProcess.routing_detail_id,
currentProcess.workItems
);
if (res.success) {
toast.success("공정작업기준이 저장되었습니다");
setDirty(false);
await loadData();
} else {
toast.error("저장에 실패했습니다");
}
} catch (err) {
toast.error("저장 중 오류가 발생했습니다");
} finally {
setSaving(false);
}
}, [currentProcess, ensureCustom, workInstructionNo, loadData]);
// 원본으로 초기화
const handleReset = useCallback(async () => {
if (!confirm("커스터마이징한 내용을 모두 삭제하고 원본으로 되돌리시겠습니까?")) return;
try {
const res = await resetWIWorkStandard(workInstructionNo);
if (res.success) {
toast.success("원본으로 초기화되었습니다");
await loadData();
}
} catch (err) {
toast.error("초기화에 실패했습니다");
}
}, [workInstructionNo, loadData]);
const getDetailTypeLabel = (type: string) =>
DETAIL_TYPES.find(d => d.value === type)?.label || type;
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] h-[85vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2">
<ClipboardCheck className="w-4 h-4" />
- {itemName}
{routingName && <Badge variant="secondary" className="text-xs ml-2">{routingName}</Badge>}
{isCustom && <Badge variant="outline" className="text-xs ml-1 border-amber-300 text-amber-700"></Badge>}
</DialogTitle>
<DialogDescription className="text-xs">
[{workInstructionNo}] . .
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
</div>
) : processes.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<AlertCircle className="w-10 h-10 mb-3 opacity-30" />
<p className="text-sm"> </p>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* 공정 탭 */}
<div className="flex items-center gap-1 px-4 py-2 border-b bg-muted/30 overflow-x-auto shrink-0">
{processes.map((proc, idx) => (
<Button
key={proc.routing_detail_id}
variant={selectedProcessIdx === idx ? "default" : "ghost"}
size="sm"
className={cn("text-xs shrink-0 h-8", selectedProcessIdx === idx && "shadow-sm")}
onClick={() => {
setSelectedProcessIdx(idx);
setSelectedWorkItemId(null);
}}
>
<span className="mr-1.5 font-mono text-[10px] opacity-70">{proc.seq_no}.</span>
{proc.process_name}
{proc.workItems.length > 0 && (
<Badge variant="secondary" className="ml-1.5 text-[10px] h-4 px-1">{proc.workItems.length}</Badge>
)}
</Button>
))}
</div>
{/* 작업 단계 탭 */}
<div className="flex items-center gap-1 px-4 py-2 border-b shrink-0">
{PHASES.map(phase => {
const count = currentProcess?.workItems.filter(wi => wi.work_phase === phase.key).length || 0;
return (
<Button
key={phase.key}
variant={selectedPhase === phase.key ? "secondary" : "ghost"}
size="sm"
className="text-xs h-7"
onClick={() => { setSelectedPhase(phase.key); setSelectedWorkItemId(null); }}
>
{phase.label}
{count > 0 && <Badge variant="outline" className="ml-1 text-[10px] h-4 px-1">{count}</Badge>}
</Button>
);
})}
</div>
{/* 작업항목 + 상세 split */}
<div className="flex-1 flex overflow-hidden">
{/* 좌측: 작업항목 목록 */}
<div className="w-[280px] shrink-0 border-r flex flex-col overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/20 shrink-0">
<span className="text-xs font-semibold"></span>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => { setAddItemTitle(""); setAddItemOpen(true); }}>
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{currentWorkItems.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-6"> </div>
) : currentWorkItems.map((wi) => (
<div
key={wi.id}
className={cn(
"group rounded-md border p-2.5 cursor-pointer transition-colors",
selectedWorkItemId === wi.id ? "border-primary bg-primary/5" : "hover:bg-muted/50"
)}
onClick={() => setSelectedWorkItemId(wi.id!)}
>
<div className="flex items-start justify-between gap-1">
<div className="min-w-0 flex-1">
<div className="text-xs font-medium truncate">{wi.title}</div>
<div className="flex items-center gap-1.5 mt-1">
{wi.is_required === "Y" && <Badge variant="destructive" className="text-[9px] h-4 px-1"></Badge>}
<span className="text-[10px] text-muted-foreground"> {wi.details?.length || wi.detail_count || 0}</span>
</div>
</div>
<Button
variant="ghost" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
onClick={e => { e.stopPropagation(); handleDeleteWorkItem(wi.id!); }}
>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</div>
{/* 우측: 상세 목록 */}
<div className="flex-1 flex flex-col overflow-hidden">
{!selectedWorkItem ? (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<ChevronRight className="w-8 h-8 mb-2 opacity-20" />
<p className="text-xs"> </p>
</div>
) : (
<>
<div className="flex items-center justify-between px-4 py-2 border-b bg-muted/20 shrink-0">
<div>
<span className="text-xs font-semibold">{selectedWorkItem.title}</span>
<span className="text-[10px] text-muted-foreground ml-2"> </span>
</div>
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => { setAddDetailContent(""); setAddDetailType("checklist"); setAddDetailOpen(true); }}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{(!selectedWorkItem.details || selectedWorkItem.details.length === 0) ? (
<div className="text-xs text-muted-foreground text-center py-8"> </div>
) : selectedWorkItem.details.map((detail, dIdx) => (
<div key={detail.id || dIdx} className="group flex items-start gap-2 rounded-md border p-3 hover:bg-muted/30">
<GripVertical className="w-3.5 h-3.5 mt-0.5 text-muted-foreground/30 shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-[10px] h-4 px-1.5 shrink-0">
{getDetailTypeLabel(detail.detail_type || "checklist")}
</Badge>
{detail.is_required === "Y" && <Badge variant="destructive" className="text-[9px] h-4 px-1"></Badge>}
</div>
<p className="text-xs mt-1 break-all">{detail.content || "-"}</p>
{detail.remark && <p className="text-[10px] text-muted-foreground mt-0.5">{detail.remark}</p>}
{detail.detail_type === "inspection" && (detail.lower_limit || detail.upper_limit) && (
<div className="text-[10px] text-muted-foreground mt-1">
: {detail.lower_limit || "-"} ~ {detail.upper_limit || "-"} {detail.unit || ""}
</div>
)}
</div>
<Button
variant="ghost" size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
onClick={() => handleDeleteDetail(detail.id!)}
>
<Trash2 className="w-3 h-3 text-destructive" />
</Button>
</div>
))}
</div>
</>
)}
</div>
</div>
</div>
)}
<DialogFooter className="px-6 py-3 border-t shrink-0 flex items-center justify-between">
<div>
{isCustom && (
<Button variant="outline" size="sm" className="text-xs" onClick={handleReset}>
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
</Button>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onClose}></Button>
<Button onClick={handleSave} disabled={saving || (!dirty && isCustom)}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</div>
</DialogFooter>
{/* 작업항목 추가 다이얼로그 */}
<Dialog open={addItemOpen} onOpenChange={setAddItemOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[400px]" onClick={e => e.stopPropagation()}>
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs">
{PHASES.find(p => p.key === selectedPhase)?.label} .
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs"> *</Label>
<Input value={addItemTitle} onChange={e => setAddItemTitle(e.target.value)} placeholder="작업항목 제목" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addItemRequired === "Y"} onCheckedChange={v => setAddItemRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddItemOpen(false)}></Button>
<Button size="sm" onClick={handleAddWorkItem}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 상세 추가 다이얼로그 */}
<Dialog open={addDetailOpen} onOpenChange={setAddDetailOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[450px]" onClick={e => e.stopPropagation()}>
<DialogHeader>
<DialogTitle className="text-base"> </DialogTitle>
<DialogDescription className="text-xs">
"{selectedWorkItem?.title}" .
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs"></Label>
<Select value={addDetailType} onValueChange={setAddDetailType}>
<SelectTrigger className="h-8 text-xs mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{DETAIL_TYPES.map(dt => (
<SelectItem key={dt.value} value={dt.value} className="text-xs">{dt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Input value={addDetailContent} onChange={e => setAddDetailContent(e.target.value)} placeholder="상세 내용 입력" className="h-8 text-xs mt-1" />
</div>
<div className="flex items-center gap-2">
<Checkbox checked={addDetailRequired === "Y"} onCheckedChange={v => setAddDetailRequired(v ? "Y" : "N")} />
<Label className="text-xs"> </Label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={() => setAddDetailOpen(false)}></Button>
<Button size="sm" onClick={handleAddDetail}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,782 @@
"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 { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Plus, Trash2, RotateCcw, Save, X, ChevronLeft, ChevronRight, Search, Loader2, Wrench, Pencil, CheckCircle2, ArrowRight, Check, ChevronsUpDown, ClipboardCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
getWorkInstructionList, previewWorkInstructionNo, saveWorkInstruction, deleteWorkInstructions,
getWIItemSource, getWISalesOrderSource, getWIProductionPlanSource, getEquipmentList, getEmployeeList,
getRoutingVersions, RoutingVersionData,
} from "@/lib/api/workInstruction";
import { WorkStandardEditModal } from "./WorkStandardEditModal";
type SourceType = "production" | "order" | "item";
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
"일반": { label: "일반", cls: "bg-blue-100 text-blue-800 border-blue-200" },
"긴급": { label: "긴급", cls: "bg-red-100 text-red-800 border-red-200" },
};
const PROGRESS_BADGE: Record<string, { label: string; cls: string }> = {
"대기": { label: "대기", cls: "bg-amber-100 text-amber-800" },
"진행중": { label: "진행중", cls: "bg-blue-100 text-blue-800" },
"완료": { label: "완료", cls: "bg-emerald-100 text-emerald-800" },
};
interface EquipmentOption { id: string; equipment_code: string; equipment_name: string; }
interface EmployeeOption { user_id: string; user_name: string; dept_name: string | null; }
interface SelectedItem {
itemCode: string; itemName: string; spec: string; qty: number; remark: string;
sourceType: SourceType; sourceTable: string; sourceId: string | number;
}
export default function WorkInstructionPage() {
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [equipmentOptions, setEquipmentOptions] = useState<EquipmentOption[]>([]);
const [employeeOptions, setEmployeeOptions] = useState<EmployeeOption[]>([]);
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
const [searchStatus, setSearchStatus] = useState("all");
const [searchProgress, setSearchProgress] = useState("all");
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
// 1단계: 등록 모달
const [isRegModalOpen, setIsRegModalOpen] = useState(false);
const [regSourceType, setRegSourceType] = useState<SourceType | "">("");
const [regSourceData, setRegSourceData] = useState<any[]>([]);
const [regSourceLoading, setRegSourceLoading] = useState(false);
const [regKeyword, setRegKeyword] = useState("");
const [regCheckedIds, setRegCheckedIds] = useState<Set<string>>(new Set());
const [regMergeSameItem, setRegMergeSameItem] = useState(true);
const [regPage, setRegPage] = useState(1);
const [regPageSize] = useState(20);
const [regTotalCount, setRegTotalCount] = useState(0);
// 2단계: 확인 모달
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const [confirmItems, setConfirmItems] = useState<SelectedItem[]>([]);
const [confirmWiNo, setConfirmWiNo] = useState("");
const [confirmStatus, setConfirmStatus] = useState("일반");
const [confirmStartDate, setConfirmStartDate] = useState("");
const [confirmEndDate, setConfirmEndDate] = useState("");
const nv = (v: string) => v || "none";
const fromNv = (v: string) => v === "none" ? "" : v;
const [confirmEquipmentId, setConfirmEquipmentId] = useState("");
const [confirmWorkTeam, setConfirmWorkTeam] = useState("");
const [confirmWorker, setConfirmWorker] = useState("");
const [saving, setSaving] = useState(false);
// 수정 모달
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editOrder, setEditOrder] = useState<any>(null);
const [editItems, setEditItems] = useState<SelectedItem[]>([]);
const [editStatus, setEditStatus] = useState("일반");
const [editStartDate, setEditStartDate] = useState("");
const [editEndDate, setEditEndDate] = useState("");
const [editEquipmentId, setEditEquipmentId] = useState("");
const [editWorkTeam, setEditWorkTeam] = useState("");
const [editWorker, setEditWorker] = useState("");
const [editRemark, setEditRemark] = useState("");
const [editSaving, setEditSaving] = useState(false);
const [addQty, setAddQty] = useState("");
const [addEquipment, setAddEquipment] = useState("");
const [addWorkTeam, setAddWorkTeam] = useState("");
const [addWorker, setAddWorker] = useState("");
const [confirmWorkerOpen, setConfirmWorkerOpen] = useState(false);
const [editWorkerOpen, setEditWorkerOpen] = useState(false);
const [addWorkerOpen, setAddWorkerOpen] = useState(false);
// 라우팅 관련 상태
const [confirmRouting, setConfirmRouting] = useState("");
const [confirmRoutingOptions, setConfirmRoutingOptions] = useState<RoutingVersionData[]>([]);
const [editRouting, setEditRouting] = useState("");
const [editRoutingOptions, setEditRoutingOptions] = useState<RoutingVersionData[]>([]);
// 공정작업기준 모달 상태
const [wsModalOpen, setWsModalOpen] = useState(false);
const [wsModalWiNo, setWsModalWiNo] = useState("");
const [wsModalRoutingId, setWsModalRoutingId] = useState("");
const [wsModalRoutingName, setWsModalRoutingName] = useState("");
const [wsModalItemName, setWsModalItemName] = useState("");
const [wsModalItemCode, setWsModalItemCode] = useState("");
useEffect(() => { const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500); return () => clearTimeout(t); }, [searchKeyword]);
useEffect(() => {
getEquipmentList().then(r => { if (r.success) setEquipmentOptions(r.data || []); });
getEmployeeList().then(r => { if (r.success) setEmployeeOptions(r.data || []); });
}, []);
const fetchOrders = 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 (searchProgress !== "all") params.progressStatus = searchProgress;
if (debouncedKeyword.trim()) params.keyword = debouncedKeyword.trim();
const r = await getWorkInstructionList(params);
if (r.success) setOrders(r.data || []);
} catch {} finally { setLoading(false); }
}, [searchDateFrom, searchDateTo, searchStatus, searchProgress, debouncedKeyword]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
const handleResetSearch = () => {
setSearchKeyword(""); setDebouncedKeyword(""); setSearchStatus("all"); setSearchProgress("all");
setSearchDateFrom(""); setSearchDateTo("");
};
// ─── 1단계 등록 ───
const openRegModal = () => {
setRegSourceType("production"); setRegSourceData([]); setRegKeyword(""); setRegCheckedIds(new Set());
setRegPage(1); setRegTotalCount(0); setRegMergeSameItem(true); setIsRegModalOpen(true);
};
const fetchRegSource = useCallback(async (pageOverride?: number) => {
if (!regSourceType) return;
setRegSourceLoading(true);
try {
const p = pageOverride ?? regPage;
const params: any = { page: p, pageSize: regPageSize };
if (regKeyword.trim()) params.keyword = regKeyword.trim();
let r;
switch (regSourceType) {
case "production": r = await getWIProductionPlanSource(params); break;
case "order": r = await getWISalesOrderSource(params); break;
case "item": r = await getWIItemSource(params); break;
}
if (r?.success) { setRegSourceData(r.data || []); setRegTotalCount(r.totalCount || 0); }
} catch {} finally { setRegSourceLoading(false); }
}, [regSourceType, regKeyword, regPage, regPageSize]);
useEffect(() => { if (isRegModalOpen && regSourceType) { setRegPage(1); setRegCheckedIds(new Set()); fetchRegSource(1); } }, [regSourceType]);
const getRegId = (item: any) => regSourceType === "item" ? (item.item_code || item.id) : String(item.id);
const toggleRegItem = (id: string) => { setRegCheckedIds(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); };
const toggleRegAll = () => { if (regCheckedIds.size === regSourceData.length) setRegCheckedIds(new Set()); else setRegCheckedIds(new Set(regSourceData.map(getRegId))); };
const applyRegistration = () => {
if (regCheckedIds.size === 0) { alert("품목을 선택해주세요."); return; }
const items: SelectedItem[] = [];
for (const item of regSourceData) {
if (!regCheckedIds.has(getRegId(item))) continue;
if (regSourceType === "item") items.push({ itemCode: item.item_code, itemName: item.item_name || "", spec: item.spec || "", qty: 1, remark: "", sourceType: "item", sourceTable: "item_info", sourceId: item.item_code });
else if (regSourceType === "order") items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: item.spec || "", qty: Number(item.qty || 1), remark: "", sourceType: "order", sourceTable: "sales_order_detail", sourceId: item.id });
else items.push({ itemCode: item.item_code || "", itemName: item.item_name || "", spec: "", qty: Number(item.plan_qty || 1), remark: "", sourceType: "production", sourceTable: "production_plan_mng", sourceId: item.id });
}
// 동일품목 합산
if (regMergeSameItem) {
const merged = new Map<string, SelectedItem>();
for (const it of items) {
const key = it.itemCode;
if (merged.has(key)) { merged.get(key)!.qty += it.qty; }
else { merged.set(key, { ...it }); }
}
setConfirmItems(Array.from(merged.values()));
} else {
setConfirmItems(items);
}
setConfirmWiNo("불러오는 중...");
setConfirmStatus("일반"); setConfirmStartDate(new Date().toISOString().split("T")[0]);
setConfirmEndDate(""); setConfirmEquipmentId(""); setConfirmWorkTeam(""); setConfirmWorker("");
setConfirmRouting(""); setConfirmRoutingOptions([]);
previewWorkInstructionNo().then(r => { if (r.success) setConfirmWiNo(r.instructionNo); else setConfirmWiNo("(자동생성)"); }).catch(() => setConfirmWiNo("(자동생성)"));
// 첫 번째 품목의 라우팅 로드
const firstItem = items.length > 0 ? items[0] : null;
if (firstItem) {
getRoutingVersions("__new__", firstItem.itemCode).then(r => {
if (r.success && r.data) {
setConfirmRoutingOptions(r.data);
const defaultRouting = r.data.find(rv => rv.is_default);
if (defaultRouting) setConfirmRouting(defaultRouting.id);
}
}).catch(() => {});
}
setIsRegModalOpen(false); setIsConfirmModalOpen(true);
};
// ─── 2단계 최종 적용 ───
const finalizeRegistration = async () => {
if (confirmItems.length === 0) { alert("품목이 없습니다."); return; }
setSaving(true);
try {
const payload = {
status: confirmStatus, startDate: confirmStartDate, endDate: confirmEndDate,
equipmentId: confirmEquipmentId, workTeam: confirmWorkTeam, worker: confirmWorker,
routing: confirmRouting || null,
items: confirmItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
};
const r = await saveWorkInstruction(payload);
if (r.success) { setIsConfirmModalOpen(false); fetchOrders(); alert("작업지시가 등록되었습니다."); }
else alert(r.message || "저장 실패");
} catch (e: any) { alert(e.message || "저장 실패"); } finally { setSaving(false); }
};
// ─── 수정 모달 ───
const openEditModal = (order: any) => {
const wiNo = order.work_instruction_no;
const relatedDetails = orders.filter(o => o.work_instruction_no === wiNo);
setEditOrder(order); setEditStatus(order.status || "일반");
setEditStartDate(order.start_date || ""); setEditEndDate(order.end_date || "");
setEditEquipmentId(order.equipment_id || ""); setEditWorkTeam(order.work_team || "");
setEditWorker(order.worker || ""); setEditRemark(order.wi_remark || "");
setEditItems(relatedDetails.map((d: any) => ({
itemCode: d.item_number || d.part_code || "", itemName: d.item_name || "", spec: d.item_spec || "",
qty: Number(d.detail_qty || 0), remark: d.detail_remark || "",
sourceType: (d.source_table === "sales_order_detail" ? "order" : d.source_table === "production_plan_mng" ? "production" : "item") as SourceType,
sourceTable: d.source_table || "item_info", sourceId: d.source_id || "",
})));
setAddQty(""); setAddEquipment(""); setAddWorkTeam(""); setAddWorker("");
setEditRouting(order.routing_version_id || "");
setEditRoutingOptions([]);
// 라우팅 옵션 로드
const itemCode = order.item_number || order.part_code || "";
if (itemCode) {
getRoutingVersions(wiNo, itemCode).then(r => {
if (r.success && r.data) setEditRoutingOptions(r.data);
}).catch(() => {});
}
setIsEditModalOpen(true);
};
const addEditItem = () => {
if (!addQty || Number(addQty) <= 0) { alert("수량을 입력해주세요."); return; }
setEditItems(prev => [...prev, {
itemCode: editOrder?.item_number || "", itemName: editOrder?.item_name || "", spec: editOrder?.item_spec || "",
qty: Number(addQty), remark: "", sourceType: "item", sourceTable: "item_info", sourceId: editOrder?.item_number || "",
}]);
setAddQty("");
};
const saveEdit = async () => {
if (!editOrder || editItems.length === 0) { alert("품목이 없습니다."); return; }
setEditSaving(true);
try {
const payload = {
id: editOrder.wi_id, status: editStatus, startDate: editStartDate, endDate: editEndDate,
equipmentId: editEquipmentId, workTeam: editWorkTeam, worker: editWorker, remark: editRemark,
routing: editRouting || null,
items: editItems.map(i => ({ itemNumber: i.itemCode, itemCode: i.itemCode, qty: String(i.qty), remark: i.remark, sourceTable: i.sourceTable, sourceId: String(i.sourceId), partCode: i.itemCode })),
};
const r = await saveWorkInstruction(payload);
if (r.success) { setIsEditModalOpen(false); fetchOrders(); alert("수정되었습니다."); }
else alert(r.message || "저장 실패");
} catch (e: any) { alert(e.message || "저장 실패"); } finally { setEditSaving(false); }
};
const handleDelete = async (wiId: string) => {
if (!confirm("이 작업지시를 삭제하시겠습니까?")) return;
const r = await deleteWorkInstructions([wiId]);
if (r.success) { fetchOrders(); } else alert(r.message || "삭제 실패");
};
const getProgress = (o: any) => {
const t = Number(o.total_qty || 0), c = Number(o.completed_qty || 0);
return t === 0 ? 0 : Math.min(100, Math.round((c / t) * 100));
};
const getProgressLabel = (o: any) => { const p = getProgress(o); if (o.progress_status) return o.progress_status; if (p >= 100) return "완료"; if (p > 0) return "진행중"; return "대기"; };
const totalRegPages = Math.max(1, Math.ceil(regTotalCount / regPageSize));
const getDisplayNo = (o: any) => {
const cnt = Number(o.detail_count || 1);
const seq = Number(o.detail_seq || 1);
if (cnt <= 1) return o.work_instruction_no || "-";
return `${o.work_instruction_no}-${String(seq).padStart(2, "0")}`;
};
const openWorkStandardModal = (wiNo: string, routingVersionId: string, routingName: string, itemName: string, itemCode: string) => {
if (!routingVersionId) { alert("라우팅이 선택되지 않았습니다."); return; }
setWsModalWiNo(wiNo);
setWsModalRoutingId(routingVersionId);
setWsModalRoutingName(routingName);
setWsModalItemName(itemName);
setWsModalItemCode(itemCode);
setWsModalOpen(true);
};
const getWorkerName = (userId: string) => {
if (!userId) return "-";
const emp = employeeOptions.find(e => e.user_id === userId);
return emp ? emp.user_name : userId;
};
const WorkerCombobox = ({ value, onChange, open, onOpenChange, className, triggerClassName }: {
value: string; onChange: (v: string) => void; open: boolean; onOpenChange: (v: boolean) => void;
className?: string; triggerClassName?: string;
}) => (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}
className={cn("w-full justify-between font-normal", triggerClassName || "h-9 text-sm")}>
{value ? (employeeOptions.find(e => e.user_id === value)?.user_name || value) : "작업자 선택"}
<ChevronsUpDown className="ml-2 h-3.5 w-3.5 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="이름 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-4 text-center"> </CommandEmpty>
<CommandGroup>
<CommandItem value="__none__" onSelect={() => { onChange(""); onOpenChange(false); }} className="text-xs">
<Check className={cn("mr-2 h-3.5 w-3.5", !value ? "opacity-100" : "opacity-0")} />
</CommandItem>
{employeeOptions.map(emp => (
<CommandItem key={emp.user_id} value={`${emp.user_name} ${emp.user_id}`}
onSelect={() => { onChange(emp.user_id); onOpenChange(false); }} className="text-xs">
<Check className={cn("mr-2 h-3.5 w-3.5", value === emp.user_id ? "opacity-100" : "opacity-0")} />
{emp.user_name}{emp.dept_name ? ` (${emp.dept_name})` : ""}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
return (
<div className="flex flex-col h-full gap-4 p-4">
{/* 검색 */}
<Card>
<CardContent className="p-4">
<div className="flex flex-wrap items-end gap-4">
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<div className="flex items-center gap-2">
<div className="w-[150px]"><FormDatePicker value={searchDateFrom} onChange={setSearchDateFrom} placeholder="시작일" /></div>
<span className="text-muted-foreground">~</span>
<div className="w-[150px]"><FormDatePicker value={searchDateTo} onChange={setSearchDateTo} placeholder="종료일" /></div>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input placeholder="작업지시번호/품목명" value={searchKeyword} onChange={e => setSearchKeyword(e.target.value)} className="h-9 w-[200px]" />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="h-9 w-[120px]"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="all"></SelectItem><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchProgress} onValueChange={setSearchProgress}>
<SelectTrigger className="h-9 w-[130px]"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="all"></SelectItem><SelectItem value="대기"></SelectItem><SelectItem value="진행중"></SelectItem><SelectItem value="완료"></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-1.5" /> </Button>
</div>
</div>
</CardContent>
</Card>
{/* 메인 테이블 */}
<Card className="flex-1 flex flex-col overflow-hidden">
<CardContent className="p-0 flex flex-col flex-1 overflow-hidden">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Wrench className="w-4 h-4" />
<Badge variant="secondary" className="text-xs">{new Set(orders.map(o => o.work_instruction_no)).size} ({orders.length})</Badge>
</h3>
<Button size="sm" onClick={openRegModal}><Plus className="w-4 h-4 mr-1.5" /> </Button>
</div>
<div className="flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[70px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[150px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow><TableCell colSpan={13} className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
) : orders.length === 0 ? (
<TableRow><TableCell colSpan={13} className="text-center py-12 text-muted-foreground"> </TableCell></TableRow>
) : orders.map((o, rowIdx) => {
const pct = getProgress(o);
const pLabel = getProgressLabel(o);
const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"];
const sBadge = STATUS_BADGE[o.status] || STATUS_BADGE["일반"];
const isFirstOfGroup = Number(o.detail_seq) === 1;
return (
<TableRow key={`${o.wi_id}-${o.detail_id}`} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs font-medium">{getDisplayNo(o)}</TableCell>
<TableCell className="text-center"><Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge></TableCell>
<TableCell className="text-center">
{isFirstOfGroup ? (
<div className="flex flex-col items-center gap-1">
<Badge variant="secondary" className={cn("text-[10px]", pBadge.cls)}>{pBadge.label}</Badge>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all", pct >= 100 ? "bg-emerald-500" : pct > 0 ? "bg-blue-500" : "bg-gray-300")} style={{ width: `${pct}%` }} />
</div>
<span className="text-[10px] text-muted-foreground">{pct}%</span>
</div>
) : <span className="text-[10px] text-muted-foreground"></span>}
</TableCell>
<TableCell className="text-sm">{o.item_name || o.item_number || "-"}</TableCell>
<TableCell className="text-xs">{o.item_spec || "-"}</TableCell>
<TableCell className="text-right text-xs font-medium">{Number(o.detail_qty || 0).toLocaleString()}</TableCell>
<TableCell className="text-xs">{isFirstOfGroup ? (o.equipment_name || "-") : ""}</TableCell>
<TableCell className="text-xs">
{isFirstOfGroup ? (
o.routing_version_id ? (
<button
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer text-xs text-left"
onClick={e => {
e.stopPropagation();
openWorkStandardModal(
o.work_instruction_no,
o.routing_version_id,
o.routing_name || "",
o.item_name || o.item_number || "",
o.item_number || ""
);
}}
>
{o.routing_name || "라우팅"} <ClipboardCheck className="w-3 h-3 inline ml-0.5" />
</button>
) : <span className="text-muted-foreground">-</span>
) : ""}
</TableCell>
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.work_team || "-") : ""}</TableCell>
<TableCell className="text-xs">{isFirstOfGroup ? getWorkerName(o.worker) : ""}</TableCell>
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.start_date || "-") : ""}</TableCell>
<TableCell className="text-center text-xs">{isFirstOfGroup ? (o.end_date || "-") : ""}</TableCell>
<TableCell className="text-center">
{isFirstOfGroup && (
<div className="flex items-center justify-center gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(o)}><Pencil className="w-3 h-3 mr-1" /> </Button>
<Button variant="outline" size="sm" className="h-7 text-xs px-2 text-destructive border-destructive/30 hover:bg-destructive/10" onClick={() => handleDelete(o.wi_id)}><Trash2 className="w-3 h-3 mr-1" /> </Button>
</div>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* ── 1단계: 등록 모달 ── */}
<Dialog open={isRegModalOpen} onOpenChange={setIsRegModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[1200px] h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2"><Plus className="w-4 h-4" /> </DialogTitle>
<DialogDescription className="text-xs"> "작업지시 적용" .</DialogDescription>
</DialogHeader>
<div className="px-6 py-3 border-b bg-muted/30 flex items-center gap-3 flex-wrap shrink-0">
<Label className="text-sm font-semibold whitespace-nowrap">:</Label>
<Select value={regSourceType} onValueChange={v => setRegSourceType(v as SourceType)}>
<SelectTrigger className="h-9 w-[160px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent><SelectItem value="production"></SelectItem><SelectItem value="order"></SelectItem><SelectItem value="item"></SelectItem></SelectContent>
</Select>
{regSourceType && (<>
<Input placeholder="검색..." value={regKeyword} onChange={e => setRegKeyword(e.target.value)} className="h-9 w-[220px]"
onKeyDown={e => { if (e.key === "Enter") { setRegPage(1); fetchRegSource(1); } }} />
<Button size="sm" className="h-9" onClick={() => { setRegPage(1); fetchRegSource(1); }} disabled={regSourceLoading}>
{regSourceLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}<span className="ml-1.5"></span>
</Button>
</>)}
<div className="flex-1" />
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<Checkbox checked={regMergeSameItem} onCheckedChange={v => setRegMergeSameItem(!!v)} />
<span className="text-sm font-semibold"> </span>
</label>
</div>
<div className="flex-1 overflow-auto px-6 py-4">
{!regSourceType ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm"> </div>
) : regSourceLoading ? (
<div className="flex items-center justify-center h-full"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : regSourceData.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm"> </div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[50px] text-center"><Checkbox checked={regSourceData.length > 0 && regCheckedIds.size === regSourceData.length} onCheckedChange={toggleRegAll} /></TableHead>
{regSourceType === "item" && <><TableHead className="w-[120px]"></TableHead><TableHead></TableHead><TableHead className="w-[120px]"></TableHead></>}
{regSourceType === "order" && <><TableHead className="w-[110px]"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[100px]"></TableHead></>}
{regSourceType === "production" && <><TableHead className="w-[110px]"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead><TableHead className="w-[80px] text-right"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[90px]"></TableHead><TableHead className="w-[100px]"></TableHead></>}
</TableRow>
</TableHeader>
<TableBody>
{regSourceData.map((item, idx) => {
const id = getRegId(item);
const checked = regCheckedIds.has(id);
return (
<TableRow key={`${regSourceType}-${id}-${idx}`} className={cn("cursor-pointer hover:bg-muted/50", checked && "bg-primary/5")} onClick={() => toggleRegItem(id)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}><Checkbox checked={checked} onCheckedChange={() => toggleRegItem(id)} /></TableCell>
{regSourceType === "item" && <><TableCell className="text-xs font-medium">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-xs">{item.spec || "-"}</TableCell></>}
{regSourceType === "order" && <><TableCell className="text-xs">{item.order_no}</TableCell><TableCell className="text-xs">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-xs">{item.spec || "-"}</TableCell><TableCell className="text-right text-xs">{Number(item.qty || 0).toLocaleString()}</TableCell><TableCell className="text-xs">{item.due_date || "-"}</TableCell></>}
{regSourceType === "production" && <><TableCell className="text-xs">{item.plan_no}</TableCell><TableCell className="text-xs">{item.item_code}</TableCell><TableCell className="text-sm">{item.item_name}</TableCell><TableCell className="text-right text-xs">{Number(item.plan_qty || 0).toLocaleString()}</TableCell><TableCell className="text-xs">{item.start_date ? String(item.start_date).split("T")[0] : "-"}</TableCell><TableCell className="text-xs">{item.end_date ? String(item.end_date).split("T")[0] : "-"}</TableCell><TableCell className="text-xs">{item.equipment_name || "-"}</TableCell></>}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
{regTotalCount > 0 && (
<div className="px-6 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
<span className="text-xs text-muted-foreground"> {regTotalCount} (: {regCheckedIds.size})</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" className="h-7 w-7" disabled={regPage <= 1} onClick={() => { const p = regPage - 1; setRegPage(p); fetchRegSource(p); }}><ChevronLeft className="w-3.5 h-3.5" /></Button>
<span className="text-xs font-medium px-2">{regPage} / {totalRegPages}</span>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={regPage >= totalRegPages} onClick={() => { const p = regPage + 1; setRegPage(p); fetchRegSource(p); }}><ChevronRight className="w-3.5 h-3.5" /></Button>
</div>
</div>
)}
<DialogFooter className="px-6 py-3 border-t shrink-0">
<Button variant="outline" onClick={() => setIsRegModalOpen(false)}></Button>
<Button onClick={applyRegistration} disabled={regCheckedIds.size === 0}><ArrowRight className="w-4 h-4 mr-1.5" /> </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── 2단계: 확인 모달 ── */}
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[1000px] max-h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2"><CheckCircle2 className="w-4 h-4" /> </DialogTitle>
<DialogDescription className="text-xs"> "최종 적용" .</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto p-6 space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-sm font-semibold mb-4"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={confirmWiNo} readOnly className="h-9 bg-muted/50 text-muted-foreground" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Select value={confirmStatus} onValueChange={setConfirmStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label><FormDatePicker value={confirmStartDate} onChange={setConfirmStartDate} placeholder="시작일" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><FormDatePicker value={confirmEndDate} onChange={setConfirmEndDate} placeholder="완료예정일" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Select value={nv(confirmEquipmentId)} onValueChange={v => setConfirmEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select>
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Select value={nv(confirmWorkTeam)} onValueChange={v => setConfirmWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select>
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<WorkerCombobox value={confirmWorker} onChange={setConfirmWorker} open={confirmWorkerOpen} onOpenChange={setConfirmWorkerOpen} />
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Select value={nv(confirmRouting)} onValueChange={v => setConfirmRouting(fromNv(v))}>
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{confirmRoutingOptions.map(rv => (
<SelectItem key={rv.id} value={rv.id}>
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className="border rounded-lg p-5">
<h4 className="text-sm font-semibold mb-3"> </h4>
<div className="max-h-[300px] overflow-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow><TableHead className="w-[60px]"></TableHead><TableHead className="w-[120px]"></TableHead><TableHead></TableHead><TableHead className="w-[100px]"></TableHead><TableHead className="w-[100px]"></TableHead><TableHead></TableHead></TableRow>
</TableHeader>
<TableBody>
{confirmItems.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-sm">{item.itemName || item.itemCode}</TableCell>
<TableCell className="text-xs">{item.spec || "-"}</TableCell>
<TableCell><Input type="number" className="h-7 text-xs w-20" value={item.qty} onChange={e => setConfirmItems(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 => setConfirmItems(prev => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
<DialogFooter className="px-6 py-3 border-t shrink-0">
<Button variant="outline" onClick={() => { setIsConfirmModalOpen(false); setIsRegModalOpen(true); }}><ChevronLeft className="w-4 h-4 mr-1" /> </Button>
<Button variant="outline" onClick={() => setIsConfirmModalOpen(false)}></Button>
<Button onClick={finalizeRegistration} disabled={saving}>{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ── 수정 모달 ── */}
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[1100px] max-h-[90vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-6 py-4 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2"><Wrench className="w-4 h-4" /> - {editOrder?.work_instruction_no}</DialogTitle>
<DialogDescription className="text-xs"> / .</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto p-6 space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-sm font-semibold mb-4"> </h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="space-y-1.5"><Label className="text-xs"></Label><Select value={editStatus} onValueChange={setEditStatus}><SelectTrigger className="h-9"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="일반"></SelectItem><SelectItem value="긴급"></SelectItem></SelectContent></Select></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><FormDatePicker value={editStartDate} onChange={setEditStartDate} placeholder="시작일" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><FormDatePicker value={editEndDate} onChange={setEditEndDate} placeholder="완료예정일" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Select value={nv(editEquipmentId)} onValueChange={v => setEditEquipmentId(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="설비" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Select value={nv(editWorkTeam)} onValueChange={v => setEditWorkTeam(fromNv(v))}><SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<WorkerCombobox value={editWorker} onChange={setEditWorker} open={editWorkerOpen} onOpenChange={setEditWorkerOpen} />
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Select value={nv(editRouting)} onValueChange={v => setEditRouting(fromNv(v))}>
<SelectTrigger className="h-9"><SelectValue placeholder="라우팅 선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{editRoutingOptions.map(rv => (
<SelectItem key={rv.id} value={rv.id}>
{rv.version_name || "라우팅"} {rv.is_default ? "(기본)" : ""} - {rv.processes.length}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5"><Label className="text-xs"></Label>
<Button
variant="outline"
className="h-9 w-full text-xs"
disabled={!editRouting}
onClick={() => {
if (!editOrder || !editRouting) return;
const rv = editRoutingOptions.find(r => r.id === editRouting);
openWorkStandardModal(
editOrder.work_instruction_no,
editRouting,
rv?.version_name || "",
editOrder.item_name || editOrder.item_number || "",
editOrder.item_number || ""
);
}}
>
<ClipboardCheck className="w-3.5 h-3.5 mr-1.5" />
</Button>
</div>
<div className="space-y-1.5 col-span-2"><Label className="text-xs"></Label><Input value={editRemark} onChange={e => setEditRemark(e.target.value)} className="h-9" placeholder="비고" /></div>
</div>
</div>
{/* 인라인 추가 폼 */}
<div className="border rounded-lg p-4 bg-muted/20">
<div className="flex items-end gap-3 flex-wrap">
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"> <span className="text-destructive">*</span></Label><Input type="number" value={addQty} onChange={e => setAddQty(e.target.value)} className="h-8 w-24 text-xs" placeholder="0" /></div>
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"></Label><Select value={nv(addEquipment)} onValueChange={v => setAddEquipment(fromNv(v))}><SelectTrigger className="h-8 w-[160px] text-xs"><SelectValue placeholder="설비 선택" /></SelectTrigger><SelectContent><SelectItem value="none"> </SelectItem>{equipmentOptions.map(eq => <SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>)}</SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"></Label><Select value={nv(addWorkTeam)} onValueChange={v => setAddWorkTeam(fromNv(v))}><SelectTrigger className="h-8 w-[100px] text-xs"><SelectValue placeholder="작업조" /></SelectTrigger><SelectContent><SelectItem value="none"></SelectItem><SelectItem value="주간"></SelectItem><SelectItem value="야간"></SelectItem></SelectContent></Select></div>
<div className="space-y-1"><Label className="text-[11px] text-muted-foreground"></Label>
<div className="w-[150px]"><WorkerCombobox value={addWorker} onChange={setAddWorker} open={addWorkerOpen} onOpenChange={setAddWorkerOpen} triggerClassName="h-8 text-xs" /></div>
</div>
<Button size="sm" className="h-8" onClick={addEditItem}><Plus className="w-3 h-3 mr-1" /> </Button>
</div>
</div>
{/* 품목 테이블 */}
<div className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between p-3 bg-muted/20 border-b">
<span className="text-sm font-semibold"> </span>
<span className="text-xs text-muted-foreground">{editItems.length}</span>
</div>
<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></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={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>
</TableRow>
))}
</TableBody>
</Table>
</div>
{editItems.length > 0 && (
<div className="p-3 border-t bg-muted/20 flex items-center justify-between">
<span className="text-sm font-semibold"> </span>
<span className="text-lg font-bold text-primary">{editItems.reduce((s, i) => s + i.qty, 0).toLocaleString()} EA</span>
</div>
)}
</div>
</div>
<DialogFooter className="px-6 py-3 border-t shrink-0">
<Button variant="outline" onClick={() => setIsEditModalOpen(false)}></Button>
<Button onClick={saveEdit} disabled={editSaving}>{editSaving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 공정작업기준 수정 모달 */}
<WorkStandardEditModal
open={wsModalOpen}
onClose={() => setWsModalOpen(false)}
workInstructionNo={wsModalWiNo}
routingVersionId={wsModalRoutingId}
routingName={wsModalRoutingName}
itemName={wsModalItemName}
itemCode={wsModalItemCode}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+900
View File
@@ -0,0 +1,900 @@
"use client";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
// Card, CardContent 제거 — DynamicSearchFilter가 대체
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Plus, Trash2, RotateCcw, Save, Loader2, FileSpreadsheet, Download,
ClipboardList, Pencil, Search, X, Maximize2, Minimize2, Truck,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
const DETAIL_TABLE = "sales_order_detail";
// 천단위 구분자 표시용 (입력 중에는 콤마 포함 표시, 저장 시 숫자만)
const formatNumber = (val: string) => {
const num = val.replace(/[^\d.-]/g, "");
if (!num) return "";
const parts = num.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
};
const parseNumber = (val: string) => val.replace(/,/g, "");
const MASTER_TABLE = "sales_order_mng";
// 메인 목록 테이블 컬럼 (sales_order_detail 기준)
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "order_no", label: "수주번호", width: "w-[120px]" },
{ key: "part_code", label: "품번", width: "w-[120px]", editable: true },
{ key: "part_name", label: "품명", minWidth: "min-w-[150px]", editable: true },
{ key: "spec", label: "규격", width: "w-[120px]", editable: true },
{ key: "unit", label: "단위", width: "w-[70px]", editable: true },
{ key: "qty", label: "수량", width: "w-[90px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
{ key: "ship_qty", label: "출하수량", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "balance_qty", label: "잔량", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "unit_price", label: "단가", width: "w-[100px]", editable: true, inputType: "number", formatNumber: true, align: "right" },
{ key: "amount", label: "금액", width: "w-[110px]", formatNumber: true, align: "right" },
{ key: "due_date", label: "납기일", width: "w-[110px]" },
{ key: "memo", label: "메모", width: "w-[100px]", editable: true },
];
// 조건부 레이어 설정 (input_mode, sell_mode에 따라 표시 필드가 달라짐)
// Zone 10: input_mode → 공급업체우선(CAT_MLZWPH5R_983R) / 품목우선(CAT_MLZWPUQC_PB8Z)
// Zone 17: sell_mode → 해외판매(CAT_MLZWFF2Z_BQCV)
export default function SalesOrderPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
// isModalFullscreen 제거됨 — FullscreenDialog 사용
const [isEditMode, setIsEditMode] = useState(false);
const [saving, setSaving] = useState(false);
const [masterForm, setMasterForm] = useState<Record<string, any>>({});
const [detailRows, setDetailRows] = useState<any[]>([]);
// 품목 선택 모달 (리피터에서 품목 추가용)
const [itemSelectOpen, setItemSelectOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemCheckedIds, setItemCheckedIds] = useState<Set<string>>(new Set());
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 출하계획 모달
const [shippingPlanOpen, setShippingPlanOpen] = useState(false);
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 체크된 행 (다중선택)
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 카테고리 로드
useEffect(() => {
const loadCategories = async () => {
const catColumns = ["sell_mode", "input_mode", "price_mode", "incoterms", "payment_term"];
const optMap: Record<string, { code: string; label: string }[]> = {};
const flatten = (vals: any[]): { code: string; label: string }[] => {
const result: { code: string; label: string }[] = [];
for (const v of vals) {
result.push({ code: v.valueCode, label: v.valueLabel });
if (v.children?.length) result.push(...flatten(v.children));
}
return result;
};
// 라벨 치환 + 중복 제거 (같은 label이면 첫 번째만 유지)
const LABEL_REPLACE: Record<string, string> = {
"공급업체 우선": "거래처 우선",
"공급업체우선": "거래처 우선",
};
const dedup = (items: { code: string; label: string }[]) => {
const seen = new Set<string>();
return items
.map((item) => ({ ...item, label: LABEL_REPLACE[item.label] || item.label }))
.filter((item) => {
const key = item.label.replace(/\s/g, "");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
await Promise.all(
catColumns.map(async (col) => {
try {
const res = await apiClient.get(`/table-categories/${MASTER_TABLE}/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[col] = dedup(flatten(res.data.data));
}
} catch { /* skip */ }
})
);
// 거래처 목록도 로드
try {
const custRes = await apiClient.post(`/table-management/tables/customer_mng/data`, {
page: 1, size: 500, autoFilter: true,
});
const custs = custRes.data?.data?.data || custRes.data?.data?.rows || [];
optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` }));
} catch { /* skip */ }
// 사용자 목록 로드 (담당자 선택용)
try {
const userRes = await apiClient.post(`/table-management/tables/user_info/data`, {
page: 1, size: 500, autoFilter: true,
});
const users = userRes.data?.data?.data || userRes.data?.data?.rows || [];
optMap["manager_id"] = users.map((u: any) => ({
code: u.user_id || u.id,
label: `${u.user_name || u.name || u.user_id}${u.position_name ? ` (${u.position_name})` : ""}`,
}));
} catch { /* skip */ }
// item_info 카테고리도 로드 (unit, material 등 코드→라벨 변환용)
for (const col of ["unit", "material", "division", "type"]) {
try {
const res = await apiClient.get(`/table-categories/item_info/${col}/values`);
if (res.data?.success && res.data.data?.length > 0) {
optMap[`item_${col}`] = flatten(res.data.data);
}
} catch { /* skip */ }
}
setCategoryOptions(optMap);
};
loadCategories();
}, []);
// 데이터 조회
const fetchOrders = useCallback(async () => {
setLoading(true);
try {
const filters: any[] = searchFilters.map((f) => ({
columnName: f.columnName,
operator: f.operator,
value: f.value,
}));
const res = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 500,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
sort: { columnName: "order_no", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// part_code → item_info 조인 (품명/규격이 비어있는 경우 보강)
const partCodes = [...new Set(rows.map((r: any) => r.part_code).filter(Boolean))];
let itemMap: Record<string, any> = {};
if (partCodes.length > 0) {
try {
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: partCodes.length + 10,
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: partCodes }] },
autoFilter: true,
});
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
for (const item of items) {
itemMap[item.item_number] = item;
}
} catch { /* skip */ }
}
// 조인 적용 + 카테고리 코드→라벨 변환
const resolveLabel = (key: string, code: string) => {
if (!code) return "";
const opts = categoryOptions[key];
if (!opts) return code;
return opts.find((o) => o.code === code)?.label || code;
};
const data = rows.map((row: any) => {
const item = itemMap[row.part_code];
const rawUnit = row.unit || item?.unit || "";
return {
...row,
part_name: row.part_name || item?.item_name || "",
spec: row.spec || item?.size || "",
unit: resolveLabel("item_unit", rawUnit) || rawUnit,
};
});
setOrders(data);
setTotalCount(res.data?.data?.total || data.length);
} catch (err) {
console.error("수주 조회 실패:", err);
toast.error("수주 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [searchFilters]);
useEffect(() => { fetchOrders(); }, [fetchOrders]);
const getCategoryLabel = (col: string, code: string) => {
if (!code) return "";
const found = categoryOptions[col]?.find((o) => o.code === code);
return found?.label || code;
};
// 등록 모달 열기
// 납품처 목록 (거래처 선택 시 조회)
const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]);
const loadDeliveryOptions = async (customerCode: string) => {
if (!customerCode) { setDeliveryOptions([]); return; }
try {
const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] },
autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setDeliveryOptions(rows.map((r: any) => ({
code: r.destination_code || r.id,
label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`,
})));
} catch { setDeliveryOptions([]); }
};
const openRegisterModal = () => {
// 기본값: 각 카테고리의 첫 번째 옵션
const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || "";
const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || "";
const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || "";
setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode, manager_id: user?.userId || "" });
setDetailRows([]);
setDeliveryOptions([]);
setIsEditMode(false);
setIsModalOpen(true);
};
// 수정 모달 열기 (order_no로 마스터 + 디테일 조회)
const openEditModal = async (orderNo: string) => {
try {
// 마스터 조회
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masterData = (masterRes.data?.data?.data || masterRes.data?.data?.rows || [])[0];
// 디테일 조회
const detailRes = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const detailData = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
setMasterForm(masterData || {});
setDetailRows(detailData.map((d: any, i: number) => ({ ...d, _id: d.id || `row_${i}` })));
setIsEditMode(true);
setIsModalOpen(true);
} catch (err) {
console.error("수주 상세 조회 실패:", err);
toast.error("수주 정보를 불러오는데 실패했습니다.");
}
};
// 삭제 (다중 선택)
const handleDelete = async () => {
if (checkedIds.length === 0) { toast.error("삭제할 수주를 선택해주세요."); return; }
const selectedItems = orders.filter((o) => checkedIds.includes(o.id));
const orderNos = [...new Set(selectedItems.map((o) => o.order_no))];
const ok = await confirm(`${checkedIds.length}건의 수주 데이터를 삭제하시겠습니까?`, {
description: "삭제된 데이터는 복구할 수 없습니다.",
variant: "destructive",
confirmText: "삭제",
});
if (!ok) return;
try {
// 선택된 디테일 행 삭제
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: checkedIds.map((id) => ({ id })),
});
// 해당 수주번호의 남은 디테일이 없으면 마스터도 삭제
for (const orderNo of orderNos) {
const remaining = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const rows = remaining.data?.data?.data || remaining.data?.data?.rows || [];
if (rows.length === 0) {
const masterRes = await apiClient.post(`/table-management/tables/${MASTER_TABLE}/data`, {
page: 1, size: 1,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: orderNo }] },
autoFilter: true,
});
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
if (masters.length > 0) {
await apiClient.delete(`/table-management/tables/${MASTER_TABLE}/delete`, {
data: masters.map((m: any) => ({ id: m.id })),
});
}
}
}
toast.success("삭제되었습니다.");
setCheckedIds([]);
fetchOrders();
} catch (err) {
console.error("삭제 실패:", err);
toast.error("삭제에 실패했습니다.");
}
};
// 저장 (마스터 + 디테일)
const handleSave = async () => {
if (!masterForm.order_no && !isEditMode) {
toast.error("수주번호는 필수입니다.");
return;
}
if (detailRows.length === 0) {
toast.error("품목을 1개 이상 추가해주세요.");
return;
}
setSaving(true);
try {
const { id, created_date, updated_date, writer, company_code, created_by, updated_by, ...masterFields } = masterForm;
if (isEditMode && id) {
// 마스터 수정
await apiClient.put(`/table-management/tables/${MASTER_TABLE}/edit`, {
originalData: { id },
updatedData: masterFields,
});
// 기존 디테일 삭제 후 재삽입
const existingDetails = await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [{ columnName: "order_no", operator: "equals", value: masterForm.order_no }] },
autoFilter: true,
});
const existings = existingDetails.data?.data?.data || existingDetails.data?.data?.rows || [];
if (existings.length > 0) {
await apiClient.delete(`/table-management/tables/${DETAIL_TABLE}/delete`, {
data: existings.map((d: any) => ({ id: d.id })),
});
}
} else {
// 마스터 등록
await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterFields);
}
// 디테일 등록
for (const row of detailRows) {
const { _id, id: rowId, created_date: _cd, updated_date: _ud, writer: _w, company_code: _cc, ...detailFields } = row;
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
...detailFields,
order_no: masterForm.order_no,
});
}
toast.success(isEditMode ? "수정되었습니다." : "등록되었습니다.");
setIsModalOpen(false);
fetchOrders();
} catch (err: any) {
console.error("저장 실패:", err);
toast.error(err.response?.data?.message || "저장에 실패했습니다.");
} finally {
setSaving(false);
}
};
// 품목 검색 (리피터에서 추가)
const searchItems = async () => {
setItemSearchLoading(true);
try {
const filters: any[] = [];
if (itemSearchKeyword) {
filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword });
}
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
page: 1, size: 50,
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
autoFilter: true,
});
setItemSearchResults(res.data?.data?.data || res.data?.data?.rows || []);
} catch { /* skip */ } finally {
setItemSearchLoading(false);
}
};
const addSelectedItemsToDetail = async () => {
const selected = itemSearchResults.filter((item) => itemCheckedIds.has(item.id));
if (selected.length === 0) { toast.error("품목을 선택해주세요."); return; }
// 단가방식에 따라 단가 조회
const isStandardPrice = masterForm.price_mode === "CAT_MM0BUZKL_HJ7U" || masterForm.price_mode === "CAT_MLKG792S_54WJ";
const isCustomerPrice = masterForm.price_mode === "CAT_MM0BV3OS_41DX" || masterForm.price_mode === "CAT_MLKG7D8K_N8SI";
const partnerId = masterForm.partner_id;
const today = new Date().toISOString().split("T")[0];
// 거래처별 단가 조회 (선택된 품목들에 대해)
let customerPriceMap: Record<string, string> = {};
if (isCustomerPrice && partnerId) {
try {
const itemIds = selected.map((item) => item.item_number || item.id);
const res = await apiClient.post(`/table-management/tables/customer_item_mapping/data`, {
page: 1, size: 500,
dataFilter: {
enabled: true,
filters: [
{ columnName: "customer_id", operator: "equals", value: partnerId },
{ columnName: "item_id", operator: "in", value: itemIds },
],
},
autoFilter: true,
});
const mappings = res.data?.data?.data || res.data?.data?.rows || [];
for (const m of mappings) {
// calculated_price 우선, 없으면 current_unit_price
const price = m.calculated_price || m.current_unit_price || "";
if (price) customerPriceMap[m.item_id] = String(price);
}
} catch (err) {
console.error("거래처별 단가 조회 실패:", err);
}
}
const newRows = selected.map((item) => {
const itemCode = item.item_number || item.id;
let unitPrice = "";
if (isStandardPrice) {
// 기준단가: item_info의 standard_price 또는 selling_price
unitPrice = item.standard_price || item.selling_price || "";
} else if (isCustomerPrice && partnerId) {
// 거래처별 단가
unitPrice = customerPriceMap[itemCode] || "";
}
return {
_id: `new_${Date.now()}_${Math.random()}`,
part_code: itemCode,
part_name: item.item_name,
spec: item.size || "",
material: getCategoryLabel("item_material", item.material) || item.material || "",
unit: getCategoryLabel("item_unit", item.unit) || item.unit || "",
qty: "",
unit_price: unitPrice,
amount: "",
due_date: "",
};
});
setDetailRows((prev) => [...prev, ...newRows]);
toast.success(`${selected.length}개 품목이 추가되었습니다.`);
setItemCheckedIds(new Set());
setItemSelectOpen(false);
};
const updateDetailRow = (idx: number, field: string, value: string) => {
setDetailRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: value };
// 수량 × 단가 = 금액 자동 계산
if (field === "qty" || field === "unit_price") {
const qty = parseFloat(field === "qty" ? value : next[idx].qty) || 0;
const price = parseFloat(field === "unit_price" ? value : next[idx].unit_price) || 0;
next[idx].amount = (qty * price).toString();
}
return next;
});
};
const removeDetailRow = (idx: number) => {
setDetailRows((prev) => prev.filter((_, i) => i !== idx));
};
// input_mode 값으로 레이어 판단
// 거래처 우선 (구: 공급업체 우선) - 두 코드 모두 지원
const isSupplierFirst = masterForm.input_mode === "CAT_MLZWPH5R_983R" || masterForm.input_mode === "CAT_MLKG5KP8_C39W";
const isItemFirst = masterForm.input_mode === "CAT_MLZWPUQC_PB8Z" || masterForm.input_mode === "CAT_MLKG5FZO_HS1B";
const isOverseas = masterForm.sell_mode === "CAT_MLZWFF2Z_BQCV" || masterForm.sell_mode === "CAT_MLKGAR2W_HAPO";
const handleExcelDownload = async () => {
if (orders.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return; }
const data = orders.map((o) => {
const row: Record<string, any> = {};
for (const col of GRID_COLUMNS) row[col.label] = o[col.key] || "";
return row;
});
await exportToExcel(data, "수주관리.xlsx", "수주목록");
toast.success("다운로드 완료");
};
return (
<div className="flex h-full flex-col gap-3 p-3">
{/* 검색 필터 (사용자 설정 가능) */}
<DynamicSearchFilter
tableName={DETAIL_TABLE}
filterId="sales-order"
onFilterChange={setSearchFilters}
dataCount={totalCount}
/>
{/* 메인 테이블 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm flex flex-col">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<ClipboardList className="w-5 h-5" />
<Badge variant="secondary" className="font-normal">{totalCount}</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="w-4 h-4 mr-1.5" />
</Button>
<Button size="sm" onClick={openRegisterModal}>
<Plus className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="outline" size="sm" disabled={checkedIds.length !== 1} onClick={() => {
const item = orders.find((o) => o.id === checkedIds[0]);
if (item) openEditModal(item.order_no);
}}>
<Pencil className="w-4 h-4 mr-1.5" />
</Button>
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
<Button variant="outline" size="sm" disabled={checkedIds.length === 0} onClick={() => setShippingPlanOpen(true)}>
<Truck className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
</div>
</div>
<DataGrid
gridId="sales-order"
columns={GRID_COLUMNS}
data={orders}
loading={loading}
showCheckbox
showRowNumber={false}
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
onRowDoubleClick={(row) => openEditModal(row.order_no)}
tableName={DETAIL_TABLE}
emptyMessage="등록된 수주가 없습니다"
onCellEdit={() => fetchOrders()}
/>
</div>
{/* 수주 등록/수정 모달 */}
<FullscreenDialog
open={isModalOpen}
onOpenChange={setIsModalOpen}
title={isEditMode ? "수주 수정" : "수주 등록"}
description={isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."}
footer={
<>
<Button variant="outline" onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
</>
}
>
<div className="space-y-4 py-2">
{/* 기본 레이어 (항상 표시) */}
<div className="grid grid-cols-4 gap-4">
<div className="space-y-1.5">
<Label className="text-sm"> <span className="text-destructive">*</span></Label>
<Input value={masterForm.order_no || ""} onChange={(e) => setMasterForm((p) => ({ ...p, order_no: e.target.value }))}
placeholder="수주번호" className="h-9" disabled={isEditMode} />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<FormDatePicker value={masterForm.order_date || ""} onChange={(v) => setMasterForm((p) => ({ ...p, order_date: v }))} placeholder="수주일" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"> </Label>
<Select value={masterForm.sell_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, sell_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["sell_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.input_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, input_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["input_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.price_mode || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, price_mode: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["price_mode"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{/* 레이어 2: 거래처 우선 (거래처, 담당자, 납품처, 납품장소) */}
{isSupplierFirst && (
<div className="grid grid-cols-4 gap-4 border-t pt-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.partner_id || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }}>
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.manager_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, manager_id: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="담당자 선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["manager_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
{deliveryOptions.length > 0 ? (
<Select value={masterForm.delivery_partner_id || ""} onValueChange={(v) => {
setMasterForm((p) => ({ ...p, delivery_partner_id: v }));
// 선택한 납품처의 주소를 자동 입력
const found = deliveryOptions.find((o) => o.code === v);
if (found) {
const addr = found.label.match(/\((.+)\)$/)?.[1] || "";
if (addr) setMasterForm((p) => ({ ...p, delivery_partner_id: v, delivery_address: addr }));
}
}}>
<SelectTrigger className="h-9"><SelectValue placeholder="납품처 선택" /></SelectTrigger>
<SelectContent>
{deliveryOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input value={masterForm.delivery_partner_id || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))}
placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} />
)}
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.delivery_address || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))}
placeholder="납품장소" className="h-9" />
</div>
</div>
)}
{/* 레이어 4: 해외판매 (인코텀즈, 결제조건, 통화, 선적항, 도착항, HS코드) */}
{isOverseas && (
<div className="grid grid-cols-3 gap-4 border-t pt-4">
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.incoterms || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, incoterms: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["incoterms"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Select value={masterForm.payment_term || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, payment_term: v }))}>
<SelectTrigger className="h-9"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{(categoryOptions["payment_term"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.currency || ""} onChange={(e) => setMasterForm((p) => ({ ...p, currency: e.target.value }))}
placeholder="KRW" className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.port_of_loading || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_loading: e.target.value }))}
className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.port_of_discharge || ""} onChange={(e) => setMasterForm((p) => ({ ...p, port_of_discharge: e.target.value }))}
className="h-9" />
</div>
<div className="space-y-1.5">
<Label className="text-sm">HS Code</Label>
<Input value={masterForm.hs_code || ""} onChange={(e) => setMasterForm((p) => ({ ...p, hs_code: e.target.value }))}
className="h-9" />
</div>
</div>
)}
{/* 리피터 그리드 (품목 목록) — 레이어 2,3 공통 */}
<div className="border rounded-lg">
<div className="flex items-center justify-between p-3 border-b bg-muted/10">
<span className="text-sm font-semibold"> </span>
<Button size="sm" variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(true); searchItems(); }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="overflow-auto max-h-[300px]">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[110px]"></TableHead>
<TableHead className="w-[200px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{detailRows.length === 0 ? (
<TableRow><TableCell colSpan={9} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : detailRows.map((row, idx) => (
<TableRow key={row._id || idx}>
<TableCell>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive" onClick={() => removeDetailRow(idx)}>
<X className="w-3.5 h-3.5" />
</Button>
</TableCell>
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_code}>{row.part_code}</span></TableCell>
<TableCell className="text-xs max-w-[120px]"><span className="block truncate" title={row.part_name}>{row.part_name}</span></TableCell>
<TableCell className="text-xs">{row.spec}</TableCell>
<TableCell className="text-xs">{row.unit}</TableCell>
<TableCell>
<Input value={formatNumber(row.qty || "")} onChange={(e) => updateDetailRow(idx, "qty", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
</TableCell>
<TableCell>
<Input value={formatNumber(row.unit_price || "")} onChange={(e) => updateDetailRow(idx, "unit_price", parseNumber(e.target.value))}
className="h-8 text-sm text-right" />
</TableCell>
<TableCell className="text-sm text-right font-medium">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>
<TableCell>
<FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* 메모 */}
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<Input value={masterForm.memo || ""} onChange={(e) => setMasterForm((p) => ({ ...p, memo: e.target.value }))}
placeholder="메모" className="h-9" />
</div>
</div>
{/* 품목 선택 모달 (등록 모달 내부에 중첩) */}
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
<DialogContent className="max-w-3xl max-h-[70vh]" onInteractOutside={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2 mb-3">
<Input placeholder="품명/품목코드 검색" value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItems()}
className="h-9 flex-1" />
<Button size="sm" onClick={searchItems} disabled={itemSearchLoading} className="h-9">
{itemSearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /> </>}
</Button>
</div>
<div className="overflow-auto max-h-[350px] border rounded-lg">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center">
<input type="checkbox"
checked={itemSearchResults.length > 0 && itemCheckedIds.size === itemSearchResults.length}
onChange={(e) => {
if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id)));
else setItemCheckedIds(new Set());
}} />
</TableHead>
<TableHead className="w-[130px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itemSearchResults.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8"> </TableCell></TableRow>
) : itemSearchResults.map((item) => (
<TableRow key={item.id} className={cn("cursor-pointer", itemCheckedIds.has(item.id) && "bg-primary/5")}
onClick={() => setItemCheckedIds((prev) => {
const next = new Set(prev);
if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
return next;
})}>
<TableCell className="text-center">
<input type="checkbox" checked={itemCheckedIds.has(item.id)} readOnly />
</TableCell>
<TableCell className="text-xs max-w-[130px]"><span className="block truncate" title={item.item_number}>{item.item_number}</span></TableCell>
<TableCell className="text-sm max-w-[150px]"><span className="block truncate" title={item.item_name}>{item.item_name}</span></TableCell>
<TableCell className="text-xs">{item.size}</TableCell>
<TableCell className="text-xs">{item.material}</TableCell>
<TableCell className="text-xs">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<DialogFooter>
<div className="flex items-center gap-2 w-full justify-between">
<span className="text-sm text-muted-foreground">{itemCheckedIds.size} </span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => { setItemCheckedIds(new Set()); setItemSelectOpen(false); }}></Button>
<Button onClick={addSelectedItemsToDetail} disabled={itemCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {itemCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</FullscreenDialog>
{/* 출하계획 동시 등록 모달 */}
<ShippingPlanBatchModal
open={shippingPlanOpen}
onOpenChange={setShippingPlanOpen}
selectedDetailIds={checkedIds}
onSuccess={fetchOrders}
/>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={DETAIL_TABLE}
userId={user?.userId}
onSuccess={() => fetchOrders()}
/>
{/* 공통 확인 다이얼로그 */}
{ConfirmDialogComponent}
</div>
);
}
@@ -0,0 +1,504 @@
"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 } from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { exportToExcel } from "@/lib/utils/excelExport";
const ITEM_TABLE = "item_info";
const MAPPING_TABLE = "customer_item_mapping";
const CUSTOMER_TABLE = "customer_mng";
// 좌측: 판매품목 컬럼
const LEFT_COLUMNS: DataGridColumn[] = [
{ key: "item_number", label: "품번", width: "w-[110px]" },
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
{ key: "size", label: "규격", width: "w-[90px]" },
{ key: "unit", label: "단위", width: "w-[60px]" },
{ key: "standard_price", label: "기준단가", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "selling_price", label: "판매가격", width: "w-[90px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
{ key: "status", label: "상태", width: "w-[60px]" },
];
// 우측: 거래처 정보 컬럼
const RIGHT_COLUMNS: DataGridColumn[] = [
{ key: "customer_code", label: "거래처코드", width: "w-[110px]" },
{ key: "customer_name", label: "거래처명", minWidth: "min-w-[120px]" },
{ key: "customer_item_code", label: "거래처품번", width: "w-[100px]" },
{ key: "customer_item_name", label: "거래처품명", width: "w-[100px]" },
{ key: "base_price", label: "기준가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "calculated_price", label: "단가", width: "w-[80px]", formatNumber: true, align: "right" },
{ key: "currency_code", label: "통화", width: "w-[50px]" },
];
export default function SalesItemPage() {
const { user } = useAuth();
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// 좌측: 품목
const [items, setItems] = useState<any[]>([]);
const [itemLoading, setItemLoading] = useState(false);
const [itemCount, setItemCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
// 우측: 거래처
const [customerItems, setCustomerItems] = useState<any[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
// 카테고리
const [categoryOptions, setCategoryOptions] = useState<Record<string, { code: string; label: string }[]>>({});
// 거래처 추가 모달
const [custSelectOpen, setCustSelectOpen] = useState(false);
const [custSearchKeyword, setCustSearchKeyword] = useState("");
const [custSearchResults, setCustSearchResults] = useState<any[]>([]);
const [custSearchLoading, setCustSearchLoading] = useState(false);
const [custCheckedIds, setCustCheckedIds] = useState<Set<string>>(new Set());
// 품목 수정 모달
const [editItemOpen, setEditItemOpen] = useState(false);
const [editItemForm, setEditItemForm] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 카테고리 로드
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;
};
// 좌측: 품목 조회
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 addSelectedCustomers = async () => {
const selected = custSearchResults.filter((c) => custCheckedIds.has(c.id));
if (selected.length === 0 || !selectedItem) return;
try {
for (const cust of selected) {
await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
customer_id: cust.customer_code,
item_id: selectedItem.item_number,
});
}
toast.success(`${selected.length}개 거래처가 추가되었습니다.`);
setCustCheckedIds(new Set());
setCustSelectOpen(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="sales-item"
onFilterChange={setSearchFilters}
dataCount={itemCount}
extraActions={
<div className="flex gap-1.5">
<Button variant="outline" size="sm" className="h-9" onClick={() => setExcelUploadOpen(true)}>
<FileSpreadsheet className="w-3.5 h-3.5 mr-1" />
</Button>
<Button variant="outline" size="sm" className="h-9" onClick={handleExcelDownload}>
<Download className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
}
/>
{/* 분할 패널 */}
<div className="flex-1 overflow-hidden border rounded-lg bg-background shadow-sm">
<ResizablePanelGroup direction="horizontal">
{/* 좌측: 판매품목 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Package className="w-4 h-4" />
<Badge variant="secondary" className="font-normal">{itemCount}</Badge>
</div>
<div className="flex gap-1.5">
<Button variant="outline" size="sm" disabled={!selectedItemId} onClick={openEditItem}>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
</div>
<DataGrid
gridId="sales-item-left"
columns={LEFT_COLUMNS}
data={items}
loading={itemLoading}
selectedId={selectedItemId}
onSelect={setSelectedItemId}
onRowDoubleClick={() => openEditItem()}
emptyMessage="등록된 판매품목이 없습니다"
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 거래처 정보 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between p-3 border-b bg-muted/10 shrink-0">
<div className="font-semibold flex items-center gap-2 text-sm">
<Users className="w-4 h-4" />
{selectedItem && <Badge variant="outline" className="font-normal">{selectedItem.item_name}</Badge>}
</div>
<Button variant="outline" size="sm" disabled={!selectedItemId}
onClick={() => { setCustCheckedIds(new Set()); setCustSelectOpen(true); searchCustomers(); }}>
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{!selectedItemId ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<DataGrid
gridId="sales-item-right"
columns={RIGHT_COLUMNS}
data={customerItems}
loading={customerLoading}
showRowNumber={false}
emptyMessage="등록된 거래처가 없습니다"
/>
)}
</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={addSelectedCustomers} disabled={custCheckedIds.size === 0}>
<Plus className="w-4 h-4 mr-1" /> {custCheckedIds.size}
</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 엑셀 업로드 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName={ITEM_TABLE}
userId={user?.userId}
onSuccess={() => fetchItems()}
/>
{ConfirmDialogComponent}
</div>
);
}
@@ -0,0 +1,845 @@
"use client";
import React, { useState, useMemo, 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";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
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, FileSpreadsheet } from "lucide-react";
import { cn } from "@/lib/utils";
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
import {
getShippingOrderList,
saveShippingOrder,
deleteShippingOrders,
previewShippingOrderNo,
getShipmentPlanSource,
getSalesOrderSource,
getItemSource,
} from "@/lib/api/shipping";
import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
import { FullscreenDialog } from "@/components/common/FullscreenDialog";
type DataSourceType = "shipmentPlan" | "salesOrder" | "itemInfo";
const STATUS_OPTIONS = [
{ value: "all", label: "전체" },
{ value: "READY", label: "준비중" },
{ value: "IN_PROGRESS", label: "진행중" },
{ value: "COMPLETED", label: "완료" },
];
const getStatusLabel = (s: string) => STATUS_OPTIONS.find(o => o.value === s)?.label || s;
const getStatusColor = (s: string) => {
switch (s) {
case "READY": return "bg-amber-100 text-amber-800 border-amber-200";
case "IN_PROGRESS": return "bg-blue-100 text-blue-800 border-blue-200";
case "COMPLETED": return "bg-emerald-100 text-emerald-800 border-emerald-200";
default: return "bg-gray-100 text-gray-800 border-gray-200";
}
};
const getSourceBadge = (s: string) => {
switch (s) {
case "shipmentPlan": return { label: "출하계획", cls: "bg-blue-100 text-blue-700" };
case "salesOrder": return { label: "수주", cls: "bg-emerald-100 text-emerald-700" };
case "itemInfo": return { label: "품목", cls: "bg-purple-100 text-purple-700" };
default: return { label: s, cls: "bg-gray-100 text-gray-700" };
}
};
interface SelectedItem {
id: string | number;
itemCode: string;
itemName: string;
spec: string;
material: string;
customer: string;
planQty: number;
orderQty: number;
sourceType: DataSourceType;
shipmentPlanId?: number;
salesOrderId?: number;
detailId?: string;
partnerCode?: string;
}
export default function ShippingOrderPage() {
const [orders, setOrders] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [checkedIds, setCheckedIds] = useState<number[]>([]);
// 검색
const [searchKeyword, setSearchKeyword] = useState("");
const [searchCustomer, setSearchCustomer] = useState("");
const [debouncedKeyword, setDebouncedKeyword] = useState("");
const [debouncedCustomer, setDebouncedCustomer] = useState("");
const [searchStatus, setSearchStatus] = useState("all");
const [searchDateFrom, setSearchDateFrom] = useState("");
const [searchDateTo, setSearchDateTo] = useState("");
// 엑셀 업로드
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
// 모달
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editId, setEditId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
// 모달 폼
const [formOrderNumber, setFormOrderNumber] = useState("");
const [formOrderDate, setFormOrderDate] = useState("");
const [formCustomer, setFormCustomer] = useState("");
const [formPartnerId, setFormPartnerId] = useState("");
const [formStatus, setFormStatus] = useState("READY");
const [formCarrier, setFormCarrier] = useState("");
const [formVehicle, setFormVehicle] = useState("");
const [formDriver, setFormDriver] = useState("");
const [formDriverPhone, setFormDriverPhone] = useState("");
const [formArrival, setFormArrival] = useState("");
const [formAddress, setFormAddress] = useState("");
const [formMemo, setFormMemo] = useState("");
const [isTransportCollapsed, setIsTransportCollapsed] = useState(false);
// 모달 왼쪽 패널
const [dataSource, setDataSource] = useState<DataSourceType>("shipmentPlan");
const [sourceKeyword, setSourceKeyword] = useState("");
const [sourceData, setSourceData] = useState<any[]>([]);
const [sourceLoading, setSourceLoading] = useState(false);
const [selectedItems, setSelectedItems] = useState<SelectedItem[]>([]);
const [sourcePage, setSourcePage] = useState(1);
const [sourcePageSize] = useState(20);
const [sourceTotalCount, setSourceTotalCount] = useState(0);
// 텍스트 입력 debounce (500ms)
useEffect(() => {
const t = setTimeout(() => setDebouncedKeyword(searchKeyword), 500);
return () => clearTimeout(t);
}, [searchKeyword]);
useEffect(() => {
const t = setTimeout(() => setDebouncedCustomer(searchCustomer), 500);
return () => clearTimeout(t);
}, [searchCustomer]);
// 초기 날짜
useEffect(() => {
const today = new Date();
const from = new Date(today);
from.setMonth(from.getMonth() - 1);
setSearchDateFrom(from.toISOString().split("T")[0]);
setSearchDateTo(today.toISOString().split("T")[0]);
}, []);
// 데이터 조회
const fetchOrders = 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 (debouncedCustomer.trim()) params.customer = debouncedCustomer.trim();
if (debouncedKeyword.trim()) params.keyword = debouncedKeyword.trim();
const result = await getShippingOrderList(params);
if (result.success) setOrders(result.data || []);
} catch (err) {
console.error("출하지시 조회 실패:", err);
} finally {
setLoading(false);
}
}, [searchDateFrom, searchDateTo, searchStatus, debouncedCustomer, debouncedKeyword]);
useEffect(() => {
if (searchDateFrom && searchDateTo) fetchOrders();
}, [fetchOrders]);
// 소스 데이터 조회
const fetchSourceData = useCallback(async (pageOverride?: number) => {
setSourceLoading(true);
try {
const currentPage = pageOverride ?? sourcePage;
const params: any = { page: currentPage, pageSize: sourcePageSize };
if (sourceKeyword.trim()) params.keyword = sourceKeyword.trim();
let result;
switch (dataSource) {
case "shipmentPlan":
result = await getShipmentPlanSource(params);
break;
case "salesOrder":
result = await getSalesOrderSource(params);
break;
case "itemInfo":
result = await getItemSource(params);
break;
}
if (result?.success) {
setSourceData(result.data || []);
setSourceTotalCount(result.totalCount || 0);
}
} catch (err) {
console.error("소스 데이터 조회 실패:", err);
} finally {
setSourceLoading(false);
}
}, [dataSource, sourceKeyword, sourcePage, sourcePageSize]);
useEffect(() => {
if (isModalOpen) {
setSourcePage(1);
fetchSourceData(1);
}
}, [isModalOpen, dataSource]);
// 핸들러
const handleResetSearch = () => {
setSearchKeyword("");
setSearchCustomer("");
setDebouncedKeyword("");
setDebouncedCustomer("");
setSearchStatus("all");
const today = new Date();
const from = new Date(today);
from.setMonth(from.getMonth() - 1);
setSearchDateFrom(from.toISOString().split("T")[0]);
setSearchDateTo(today.toISOString().split("T")[0]);
};
const handleCheckAll = (checked: boolean) => {
setCheckedIds(checked ? orders.map((o: any) => o.id) : []);
};
const handleDeleteSelected = async () => {
if (checkedIds.length === 0) return;
if (!confirm(`선택한 ${checkedIds.length}개의 출하지시를 삭제하시겠습니까?`)) return;
try {
const result = await deleteShippingOrders(checkedIds);
if (result.success) {
setCheckedIds([]);
fetchOrders();
alert("삭제되었습니다.");
}
} catch (err: any) {
alert(err.message || "삭제 실패");
}
};
// 모달 열기
const openModal = (order?: any) => {
if (order) {
setIsEditMode(true);
setEditId(order.id);
setFormOrderNumber(order.instruction_no || "");
setFormOrderDate(order.instruction_date ? order.instruction_date.split("T")[0] : "");
setFormCustomer(order.customer_name || "");
setFormPartnerId(order.partner_id || "");
setFormStatus(order.status || "READY");
setFormCarrier(order.carrier_name || "");
setFormVehicle(order.vehicle_no || "");
setFormDriver(order.driver_name || "");
setFormDriverPhone(order.driver_contact || "");
setFormArrival(order.arrival_time ? new Date(order.arrival_time).toLocaleString("sv-SE", { timeZone: "Asia/Seoul" }).replace(" ", "T").slice(0, 16) : "");
setFormAddress(order.delivery_address || "");
setFormMemo(order.memo || "");
const items = order.items || [];
setSelectedItems(items.filter((it: any) => it.id).map((it: any) => {
const srcType = it.source_type || "shipmentPlan";
// 소스 데이터와 매칭할 수 있도록 원래 소스 id를 사용
let sourceId: string | number = it.id;
if (srcType === "shipmentPlan" && it.shipment_plan_id) sourceId = it.shipment_plan_id;
else if (srcType === "salesOrder" && it.detail_id) sourceId = it.detail_id;
else if (srcType === "itemInfo") sourceId = it.item_code || "";
return {
id: sourceId,
itemCode: it.item_code || "",
itemName: it.item_name || "",
spec: it.spec || "",
material: it.material || "",
customer: order.customer_name || "",
planQty: Number(it.plan_qty || 0),
orderQty: Number(it.order_qty || 0),
sourceType: srcType,
shipmentPlanId: it.shipment_plan_id,
salesOrderId: it.sales_order_id,
detailId: it.detail_id,
partnerCode: order.partner_id,
};
}));
} else {
setIsEditMode(false);
setEditId(null);
setFormOrderNumber("불러오는 중...");
setFormOrderDate(new Date().toISOString().split("T")[0]);
previewShippingOrderNo().then(r => {
if (r.success) setFormOrderNumber(r.instructionNo);
else setFormOrderNumber("(자동생성)");
}).catch(() => setFormOrderNumber("(자동생성)"));
setFormCustomer("");
setFormPartnerId("");
setFormStatus("READY");
setFormCarrier("");
setFormVehicle("");
setFormDriver("");
setFormDriverPhone("");
setFormArrival("");
setFormAddress("");
setFormMemo("");
setSelectedItems([]);
}
setDataSource("shipmentPlan");
setSourceKeyword("");
setSourceData([]);
setIsTransportCollapsed(false);
setIsModalOpen(true);
};
// 소스 아이템 선택 토글
const toggleSourceItem = (item: any) => {
const key = dataSource === "shipmentPlan" ? item.id
: dataSource === "salesOrder" ? item.id
: item.item_code;
const exists = selectedItems.findIndex(s => {
// 같은 소스 타입에서 id 매칭
if (s.sourceType === dataSource) {
if (dataSource === "itemInfo") return s.itemCode === key;
return String(s.id) === String(key);
}
// 다른 소스 타입이라도 원래 소스 id로 매칭
if (dataSource === "shipmentPlan" && s.shipmentPlanId) return String(s.shipmentPlanId) === String(item.id);
if (dataSource === "salesOrder" && s.detailId) return String(s.detailId) === String(item.id);
return false;
});
if (exists > -1) {
setSelectedItems(prev => prev.filter((_, i) => i !== exists));
} else {
const newItem: SelectedItem = {
id: key,
itemCode: item.item_code || "",
itemName: item.item_name || "",
spec: item.spec || "",
material: item.material || "",
customer: item.customer_name || "",
planQty: Number(item.plan_qty || item.qty || item.balance_qty || 0),
orderQty: Number(item.plan_qty || item.balance_qty || item.qty || 1),
sourceType: dataSource,
shipmentPlanId: dataSource === "shipmentPlan" ? item.id : undefined,
salesOrderId: dataSource === "salesOrder" ? (item.master_id || undefined) : undefined,
detailId: dataSource === "salesOrder" ? item.id : (dataSource === "shipmentPlan" ? item.detail_id : undefined),
partnerCode: item.partner_code || "",
};
setSelectedItems(prev => [...prev, newItem]);
if (!formCustomer && item.customer_name) {
setFormCustomer(item.customer_name);
setFormPartnerId(item.partner_code || "");
}
}
};
const removeSelectedItem = (idx: number) => {
setSelectedItems(prev => prev.filter((_, i) => i !== idx));
};
const updateOrderQty = (idx: number, val: number) => {
setSelectedItems(prev => prev.map((item, i) => i === idx ? { ...item, orderQty: val } : item));
};
// 저장
const handleSave = async () => {
if (!formOrderDate) { alert("출하지시일을 입력해주세요."); return; }
if (selectedItems.length === 0) { alert("품목을 선택해주세요."); return; }
setSaving(true);
try {
const payload = {
id: isEditMode ? editId : undefined,
instructionDate: formOrderDate,
partnerId: formPartnerId || formCustomer,
status: formStatus,
memo: formMemo,
carrierName: formCarrier,
vehicleNo: formVehicle,
driverName: formDriver,
driverContact: formDriverPhone,
arrivalTime: formArrival ? `${formArrival}+09:00` : null,
deliveryAddress: formAddress,
items: selectedItems.map(item => ({
itemCode: item.itemCode,
itemName: item.itemName,
spec: item.spec,
material: item.material,
orderQty: item.orderQty,
planQty: item.planQty,
shipQty: 0,
sourceType: item.sourceType,
shipmentPlanId: item.shipmentPlanId,
salesOrderId: item.salesOrderId,
detailId: item.detailId,
})),
};
const result = await saveShippingOrder(payload);
if (result.success) {
setIsModalOpen(false);
fetchOrders();
alert(isEditMode ? "출하지시가 수정되었습니다." : "출하지시가 등록되었습니다.");
} else {
alert(result.message || "저장 실패");
}
} catch (err: any) {
alert(err.message || "저장 중 오류 발생");
} finally {
setSaving(false);
}
};
const formatDate = (d: string) => d ? d.split("T")[0] : "-";
const dataSourceTitle: Record<DataSourceType, string> = {
shipmentPlan: "출하계획 목록",
salesOrder: "수주정보 목록",
itemInfo: "품목정보 목록",
};
return (
<div className="flex flex-col h-[calc(100vh-4rem)] bg-muted/30 p-4 gap-4">
{/* 검색 */}
<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-[160px] h-9" value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input placeholder="거래처 검색" className="w-[140px] h-9" value={searchCustomer}
onChange={(e) => setSearchCustomer(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
</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>
{STATUS_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<div className="flex items-center gap-2">
<div className="w-[160px]">
<FormDatePicker value={searchDateFrom} onChange={setSearchDateFrom} placeholder="시작일" />
</div>
<span className="text-muted-foreground">~</span>
<div className="w-[160px]">
<FormDatePicker value={searchDateTo} onChange={setSearchDateTo} placeholder="종료일" />
</div>
</div>
</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">
<Truck className="w-5 h-5" />
<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>
<Button variant="destructive" size="sm" disabled={checkedIds.length === 0} onClick={handleDeleteSelected}>
<Trash2 className="w-4 h-4 mr-1.5" /> {checkedIds.length > 0 && `(${checkedIds.length})`}
</Button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
<TableRow>
<TableHead className="w-[40px] text-center">
<Checkbox checked={orders.length > 0 && checkedIds.length === orders.length} onCheckedChange={handleCheckAll} />
</TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[100px] text-center"></TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[130px]"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{orders.length === 0 ? (
<TableRow>
<TableCell colSpan={13} className="h-40 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<Truck className="w-12 h-12 text-muted-foreground/30" />
<div className="font-medium"> </div>
<div className="text-sm"> </div>
</div>
</TableCell>
</TableRow>
) : (
orders.map((order: any) => {
const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : [];
if (items.length === 0) {
return (
<TableRow key={order.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
<Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
if (c) setCheckedIds(p => [...p, order.id]);
else setCheckedIds(p => p.filter(i => i !== order.id));
}} />
</TableCell>
<TableCell className="font-medium">{order.instruction_no}</TableCell>
<TableCell className="text-center">{formatDate(order.instruction_date)}</TableCell>
<TableCell>{order.customer_name || "-"}</TableCell>
<TableCell>{order.carrier_name || "-"}</TableCell>
<TableCell>{order.vehicle_no || "-"}</TableCell>
<TableCell>{order.driver_name || "-"}</TableCell>
<TableCell className="text-center">
<span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(order.status))}>{getStatusLabel(order.status)}</span>
</TableCell>
<TableCell>-</TableCell>
<TableCell>-</TableCell>
<TableCell className="text-right">0</TableCell>
<TableCell className="text-center">-</TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[100px]">{order.memo || "-"}</TableCell>
</TableRow>
);
}
return items.map((item: any, itemIdx: number) => (
<TableRow key={`${order.id}-${item.id}`} className="cursor-pointer hover:bg-muted/50" onClick={() => openModal(order)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
{itemIdx === 0 && <Checkbox checked={checkedIds.includes(order.id)} onCheckedChange={(c) => {
if (c) setCheckedIds(p => [...p, order.id]);
else setCheckedIds(p => p.filter(i => i !== order.id));
}} />}
</TableCell>
<TableCell className="font-medium">{itemIdx === 0 ? order.instruction_no : ""}</TableCell>
<TableCell className="text-center">{itemIdx === 0 ? formatDate(order.instruction_date) : ""}</TableCell>
<TableCell>{itemIdx === 0 ? (order.customer_name || "-") : ""}</TableCell>
<TableCell>{itemIdx === 0 ? (order.carrier_name || "-") : ""}</TableCell>
<TableCell>{itemIdx === 0 ? (order.vehicle_no || "-") : ""}</TableCell>
<TableCell>{itemIdx === 0 ? (order.driver_name || "-") : ""}</TableCell>
<TableCell className="text-center">
{itemIdx === 0 && <span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(order.status))}>{getStatusLabel(order.status)}</span>}
</TableCell>
<TableCell className="text-xs text-muted-foreground">{item.item_code}</TableCell>
<TableCell className="font-medium text-sm">{item.item_name}</TableCell>
<TableCell className="text-right">{Number(item.order_qty || 0).toLocaleString()}</TableCell>
<TableCell className="text-center">
{(() => { const b = getSourceBadge(item.source_type || ""); return <span className={cn("px-2 py-0.5 rounded-full text-[10px]", b.cls)}>{b.label}</span>; })()}
</TableCell>
<TableCell className="text-xs text-muted-foreground truncate max-w-[100px]">{itemIdx === 0 ? (order.memo || "-") : ""}</TableCell>
</TableRow>
));
})
)}
</TableBody>
</Table>
)}
</div>
</div>
{/* 등록/수정 모달 */}
<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">
{/* 왼쪽: 데이터 소스 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex flex-col h-full">
<div className="p-3 border-b bg-muted/30 flex flex-wrap items-center gap-2 shrink-0">
<Select value={dataSource} onValueChange={(v) => setDataSource(v as DataSourceType)}>
<SelectTrigger className="w-[130px] h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="shipmentPlan"></SelectItem>
<SelectItem value="salesOrder"></SelectItem>
<SelectItem value="itemInfo"></SelectItem>
</SelectContent>
</Select>
<Input placeholder="품번, 품명 검색" className="flex-1 h-8 text-xs min-w-[120px]"
value={sourceKeyword} onChange={(e) => setSourceKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { setSourcePage(1); fetchSourceData(1); }}} />
<Button size="sm" className="h-8 text-xs" onClick={() => { setSourcePage(1); fetchSourceData(1); }} disabled={sourceLoading}>
{sourceLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Search className="w-3 h-3" />}
<span className="ml-1"></span>
</Button>
</div>
<div className="px-4 py-2 flex items-center justify-between border-b shrink-0">
<div className="text-sm font-medium">
{dataSourceTitle[dataSource]}
<span className="text-muted-foreground ml-2 font-normal">
: <span className="text-primary font-semibold">{selectedItems.length}</span>
</span>
</div>
</div>
<div className="flex-1 overflow-auto">
{sourceLoading ? (
<div className="flex items-center justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-muted-foreground" /></div>
) : sourceData.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<div className="text-sm"> </div>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
{dataSource === "shipmentPlan" && <TableHead className="w-[70px] text-center"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{sourceData.map((item: any, idx: number) => {
const itemId = dataSource === "itemInfo" ? item.item_code : item.id;
const isSelected = selectedItems.some(s => {
// 같은 소스 타입에서 id 매칭
if (s.sourceType === dataSource) {
if (dataSource === "itemInfo") return s.itemCode === itemId;
return String(s.id) === String(itemId);
}
// 다른 소스 타입이라도 같은 품번이면 중복 방지
if (dataSource === "shipmentPlan" && s.shipmentPlanId) return String(s.shipmentPlanId) === String(item.id);
if (dataSource === "salesOrder" && s.detailId) return String(s.detailId) === String(item.id);
return false;
});
return (
<TableRow key={`${dataSource}-${itemId}-${idx}`} className={cn("cursor-pointer hover:bg-muted/50", isSelected && "bg-primary/5")} onClick={() => toggleSourceItem(item)}>
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={() => toggleSourceItem(item)} />
</TableCell>
<TableCell className="text-xs">{item.item_code || "-"}</TableCell>
<TableCell className="text-sm font-medium">{item.item_name || "-"}</TableCell>
<TableCell className="text-xs text-muted-foreground">{item.spec || "-"}</TableCell>
<TableCell className="text-xs">{item.customer_name || "-"}</TableCell>
<TableCell className="text-right text-xs">{Number(item.plan_qty || item.qty || item.balance_qty || 0).toLocaleString()}</TableCell>
{dataSource === "shipmentPlan" && (
<TableCell className="text-center">
<Badge variant="secondary" className="text-[10px]">{getStatusLabel(item.status)}</Badge>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
{/* 페이징 */}
{sourceTotalCount > 0 && (
<div className="px-4 py-2 border-t bg-muted/10 flex items-center justify-between shrink-0">
<span className="text-xs text-muted-foreground">
{sourceTotalCount} {(sourcePage - 1) * sourcePageSize + 1}-{Math.min(sourcePage * sourcePageSize, sourceTotalCount)}
</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage <= 1}
onClick={() => { const p = sourcePage - 1; setSourcePage(p); fetchSourceData(p); }}>
<ChevronLeft className="w-3.5 h-3.5" />
</Button>
<span className="text-xs font-medium px-2">{sourcePage} / {Math.max(1, Math.ceil(sourceTotalCount / sourcePageSize))}</span>
<Button variant="outline" size="icon" className="h-7 w-7" disabled={sourcePage >= Math.ceil(sourceTotalCount / sourcePageSize)}
onClick={() => { const p = sourcePage + 1; setSourcePage(p); fetchSourceData(p); }}>
<ChevronRight className="w-3.5 h-3.5" />
</Button>
</div>
</div>
)}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 폼 */}
<ResizablePanel defaultSize={45} minSize={30}>
<div className="flex flex-col h-full overflow-auto p-5 bg-muted/20 gap-5">
{/* 기본 정보 */}
<div className="bg-background border rounded-lg p-5 shrink-0">
<h3 className="text-sm font-semibold mb-4"> </h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input value={formOrderNumber} readOnly className="h-9 bg-muted/50 text-muted-foreground" />
</div>
<div className="space-y-1.5">
<Label className="text-xs"> <span className="text-destructive">*</span></Label>
<FormDatePicker value={formOrderDate} onChange={setFormOrderDate} placeholder="날짜 선택" />
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input value={formCustomer} readOnly placeholder="품목 선택 시 자동" className="h-9 bg-muted/50" />
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Select value={formStatus} onValueChange={setFormStatus}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="READY"></SelectItem>
<SelectItem value="IN_PROGRESS"></SelectItem>
<SelectItem value="COMPLETED"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 운송 정보 */}
<div className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden shrink-0">
<button className="w-full px-5 py-3 flex items-center justify-between text-left" onClick={() => setIsTransportCollapsed(!isTransportCollapsed)}>
<h3 className="text-sm font-semibold text-amber-900 flex items-center gap-2">
<Truck className="w-4 h-4" /> <span className="text-[11px] font-normal text-muted-foreground">()</span>
</h3>
{isTransportCollapsed ? <ChevronRight className="w-4 h-4 text-amber-700" /> : <ChevronDown className="w-4 h-4 text-amber-700" />}
</button>
{!isTransportCollapsed && (
<div className="px-5 pb-4 grid grid-cols-3 gap-3">
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formCarrier} onChange={(e) => setFormCarrier(e.target.value)} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formVehicle} onChange={(e) => setFormVehicle(e.target.value)} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formDriver} onChange={(e) => setFormDriver(e.target.value)} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formDriverPhone} onChange={(e) => setFormDriverPhone(e.target.value)} className="h-9" /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><FormDatePicker value={formArrival} onChange={setFormArrival} placeholder="도착예정일시" includeTime /></div>
<div className="space-y-1.5"><Label className="text-xs"></Label><Input value={formAddress} onChange={(e) => setFormAddress(e.target.value)} className="h-9" /></div>
</div>
)}
</div>
{/* 선택된 품목 */}
<div className="bg-background border rounded-lg p-5 flex-1 flex flex-col min-h-[200px]">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Badge variant="default" className="text-[10px]">{selectedItems.length}</Badge>
</h3>
<div className="flex-1 overflow-auto min-h-0">
{selectedItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<div className="text-sm"> </div>
</div>
) : (
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead className="w-[40px] text-center"></TableHead>
<TableHead className="w-[90px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[90px] text-center"></TableHead>
<TableHead className="w-[70px] text-right"></TableHead>
<TableHead className="w-[40px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selectedItems.map((item, idx) => {
const b = getSourceBadge(item.sourceType);
return (
<TableRow key={`${item.sourceType}-${item.id}-${idx}`}>
<TableCell className="text-center">
<span className={cn("px-1.5 py-0.5 rounded text-[10px]", b.cls)}>{b.label.charAt(0)}</span>
</TableCell>
<TableCell className="text-xs">{item.itemCode}</TableCell>
<TableCell className="text-sm font-medium">{item.itemName}</TableCell>
<TableCell className="text-center">
<Input type="number" value={item.orderQty} onChange={(e) => updateOrderQty(idx, parseInt(e.target.value) || 0)}
min={1} className="h-7 w-[70px] text-xs text-right mx-auto" />
</TableCell>
<TableCell className="text-right text-xs">{item.planQty ? item.planQty.toLocaleString() : "-"}</TableCell>
<TableCell className="text-center">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => removeSelectedItem(idx)}>
<X className="w-3.5 h-3.5 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</div>
{/* 메모 */}
<div className="bg-background border rounded-lg p-5 shrink-0">
<h3 className="text-sm font-semibold mb-3"></h3>
<Textarea value={formMemo} onChange={(e) => setFormMemo(e.target.value)} placeholder="출하지시 관련 메모" rows={2} className="resize-y" />
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</FullscreenDialog>
{/* 엑셀 업로드 모달 */}
<ExcelUploadModal
open={excelUploadOpen}
onOpenChange={setExcelUploadOpen}
tableName="shipment_instruction"
onSuccess={() => {
fetchOrders();
}}
/>
</div>
);
}
@@ -0,0 +1,557 @@
"use client";
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 { Card, CardContent } from "@/components/ui/card";
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 { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Search, Download, X, Save, Ban, RotateCcw, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import {
getShipmentPlanList,
updateShipmentPlan,
type ShipmentPlanListItem,
} from "@/lib/api/shipping";
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";
}
};
export default function ShippingPlanPage() {
const [data, setData] = useState<ShipmentPlanListItem[]>([]);
const [loading, setLoading] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [checkedIds, setCheckedIds] = useState<number[]>([]);
// 검색
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(() => {
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 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 (searchDateFrom && searchDateTo) {
fetchData();
}
}, [searchDateFrom, searchDateTo]);
const handleSearch = () => fetchData();
const handleResetSearch = () => {
setSearchStatus("all");
setSearchCustomer("");
setSearchKeyword("");
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();
} else {
alert(result.message || "저장 실패");
}
} catch (err: any) {
alert(err.message || "저장 중 오류 발생");
} finally {
setSaving(false);
}
};
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();
};
return (
<div className="flex flex-col h-[calc(100vh-4rem)] bg-muted/30 p-4 gap-4">
{/* 검색 영역 */}
<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>
<div className="flex items-center gap-2">
<Input
type="date"
className="w-[140px] h-9"
value={searchDateFrom}
onChange={(e) => setSearchDateFrom(e.target.value)}
/>
<span className="text-muted-foreground">~</span>
<Input
type="date"
className="w-[140px] h-9"
value={searchDateTo}
onChange={(e) => setSearchDateTo(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground"></Label>
<Select value={searchStatus} onValueChange={setSearchStatus}>
<SelectTrigger className="w-[120px] h-9">
<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.5">
<Label className="text-xs text-muted-foreground"></Label>
<Input
placeholder="거래처 검색"
className="w-[150px] h-9"
value={searchCustomer}
onChange={(e) => setSearchCustomer(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs text-muted-foreground">/</Label>
<Input
placeholder="수주번호 / 품목 검색"
className="w-[220px] h-9"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2">
<Button size="sm" className="h-9" onClick={handleSearch} disabled={loading}>
{loading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Search className="w-4 h-4 mr-2" />}
</Button>
<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">
<ResizablePanelGroup direction="horizontal">
<ResizablePanel defaultSize={selectedId ? 65 : 100} 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">
<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-[40px] text-center">
<Checkbox
checked={data.length > 0 && checkedIds.length === data.filter(p => p.status !== "CANCELLED").length}
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>
</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">
{planIdx === 0 ? (plan.order_no || "-") : ""}
</TableCell>
<TableCell className="text-center">
{planIdx === 0 ? formatDate(plan.due_date) : ""}
</TableCell>
<TableCell>
{planIdx === 0 ? (plan.customer_name || "-") : ""}
</TableCell>
<TableCell className="text-muted-foreground text-xs">{plan.part_code || "-"}</TableCell>
<TableCell className="font-medium">{plan.part_name || "-"}</TableCell>
<TableCell className="text-right">{formatNumber(plan.order_qty)}</TableCell>
<TableCell className="text-right font-semibold text-primary">{formatNumber(plan.plan_qty)}</TableCell>
<TableCell className="text-center">{formatDate(plan.plan_date)}</TableCell>
<TableCell className="text-center">
<span className={cn("px-2 py-1 rounded-full text-[11px] font-medium border", getStatusColor(plan.status))}>
{getStatusLabel(plan.status)}
</span>
</TableCell>
</TableRow>
))
)
)}
</TableBody>
</Table>
)}
</div>
</div>
</ResizablePanel>
{/* 상세 패널 */}
{selectedId && selectedPlan && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={35} minSize={25}>
<div className="flex flex-col h-full bg-card">
<div className="flex items-center justify-between p-3 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(isDetailChanged ? "bg-primary" : "bg-muted text-muted-foreground")}
>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />}
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setSelectedId(null)}>
<X className="w-4 h-4" />
</Button>
</div>
</div>
<div className="flex-1 overflow-auto p-4 space-y-6">
{/* 기본 정보 */}
<section>
<h3 className="text-sm font-semibold mb-3"> </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-1"></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-1"></span>
<span>{selectedPlan.order_no || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedPlan.customer_name || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{formatDate(selectedPlan.created_date)}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{formatDate(selectedPlan.due_date)}</span>
</div>
</div>
</section>
{/* 품목 정보 */}
<section>
<h3 className="text-sm font-semibold mb-3"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm bg-muted/30 p-3 rounded-md border border-border/50">
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedPlan.part_code || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span className="font-medium">{selectedPlan.part_name || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedPlan.spec || "-"}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{selectedPlan.material || "-"}</span>
</div>
</div>
</section>
{/* 수량 정보 */}
<section>
<h3 className="text-sm font-semibold mb-3"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-4 text-sm">
<div>
<span className="text-muted-foreground text-xs block mb-1"></span>
<span>{formatNumber(selectedPlan.order_qty)}</span>
</div>
<div>
<Label className="text-muted-foreground text-xs block mb-1"></Label>
<Input
type="number"
className="h-8"
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-1"></span>
<span>{formatNumber(selectedPlan.shipped_qty)}</span>
</div>
<div>
<span className="text-muted-foreground text-xs block mb-1"></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-3"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-4 text-sm">
<div className="col-span-2">
<Label className="text-muted-foreground text-xs block mb-1"></Label>
<Input
type="date"
className="h-8"
value={editPlanDate}
onChange={(e) => { setEditPlanDate(e.target.value); setIsDetailChanged(true); }}
disabled={selectedPlan.status === "CANCELLED" || selectedPlan.status === "COMPLETED"}
/>
</div>
<div className="col-span-2">
<Label className="text-muted-foreground text-xs block mb-1"></Label>
<Textarea
className="min-h-[80px] resize-y"
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-3"> </h3>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm text-muted-foreground">
<div>
<span className="text-xs block mb-1"></span>
<span className="text-foreground">{selectedPlan.created_by || "-"}</span>
</div>
<div>
<span className="text-xs block mb-1"></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>
</div>
);
}
@@ -26,6 +26,9 @@ import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/servi
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
import { useTabId } from "@/contexts/TabIdContext";
import { useTabStore } from "@/stores/tabStore";
import { FileSpreadsheet, Loader2 as ExcelLoader } from "lucide-react";
import { MultiTableExcelUploadModal } from "@/components/common/MultiTableExcelUploadModal";
import { autoDetectMultiTableConfig, TableChainConfig } from "@/lib/api/multiTableExcel";
export interface ScreenViewPageProps {
screenIdProp?: number;
@@ -96,6 +99,11 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
// 데이터 전달에 의해 강제 활성화된 레이어 ID 목록
const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState<string[]>([]);
// 엑셀 업로드 모달 상태
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
const [excelDetecting, setExcelDetecting] = useState(false);
// 편집 모달 상태
const [editModalOpen, setEditModalOpen] = useState(false);
const [editModalConfig, setEditModalConfig] = useState<{
@@ -650,8 +658,46 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
<TableOptionsProvider>
<div
ref={containerRef}
className={`bg-background h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}
className={`bg-background relative h-full w-full ${isPreviewMode ? "overflow-hidden p-0" : "overflow-auto p-3"}`}
>
{/* 엑셀 업로드 버튼 (테이블이 있는 화면에서만 표시) */}
{!isPreviewMode && screen?.tableName && (
<div className="absolute top-2 right-3 z-10">
<Button
variant="outline"
size="sm"
className="gap-1.5 text-xs"
disabled={excelDetecting}
onClick={async () => {
if (!screen?.tableName) return;
setExcelDetecting(true);
try {
const result = await autoDetectMultiTableConfig(screen.tableName, screenId);
if (result.success && result.data) {
setExcelChainConfig(result.data);
setExcelUploadOpen(true);
} else {
const { toast } = await import("sonner");
toast.error(result.message || "테이블 구조를 분석할 수 없습니다.");
}
} catch {
const { toast } = await import("sonner");
toast.error("테이블 구조 분석 중 오류가 발생했습니다.");
} finally {
setExcelDetecting(false);
}
}}
>
{excelDetecting ? (
<ExcelLoader className="h-3.5 w-3.5 animate-spin" />
) : (
<FileSpreadsheet className="h-3.5 w-3.5" />
)}
</Button>
</div>
)}
{/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && (
<div className="bg-muted/30 flex h-full w-full items-center justify-center">
@@ -801,6 +847,22 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
}}
/>
{/* 엑셀 업로드 모달 (멀티테이블 자동감지) */}
{excelChainConfig && (
<MultiTableExcelUploadModal
open={excelUploadOpen}
onOpenChange={(open) => {
setExcelUploadOpen(open);
if (!open) setExcelChainConfig(null);
}}
config={excelChainConfig}
onSuccess={() => {
window.dispatchEvent(new CustomEvent("refreshTable"));
setTableRefreshKey((prev) => prev + 1);
}}
/>
)}
{/* 스케줄 생성 확인 다이얼로그 */}
<ScheduleConfirmDialog
open={showConfirmDialog}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { cookies } from "next/headers";
const FLEET_API_URL = process.env.FLEET_API_URL || "https://fleet-api.vexplor.com";
const SSO_SHARED_SECRET = process.env.SSO_SHARED_SECRET || "change_this_sso_secret";
export async function POST(request: NextRequest) {
try {
// V1 로그인 세션에서 사용자 정보 추출
const cookieStore = await cookies();
const sessionToken = cookieStore.get("session_token")?.value
|| cookieStore.get("token")?.value;
if (!sessionToken) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
// 세션에서 사용자 정보 파싱 (JWT 디코딩)
let userId = "unknown";
let userName = "unknown";
let companyId = "";
try {
const payload = JSON.parse(atob(sessionToken.split(".")[1]));
userId = payload.userId || payload.user_id || payload.sub || payload.id || "unknown";
userName = payload.userName || payload.user_name || payload.name || "unknown";
companyId = payload.companyId || payload.company_id || payload.companyCode || "";
} catch {
return NextResponse.json({ error: "세션이 유효하지 않습니다." }, { status: 401 });
}
// Fleet API로 SSO 토큰 요청 (HMAC 서명)
const timestamp = Math.floor(Date.now() / 1000);
const signPayload = `${userId}|${userName}|${companyId}|${timestamp}`;
const signature = crypto
.createHmac("sha256", SSO_SHARED_SECRET)
.update(signPayload)
.digest("hex");
const response = await fetch(`${FLEET_API_URL}/api/auth/sso`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: userId,
user_name: userName,
company_id: companyId,
timestamp,
signature,
}),
});
const data = await response.json();
if (!response.ok || !data.success) {
return NextResponse.json(
{ error: data.message || "SSO 토큰 발급 실패" },
{ status: response.status },
);
}
return NextResponse.json({ token: data.data.token });
} catch (error) {
console.error("[fleet-sso] 토큰 발급 에러:", error);
return NextResponse.json({ error: "서버 오류" }, { status: 500 });
}
}
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
try {
const cookieStore = await cookies();
const token =
cookieStore.get("authToken")?.value || cookieStore.get("session_token")?.value || cookieStore.get("token")?.value;
if (!token) {
return NextResponse.json({ error: "로그인이 필요합니다." }, { status: 401 });
}
return NextResponse.json({ token });
} catch {
return NextResponse.json({ error: "서버 오류" }, { status: 500 });
}
}

Some files were not shown because too many files have changed in this diff Show More