diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 9c164e37..3b35c385 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -179,6 +179,7 @@ import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관 import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식) import devPartRoutes from "./routes/devPartRoutes"; // 개발관리>PART 등록/조회 (wace_plm 도메인 이식) import devBomRoutes from "./routes/devBomRoutes"; // 개발관리>E-BOM 등록/조회 (wace_plm 도메인 이식) +import devEoHistoryRoutes from "./routes/devEoHistoryRoutes"; // 개발관리>설계변경 리스트 (wace_plm 도메인 이식) import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목) import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리 import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리 @@ -427,6 +428,7 @@ app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진 app.use("/api/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인) app.use("/api/development", devPartRoutes); // 개발관리>PART 등록/조회 (wace_plm 도메인) app.use("/api/development", devBomRoutes); // 개발관리>E-BOM 등록/조회 (wace_plm 도메인) +app.use("/api/development", devEoHistoryRoutes); // 개발관리>설계변경 리스트 (wace_plm 도메인) app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/devEoHistoryController.ts b/backend-node/src/controllers/devEoHistoryController.ts new file mode 100644 index 00000000..1ff1af29 --- /dev/null +++ b/backend-node/src/controllers/devEoHistoryController.ts @@ -0,0 +1,39 @@ +// ============================================================ +// 개발관리 설계변경 리스트 (M5) 컨트롤러 — read-only. +// GET /api/development/eo-history/list +// GET /api/development/eo-history/:objid +// ============================================================ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as svc from "../services/devEoHistoryService"; +import { logger } from "../utils/logger"; + +function parseListFilter(q: Record): svc.EoHistoryListFilter { + const filter: svc.EoHistoryListFilter = { ...q }; + if (q.page) filter.page = Number(q.page); + if (q.page_size) filter.page_size = Number(q.page_size); + return filter; +} + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const data = await svc.list(parseListFilter(req.query as Record)); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("설계변경 리스트 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} + +export async function getByObjid(req: AuthenticatedRequest, res: Response) { + try { + const { objid } = req.params; + const row = await svc.getByObjid(objid); + if (!row) return res.status(404).json({ success: false, message: "not_found" }); + return res.json({ success: true, data: row }); + } catch (e: any) { + logger.error("설계변경 상세 실패", { error: e.message }); + return res.status(500).json({ success: false, message: e.message }); + } +} diff --git a/backend-node/src/routes/devEoHistoryRoutes.ts b/backend-node/src/routes/devEoHistoryRoutes.ts new file mode 100644 index 00000000..6ea255c0 --- /dev/null +++ b/backend-node/src/routes/devEoHistoryRoutes.ts @@ -0,0 +1,16 @@ +// ============================================================ +// 개발관리 설계변경 리스트 (M5) — read-only 라우트. +// app.ts: app.use("/api/development", devEoHistoryRoutes) — prefix 공유. +// ============================================================ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/devEoHistoryController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/eo-history/list", ctrl.getList); +router.get("/eo-history/:objid", ctrl.getByObjid); + +export default router; diff --git a/backend-node/src/services/devEoHistoryService.ts b/backend-node/src/services/devEoHistoryService.ts new file mode 100644 index 00000000..3c874444 --- /dev/null +++ b/backend-node/src/services/devEoHistoryService.ts @@ -0,0 +1,155 @@ +// ============================================================ +// 개발관리 설계변경 리스트 (M5) — wace partMng.xml#partMngHistList 1:1 read-only. +// +// 매퍼 매핑: +// partMngHistList (line 7,787) → list() +// + getByObjid() — 행 클릭 상세 다이얼로그용 (raw PART_MNG_HISTORY 행) +// +// vexplor_rps 적응: +// · NVL() → COALESCE() +// · PART_MNG.OBJID(bigint) = PM.PARENT_PART_NO(varchar) → ::varchar cast +// · CODE_NAME() → LEFT JOIN comm_code 별칭 +// ============================================================ + +import { getPool } from "../database/db"; + +export interface EoHistoryListFilter { + Year?: string; + contract_objid?: string; + unit_code?: string; + part_no?: string; + part_name?: string; + change_option?: string; + eo_start_date?: string; + eo_end_date?: string; + change_type?: string; + part_type?: string; + writer_id?: string; + page?: number; + page_size?: number; +} + +function buildWhere(filter: EoHistoryListFilter, startIdx: number) { + const params: any[] = []; + const conds: string[] = []; + let idx = startIdx; + + if (filter.Year) { conds.push(`TO_CHAR(CM.REGDATE,'YYYY') = $${idx++}`); params.push(filter.Year); } + if (filter.contract_objid) { conds.push(`CM.OBJID = $${idx++}`); params.push(filter.contract_objid); } + if (filter.unit_code) { conds.push(`B.UNIT_CODE = $${idx++}`); params.push(filter.unit_code); } + if (filter.part_no) { conds.push(`UPPER(PM.PART_NO) LIKE UPPER($${idx++})`); params.push(`%${filter.part_no}%`); } + if (filter.part_name) { conds.push(`UPPER(PM.PART_NAME) LIKE UPPER($${idx++})`); params.push(`%${filter.part_name}%`); } + if (filter.change_option) { conds.push(`PM.CHANGE_OPTION = $${idx++}`); params.push(filter.change_option); } + if (filter.eo_start_date) { conds.push(`TO_DATE(PM.EO_DATE,'YYYY-MM-DD') >= TO_DATE($${idx++},'YYYY-MM-DD')`); params.push(filter.eo_start_date); } + if (filter.eo_end_date) { conds.push(`TO_DATE(PM.EO_DATE,'YYYY-MM-DD') <= TO_DATE($${idx++},'YYYY-MM-DD')`); params.push(filter.eo_end_date); } + if (filter.change_type) { conds.push(`PM.CHANGE_TYPE = $${idx++}`); params.push(filter.change_type); } + if (filter.part_type) { conds.push(`PM.PART_TYPE = $${idx++}`); params.push(filter.part_type); } + if (filter.writer_id) { conds.push(`PM.WRITER = $${idx++}`); params.push(filter.writer_id); } + + return { sql: conds.length ? conds.join(" AND ") : "1=1", params }; +} + +function paginate(filter: { page?: number; page_size?: number }) { + const page = Math.max(1, Number(filter.page) || 1); + const pageSize = Math.min(500, Math.max(1, Number(filter.page_size) || 50)); + return { limit: pageSize, offset: (page - 1) * pageSize, page, pageSize }; +} + +// ─── 그리드 ──────────────────────────────────────────────── + +export async function list(filter: EoHistoryListFilter) { + const { limit, offset, page, pageSize } = paginate(filter); + const where = buildWhere(filter, 1); + const pool = getPool(); + + const baseSql = ` + SELECT + PM.OBJID, + PM.EO_NO, + TO_CHAR(CM.REGDATE, 'YYYY') AS YEAR, + COALESCE(CM.CUSTOMER_PROJECT_NAME, CM2.CUSTOMER_PROJECT_NAME) AS PROJECT_NAME, + COALESCE(CM2.PROJECT_NO, CM.PROJECT_NO) AS PROJECT_NO, + (SELECT SP.PART_NO || ' ' || SP.PART_NAME + FROM PART_MNG SP + WHERE SP.OBJID::varchar = PM.PARENT_PART_NO) AS PARENT_PART_INFO, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.PART_NO,'') || '->' || COALESCE(PM.CHG_PART_NO,'') + ELSE PM.PART_NO END AS PART_NO_DISP, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.PART_NAME,'') || '->' || + COALESCE((SELECT P.PART_NAME FROM PART_MNG P WHERE P.OBJID::varchar = PM.CHG_PART_OBJID), '') + ELSE PM.PART_NAME END AS PART_NAME_DISP, + PM.PART_NO, + PM.PART_NAME, + PM.BOM_QTY_STATUS, + CASE WHEN PM.BOM_QTY_STATUS = 'adding' THEN PM.QTY_TEMP ELSE PM.QTY END AS QTY, + CASE WHEN PM.BOM_QTY_STATUS = 'adding' THEN '' + WHEN PM.BOM_QTY_STATUS = 'beforeEdit' AND PM.QTY = PM.QTY_TEMP THEN '' + ELSE PM.QTY_TEMP END AS QTY_TEMP, + PM.CHANGE_TYPE, + CC_CHGTYPE.code_name AS CHANGE_TYPE_NAME, + PM.CHANGE_OPTION, + CC_CHGOPT.code_name AS CHANGE_OPTION_NAME, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.REVISION,'') || '->' || COALESCE(PM.CHG_PART_REV,'') + ELSE PM.REVISION END AS REVISION_DISP, + PM.REVISION, + PM.EO_DATE, + PM.PART_TYPE, + CC_PARTTYPE.code_name AS PART_TYPE_NAME, + PM.WRITER, + UI.user_name AS WRITER_NAME, + COALESCE(WTS.UNIT_NO || '-' || WTS.TASK_NAME, '') AS UNIT_NAME, + TO_CHAR(PM.HIS_REG_DATE, 'YYYY-MM-DD') AS HIS_REG_DATE_TITLE, + PM.BOM_DEPLOY_DATE, + TO_CHAR(PM.BOM_DEPLOY_DATE, 'YYYY-MM-DD') AS BOM_DEPLOY_DATE_TITLE + FROM PART_MNG_HISTORY PM + LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN PART_BOM_REPORT B ON B.OBJID = PM.BOM_REPORT_OBJID + LEFT JOIN PROJECT_MGMT CM2 ON CM2.OBJID = B.CONTRACT_OBJID + LEFT JOIN PMS_WBS_TASK WTS ON WTS.OBJID = B.UNIT_CODE + LEFT JOIN user_info UI ON UI.user_id = PM.WRITER + LEFT JOIN COMM_CODE CC_CHGTYPE ON CC_CHGTYPE.code_id = PM.CHANGE_TYPE AND CC_CHGTYPE.status='active' + LEFT JOIN COMM_CODE CC_CHGOPT ON CC_CHGOPT.code_id = PM.CHANGE_OPTION AND CC_CHGOPT.status='active' + LEFT JOIN COMM_CODE CC_PARTTYPE ON CC_PARTTYPE.code_id = PM.PART_TYPE AND CC_PARTTYPE.status='active' + WHERE NOT (PM.HIS_STATUS = 'DEPLOY' AND PM.CHANGE_TYPE IS NULL AND PM.REVISION = 'RE') + AND PM.REVISION IS NOT NULL + AND COALESCE(PM.BOM_STATUS, '') = 'deploy' + AND ${where.sql} + `; + + const dataSql = `${baseSql} + ORDER BY COALESCE(PM.HIS_REG_DATE, PM.REG_DATE) DESC, PM.PART_NO + LIMIT $${where.params.length + 1} OFFSET $${where.params.length + 2}`; + const countSql = `SELECT COUNT(*)::int AS total FROM (${baseSql}) X`; + + const [dataRes, countRes] = await Promise.all([ + pool.query(dataSql, [...where.params, limit, offset]), + pool.query(countSql, where.params), + ]); + + return { rows: dataRes.rows, total: countRes.rows[0]?.total ?? 0, page, pageSize }; +} + +// ─── 단건 ──────────────────────────────────────────────── + +export async function getByObjid(objid: string) { + const sql = ` + SELECT PM.*, + CC_CHGTYPE.code_name AS change_type_name, + CC_CHGOPT.code_name AS change_option_name, + CC_PARTTYPE.code_name AS part_type_name, + UI.user_name AS writer_name, + CM.customer_project_name, + CM.project_no + FROM PART_MNG_HISTORY PM + LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN user_info UI ON UI.user_id = PM.WRITER + LEFT JOIN COMM_CODE CC_CHGTYPE ON CC_CHGTYPE.code_id = PM.CHANGE_TYPE AND CC_CHGTYPE.status='active' + LEFT JOIN COMM_CODE CC_CHGOPT ON CC_CHGOPT.code_id = PM.CHANGE_OPTION AND CC_CHGOPT.status='active' + LEFT JOIN COMM_CODE CC_PARTTYPE ON CC_PARTTYPE.code_id = PM.PART_TYPE AND CC_PARTTYPE.status='active' + WHERE PM.OBJID = $1::numeric + `; + const r = await getPool().query(sql, [objid]); + return r.rows[0] ?? null; +} diff --git a/docs/migration/development/03-eo-history.md b/docs/migration/development/03-eo-history.md new file mode 100644 index 00000000..2ef4024c --- /dev/null +++ b/docs/migration/development/03-eo-history.md @@ -0,0 +1,181 @@ +# PR-C : 설계변경 리스트 구현 명세 (M5) + +> 작성: 2026-05-12 / 범위: 개발관리 M5 (설계변경 리스트) — read-only `part_mng_history` 조회. + +--- + +## 1. 매퍼 쿼리 1:1 매핑 + +| Query id | Line | 본 PR 매핑 | 용도 | +|---|---:|---|---| +| `partMngHistList` | 7,787 | `GET /api/development/eo-history/list` | M5 그리드 (read-only) | + +추가 엔드포인트: +- `GET /api/development/eo-history/:objid` — 행 클릭 상세 다이얼로그용 (raw row 반환). + +--- + +## 2. API 명세 + +### 2.1 그리드 — `GET /api/development/eo-history/list` + +**Query** (wace 10 필터 1:1): +``` +Year?: string // TO_CHAR(project_mgmt.regdate, 'YYYY') +contract_objid?: string // project_mgmt.objid +unit_code?: string // part_bom_report.unit_code +part_no?: string // LIKE +part_name?: string // LIKE +change_option?: string // comm_code id (EO사유) +eo_start_date?: string +eo_end_date?: string +change_type?: string // comm_code id (EO구분) +part_type?: string // comm_code id +writer_id?: string +page?, page_size? +``` + +**SQL** (wace `partMngHistList` 1:1, vexplor 적응): +```sql +SELECT + PM.OBJID, + PM.EO_NO, + TO_CHAR(CM.REGDATE, 'YYYY') AS YEAR, + COALESCE(CM.CUSTOMER_PROJECT_NAME, CM2.CUSTOMER_PROJECT_NAME) AS PROJECT_NAME, + COALESCE(CM2.PROJECT_NO, CM.PROJECT_NO) AS PROJECT_NO, + /* 모품번: PART_MNG SP에서 PARENT_PART_NO로 조회. PART_MNG.OBJID는 bigint → varchar cast */ + (SELECT SP.PART_NO || ' ' || SP.PART_NAME + FROM PART_MNG SP + WHERE SP.OBJID::varchar = PM.PARENT_PART_NO) AS PARENT_PART_INFO, + /* 품번변경(0001790) 시 'A->B' 머지 */ + CASE WHEN PM.CHANGE_OPTION = '0001790' THEN COALESCE(PM.PART_NO,'') || '->' || COALESCE(PM.CHG_PART_NO,'') + ELSE PM.PART_NO END AS PART_NO_DISP, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.PART_NAME,'') || '->' || + COALESCE((SELECT P.PART_NAME FROM PART_MNG P WHERE P.OBJID::varchar = PM.CHG_PART_OBJID), '') + ELSE PM.PART_NAME END AS PART_NAME_DISP, + PM.PART_NO, -- raw + PM.PART_NAME, -- raw + PM.BOM_QTY_STATUS, + CASE WHEN PM.BOM_QTY_STATUS = 'adding' THEN PM.QTY_TEMP ELSE PM.QTY END AS QTY, + CASE WHEN PM.BOM_QTY_STATUS = 'adding' THEN '' + WHEN PM.BOM_QTY_STATUS = 'beforeEdit' AND PM.QTY = PM.QTY_TEMP THEN '' + ELSE PM.QTY_TEMP END AS QTY_TEMP, + PM.CHANGE_TYPE, + CC_CHGTYPE.code_name AS CHANGE_TYPE_NAME, + PM.CHANGE_OPTION, + CC_CHGOPT.code_name AS CHANGE_OPTION_NAME, + CASE WHEN PM.CHANGE_OPTION = '0001790' + THEN COALESCE(PM.REVISION,'') || '->' || COALESCE(PM.CHG_PART_REV,'') + ELSE PM.REVISION END AS REVISION_DISP, + PM.REVISION, -- raw + PM.EO_DATE, + PM.PART_TYPE, + CC_PARTTYPE.code_name AS PART_TYPE_NAME, + PM.WRITER, + UI.user_name AS WRITER_NAME, + COALESCE(WTS.UNIT_NO || '-' || WTS.TASK_NAME, '') AS UNIT_NAME, + TO_CHAR(PM.HIS_REG_DATE, 'YYYY-MM-DD') AS HIS_REG_DATE_TITLE, + PM.BOM_DEPLOY_DATE, + TO_CHAR(PM.BOM_DEPLOY_DATE, 'YYYY-MM-DD') AS BOM_DEPLOY_DATE_TITLE + FROM PART_MNG_HISTORY PM + LEFT JOIN PROJECT_MGMT CM ON CM.OBJID = PM.CONTRACT_OBJID + LEFT JOIN PART_BOM_REPORT B ON B.OBJID = PM.BOM_REPORT_OBJID + LEFT JOIN PROJECT_MGMT CM2 ON CM2.OBJID = B.CONTRACT_OBJID + LEFT JOIN PMS_WBS_TASK WTS ON WTS.OBJID = B.UNIT_CODE + LEFT JOIN user_info UI ON UI.user_id = PM.WRITER + LEFT JOIN COMM_CODE CC_CHGTYPE ON CC_CHGTYPE.code_id = PM.CHANGE_TYPE AND CC_CHGTYPE.status='active' + LEFT JOIN COMM_CODE CC_CHGOPT ON CC_CHGOPT.code_id = PM.CHANGE_OPTION AND CC_CHGOPT.status='active' + LEFT JOIN COMM_CODE CC_PARTTYPE ON CC_PARTTYPE.code_id = PM.PART_TYPE AND CC_PARTTYPE.status='active' + WHERE 1=1 + /* 파트 신규등록건 조회 제외 — wace 1:1 */ + AND NOT (PM.HIS_STATUS = 'DEPLOY' AND PM.CHANGE_TYPE IS NULL AND PM.REVISION = 'RE') + AND PM.REVISION IS NOT NULL + AND COALESCE(PM.BOM_STATUS, '') = 'deploy' + + 동적 (위 10 필터) + ORDER BY COALESCE(PM.HIS_REG_DATE, PM.REG_DATE) DESC, PM.PART_NO +``` + +**Response**: `{ rows: EoHistoryRow[], total, page, pageSize }` + +### 2.2 단건 — `GET /api/development/eo-history/:objid` + +`SELECT PM.* FROM PART_MNG_HISTORY PM WHERE PM.OBJID = $1::numeric` + comm_code JOIN (CHANGE_TYPE/OPTION/PART_TYPE 라벨). + +--- + +## 3. Backend 파일 구조 + +``` +backend-node/src/ + routes/devEoHistoryRoutes.ts // 2 endpoint + controllers/devEoHistoryController.ts + services/devEoHistoryService.ts // list + getByObjid +``` + +`app.ts`: `app.use("/api/development", devEoHistoryRoutes)`. + +--- + +## 4. Frontend 파일 구조 + +``` +frontend/ + app/(main)/COMPANY_16/development/change-list/page.tsx + components/development/PartHisDetailDialog.tsx + lib/api/devEoHistory.ts +``` + +### 4.1 그리드 16셀 (wace partMngHisList.jsp:50~75 1:1) + +| key | 라벨 | 정렬 | 너비 | +|---|---|---|---:| +| eo_no | EO No | left | 100 | +| project_no | 프로젝트번호 | left | 120 | +| project_name | 프로젝트명 | left | 180 | +| unit_name | 유닛명 | left | 160 | +| parent_part_info | 모품번 | left | 160 | +| part_no_disp | 품번 | left | 160 | +| part_name_disp | 품명 | left | flex | +| qty | 수량 | right | 70 | +| qty_temp | 변경수량 | right | 80 | +| change_type_name | EO구분 | center | 90 | +| change_option_name | EO사유 | center | 100 | +| revision_disp | Revision | center | 90 | +| eo_date | EO Date | center | 100 | +| part_type_name | PART구분 | center | 90 | +| writer_name | 담당자 | center | 90 | +| his_reg_date_title | 실행일 | center | 100 | + +### 4.2 검색 폼 (wace 10 필드 1:1) + +Year · contract_objid · unit_code · part_no · part_name · change_option · eo_start_date~end_date · change_type · part_type · writer_id + +본 PR 1차: Year · 프로젝트 OBJID · part_no · part_name · eo_start_date~end_date · change_type · change_option · part_type (Smart 폼). writer_id 는 UserSelect 별 PR. + +### 4.3 액션 + +- 조회 (only) — read-only 메뉴 +- 행 클릭 → PartHisDetailDialog (모든 필드 disabled) + +--- + +## 5. 검증 시나리오 + +1. M5 페이지 진입 → part_mng_history 빈 그리드 (시드 후 표시 확인) +2. PART 등록(M1) → 확정(deploy) 트랜잭션으로 part_mng_history INSERT (PR-A 의 deploy 경로) + - 단, deploy 시 `bom_status` 가 NULL 이므로 본 SQL의 `bom_status='deploy'` 필터에서 제외됨 + - 본 메뉴는 **BOM 변경 이력** 표시 목적 — 실제 데이터는 M3 BOM 배포 시 INSERT됨 (PR-B 범위 밖) +3. 검색 필터(year/part_no/change_type/eo_start_date) → 동적 WHERE 적용 확인 +4. 행 클릭 → 상세 다이얼로그에 모든 필드 표시 (raw + 라벨) + +--- + +## 6. 적응 사항 + +| # | 항목 | 변경 | +|---|---|---| +| 1 | `NVL()` → `COALESCE()` | wace는 Oracle 호환 NVL 사용, PG 표준은 COALESCE | +| 2 | `PART_MNG.OBJID = PM.PARENT_PART_NO` JOIN | OBJID bigint vs PARENT_PART_NO varchar → cast `::varchar` | +| 3 | `CODE_NAME()` 함수 | LEFT JOIN comm_code 별칭으로 풀어 쓰기 | +| 4 | wace `STATUS_NQ`/`SEARCH_TYPE=CHANGE_LIST` 필터 제거 | 본 PR 메뉴는 변경리스트 한 화면 — 필터 단순화 | diff --git a/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx new file mode 100644 index 00000000..405f5d0a --- /dev/null +++ b/frontend/app/(main)/COMPANY_16/development/change-list/page.tsx @@ -0,0 +1,187 @@ +"use client"; + +// 개발관리 > 설계변경 리스트 (M5, read-only) — wace partMngHisList.jsp 1:1 +// 그리드: part_mng_history 16셀 +// 검색: 8 필드 (1차) — Year/contract_objid/part_no/part_name/eo_start~end/change_type/change_option/part_type +// 참조: docs/migration/development/03-eo-history.md + +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Search, Loader2, RotateCcw } from "lucide-react"; +import { toast } from "sonner"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { CommCodeSelect } from "@/components/common/CommCodeSelect"; +import { devEoHistoryApi, EoHistoryListFilter, EoHistoryRow } from "@/lib/api/devEoHistory"; +import { PartHisDetailDialog } from "@/components/development/PartHisDetailDialog"; + +// comm_code 그룹 (vexplor_rps) +const GROUP_PART_TYPE = "0000062"; +// change_type/change_option은 wace 운영판 그룹 ID가 명확하지 않으므로 text input으로 우선 운영. +// (시드 후 그룹 ID 확인되면 SmartSelect 전환) + +const YEAR_OPTIONS = (() => { + const cur = new Date().getFullYear(); + const arr: string[] = []; + for (let y = cur + 4; y >= cur - 8; y--) arr.push(String(y)); + return arr; +})(); + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "eo_no", label: "EO No", width: "w-[100px]", frozen: true }, + { key: "project_no", label: "프로젝트번호", width: "w-[120px]" }, + { key: "project_name", label: "프로젝트명", width: "w-[180px]" }, + { key: "unit_name", label: "유닛명", width: "w-[160px]" }, + { key: "parent_part_info", label: "모품번", width: "w-[160px]" }, + { key: "part_no_disp", label: "품번", width: "w-[160px]" }, + { key: "part_name_disp", label: "품명", minWidth: "min-w-[180px]" }, + { key: "qty", label: "수량", width: "w-[70px]", align: "right", formatNumber: true }, + { key: "qty_temp", label: "변경수량", width: "w-[80px]", align: "right", formatNumber: true }, + { key: "change_type_name", label: "EO구분", width: "w-[90px]", align: "center" }, + { key: "change_option_name", label: "EO사유", width: "w-[100px]", align: "center" }, + { key: "revision_disp", label: "Revision", width: "w-[90px]", align: "center" }, + { key: "eo_date", label: "EO Date", width: "w-[100px]", align: "center" }, + { key: "part_type_name", label: "PART구분", width: "w-[90px]", align: "center" }, + { key: "writer_name", label: "담당자", width: "w-[90px]", align: "center" }, + { key: "his_reg_date_title", label: "실행일", width: "w-[100px]", align: "center" }, +]; + +const EMPTY_FILTER: EoHistoryListFilter = { + Year: "", contract_objid: "", + part_no: "", part_name: "", + change_option: "", change_type: "", part_type: "", + eo_start_date: "", eo_end_date: "", + page: 1, page_size: 50, +}; + +export default function EoHistoryPage() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filter, setFilter] = useState(EMPTY_FILTER); + + const [detailOpen, setDetailOpen] = useState(false); + const [detailObjid, setDetailObjid] = useState(null); + + const fetchList = useCallback(async (override?: Partial) => { + setLoading(true); + try { + const f = { ...filter, ...override }; + const res = await devEoHistoryApi.list(f); + setRows(res.rows ?? []); + setTotal(res.total ?? 0); + } catch (e: any) { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + } finally { + setLoading(false); + } + }, [filter]); + + useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []); + + // EO_NO 셀 클릭 → 상세 다이얼로그 + const columns = useMemo( + () => GRID_COLUMNS.map((c) => + c.key === "eo_no" + ? { ...c, onClick: (row: any) => { setDetailObjid(row.objid); setDetailOpen(true); } } + : c, + ), + [], + ); + + return ( +
+
+
+ + + + + setFilter({ ...filter, contract_objid: e.target.value })} + placeholder="project_mgmt.objid" /> + + + setFilter({ ...filter, part_no: e.target.value })} + placeholder="part_no LIKE" /> + + + setFilter({ ...filter, part_name: e.target.value })} + placeholder="part_name LIKE" /> + + + + setFilter({ ...filter, eo_start_date: e.target.value })} /> + + + setFilter({ ...filter, eo_end_date: e.target.value })} /> + + + setFilter({ ...filter, part_type: v })} /> + + +
+ setFilter({ ...filter, change_type: e.target.value })} + placeholder="EO구분 code_id" /> + setFilter({ ...filter, change_option: e.target.value })} + placeholder="EO사유 code_id" /> +
+
+
+
+
총 {total.toLocaleString()}건 (read-only)
+
+ + +
+
+
+ +
+ +
+ + +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/frontend/components/development/PartHisDetailDialog.tsx b/frontend/components/development/PartHisDetailDialog.tsx new file mode 100644 index 00000000..9323fb6a --- /dev/null +++ b/frontend/components/development/PartHisDetailDialog.tsx @@ -0,0 +1,148 @@ +"use client"; + +// 개발관리 > 설계변경 리스트 상세 다이얼로그 (read-only). +// wace partMngHisDetailPopUp.jsp 1:1 — 모든 필드 disabled. + +import React, { useEffect, useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { devEoHistoryApi, EoHistoryDetail } from "@/lib/api/devEoHistory"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + objid: string | null; +} + +export function PartHisDetailDialog({ open, onOpenChange, objid }: Props) { + const [row, setRow] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open || !objid) return; + let alive = true; + setLoading(true); + devEoHistoryApi.detail(objid) + .then((data) => { if (alive) setRow(data); }) + .catch((e: any) => { + toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패"); + onOpenChange(false); + }) + .finally(() => { if (alive) setLoading(false); }); + return () => { alive = false; }; + }, [open, objid, onOpenChange]); + + return ( + + + + 설계변경 상세 정보 (PART_MNG_HISTORY) + + + {loading || !row ? ( +
+ +
+ ) : ( +
+
+ + + + + + + + + + +
+ +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + +
+
+ )} + + + + +
+
+ ); +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +function Row({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function V({ label, value, align }: { label: string; value: any; align?: "left" | "center" | "right" }) { + const cls = align === "right" ? "text-right" : align === "center" ? "text-center" : ""; + return ( +
+ {label && } +
+ {value != null && value !== "" ? value : } +
+
+ ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index 25684a8d..3418f123 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -111,6 +111,7 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_16/development/part-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/part-search/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/development/ebom-regist": dynamic(() => import("@/app/(main)/COMPANY_16/development/ebom-regist/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/development/ebom-search": dynamic(() => import("@/app/(main)/COMPANY_16/development/ebom-search/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_16/development/change-list": dynamic(() => import("@/app/(main)/COMPANY_16/development/change-list/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/result": dynamic(() => import("@/app/(main)/COMPANY_16/production/result/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), diff --git a/frontend/lib/api/devEoHistory.ts b/frontend/lib/api/devEoHistory.ts new file mode 100644 index 00000000..f1f77d5b --- /dev/null +++ b/frontend/lib/api/devEoHistory.ts @@ -0,0 +1,106 @@ +import { apiClient } from "./client"; + +// ============================================================ +// 개발관리 설계변경 리스트 (M5, read-only) — wace partMngHistList 1:1 +// 라우트: /api/development/eo-history/* +// ============================================================ + +export interface EoHistoryListFilter { + Year?: string; + contract_objid?: string; + unit_code?: string; + part_no?: string; + part_name?: string; + change_option?: string; + eo_start_date?: string; + eo_end_date?: string; + change_type?: string; + part_type?: string; + writer_id?: string; + page?: number; + page_size?: number; +} + +export interface EoHistoryRow { + objid: string; + eo_no: string | null; + year: string | null; + project_no: string | null; + project_name: string | null; + unit_name: string | null; + parent_part_info: string | null; + part_no_disp: string | null; + part_name_disp: string | null; + part_no: string | null; + part_name: string | null; + bom_qty_status: string | null; + qty: string | null; + qty_temp: string | null; + change_type: string | null; + change_type_name: string | null; + change_option: string | null; + change_option_name: string | null; + revision_disp: string | null; + revision: string | null; + eo_date: string | null; + part_type: string | null; + part_type_name: string | null; + writer: string | null; + writer_name: string | null; + his_reg_date_title: string | null; + bom_deploy_date: string | null; + bom_deploy_date_title: string | null; +} + +export interface EoHistoryListResponse { + rows: EoHistoryRow[]; + total: number; + page: number; + pageSize: number; +} + +export interface EoHistoryDetail extends EoHistoryRow { + // raw PART_MNG_HISTORY 추가 필드 + product_mgmt_objid?: string | null; + upg_no?: string | null; + unit?: string | null; + spec?: string | null; + material?: string | null; + weight?: string | null; + remark?: string | null; + es_spec?: string | null; + ms_spec?: string | null; + design_apply_point?: string | null; + management_flag?: string | null; + status?: string | null; + reg_date?: string | null; + is_last?: string | null; + sourcing_code?: string | null; + sub_material?: string | null; + thickness?: string | null; + width?: string | null; + height?: string | null; + out_diameter?: string | null; + in_diameter?: string | null; + length?: string | null; + supply_code?: string | null; + contract_objid?: string | null; + maker?: string | null; + his_status?: string | null; + bom_status?: string | null; + heat_treatment_hardness?: string | null; + heat_treatment_method?: string | null; + surface_treatment?: string | null; + customer_project_name?: string | null; +} + +export const devEoHistoryApi = { + async list(filter: EoHistoryListFilter = {}): Promise { + const res = await apiClient.get("/development/eo-history/list", { params: filter }); + return res.data?.data as EoHistoryListResponse; + }, + async detail(objid: string): Promise { + const res = await apiClient.get(`/development/eo-history/${objid}`); + return res.data?.data ?? null; + }, +};