프로젝트관리>진행관리 메뉴 신설 — wace projectMgmtWbsList3 1:1 이식

· 그리드: 8그룹 18셀 평탄화 (운영판 projectMgmtWbsGridList SQL 1:1)
· 검색폼: 11필드 (년도/프로젝트번호/주문유형/고객사/제품구분/요청납기/국내해외/유무상/품번/품명/S/N)
· 영업관리 패턴 통일: PartSelect 단일 search_partObjId, customer_mng 단일 LEFT JOIN
· client_mng/supply_mng 분기 → customer_mng로 흡수, CODE_NAME() 함수 직접 사용
· 행 클릭 동작은 P1.5 별도 결정 (영업관리 OrderRegistDialog 재사용 후보)
· PMS_WBS_TASK/SETUP_WBS_TASK 의존 컬럼은 그리드 표시 컬럼에 없어 P2(WBS관리) 보류

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-11 18:22:06 +09:00
parent b17d7b063d
commit a1ace22626
8 changed files with 938 additions and 0 deletions
+2
View File
@@ -175,6 +175,7 @@ import salesEstimateRoutes from "./routes/salesEstimateRoutes"; // 영업관리>
import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관리>주문서 (wace_plm 도메인 이식)
import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인)
import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers)
import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식)
import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목)
import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리
import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리
@@ -419,6 +420,7 @@ app.use("/api/sales/estimate", salesEstimateRoutes); // 영업관리>견적 (wac
app.use("/api/sales/order-mgmt", salesOrderMgmtRoutes); // 영업관리>주문서 (wace_plm 도메인)
app.use("/api/sales", salesSaleRoutes); // 영업관리>판매+매출 (wace_plm 도메인)
app.use("/api/sales", salesCommonRoutes); // 영업관리 공통 옵션 (codes/parts/customers)
app.use("/api/project/progress", projectMgmtRoutes); // 프로젝트관리>진행관리 (wace_plm 도메인)
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
@@ -0,0 +1,49 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/projectMgmtService";
import { logger } from "../utils/logger";
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const filter = req.query as Record<string, string>;
const data = await svc.listProgress(filter);
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 getProjectNoOptions(_req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listProjectNoOptions();
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 getById(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const data = await svc.getById(id);
if (!data) return res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." });
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 update(req: AuthenticatedRequest, res: Response) {
try {
const { id } = req.params;
const rowCount = await svc.updateProject(id, req.body);
if (rowCount === 0) return res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." });
return res.json({ success: true, message: "프로젝트가 수정되었습니다." });
} catch (e: any) {
logger.error("진행관리 수정 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
@@ -0,0 +1,13 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/projectMgmtController";
const router = Router();
router.use(authenticateToken);
router.get("/list", ctrl.getList);
router.get("/project-no-options", ctrl.getProjectNoOptions);
router.get("/:id", ctrl.getById);
router.put("/:id", ctrl.update);
export default router;
@@ -0,0 +1,331 @@
// ============================================================
// 프로젝트관리 > 진행관리 (wace_plm 도메인 이식)
// 메인 화면: wace `/project/projectMgmtWbsList3.do` (= 운영판 RPS 진행관리)
// 그리드 SQL: wace_plm/src/com/pms/mapper/project.xml:3854 projectMgmtWbsGridList
// JSP 원본: wace_plm/WebContent/WEB-INF/view/project/projectMgmtWbsList3.jsp
//
// 1:1 이식 + RPS 매핑 변경:
// · wace의 CLIENT_MNG/SUPPLY_MNG CASE WHEN LIKE 'C_%' 분기 → RPS는 customer_mng 단일 LEFT JOIN
// · CODE_NAME() 함수는 RPS DB 보유 — 그대로 사용
// · PMS_WBS_TASK/SETUP_WBS_TASK 의존 컬럼은 그리드 표시 컬럼에 없으므로 미반영 (P2)
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
export interface ProgressListFilter {
Year?: string; // 년도 (TO_CHAR(REGDATE,'YYYY'))
project_nos?: string; // wace project_nos (쉼표 직렬화 OBJID — 다중 또는 단일)
category_cd?: string;
customer_objid?: string;
product?: string;
status_cd?: string;
result_cd?: string;
contract_start_date?: string; // 요청납기 시작
contract_end_date?: string; // 요청납기 끝
area_cd?: string; // '국내'/'해외' 라벨로 비교 (wace 1:1)
free_of_charge?: string; // '유상'/'무상' 라벨로 비교
// 영업관리 패턴(search_partObjId 단일) 통일 — wace의 product_item_code/_name LIKE 두 필드는 부재화
search_partObjId?: string; // part_mng.objid::varchar = project_mgmt.part_objid
serial_no?: string; // contract_item_serial EXISTS
pm_user_id?: string; // (검색폼 주석처리되어 있으나 SQL은 받음)
location?: string;
setup?: string;
}
// ─── 목록: 운영판 projectMgmtWbsGridList 1:1 ──────────────────
export async function listProgress(filter: ProgressListFilter) {
const pool = getPool();
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
// wace L4187: Year — TO_CHAR(REGDATE,'YYYY')
if (filter.Year) {
conditions.push(`TO_CHAR(T.REGDATE,'YYYY') = $${idx++}`);
params.push(filter.Year);
}
// wace L4195: project_nos 쉼표 split → OBJID IN
if (filter.project_nos) {
const list = filter.project_nos.split(",").map(s => s.trim()).filter(Boolean);
if (list.length > 0) {
const placeholders = list.map(() => `$${idx++}`).join(",");
conditions.push(`T.OBJID IN (${placeholders})`);
params.push(...list);
}
}
if (filter.category_cd) { conditions.push(`T.CATEGORY_CD = $${idx++}`); params.push(filter.category_cd); }
if (filter.customer_objid) { conditions.push(`T.CUSTOMER_OBJID = $${idx++}`); params.push(filter.customer_objid); }
if (filter.product) { conditions.push(`T.PRODUCT = $${idx++}`); params.push(filter.product); }
if (filter.status_cd) { conditions.push(`T.STATUS_CD = $${idx++}`); params.push(filter.status_cd); }
if (filter.result_cd) { conditions.push(`T.RESULT_CD = $${idx++}`); params.push(filter.result_cd); }
// wace L4224-4237: 요청납기 범위 — COALESCE(contract_item.due_date, project_mgmt.due_date, contract_mgmt.due_date)
const dueDateCoalesce = `COALESCE(
(SELECT CI.DUE_DATE FROM contract_item CI WHERE CI.contract_objid = T.CONTRACT_OBJID AND CI.part_objid = T.PART_OBJID AND CI.status = 'ACTIVE' ORDER BY CI.objid DESC LIMIT 1),
T.DUE_DATE,
(SELECT CM.due_date FROM contract_mgmt CM WHERE CM.OBJID = T.CONTRACT_OBJID LIMIT 1)
)`;
if (filter.contract_start_date) {
conditions.push(`TO_DATE(${dueDateCoalesce},'YYYY-MM-DD') >= TO_DATE($${idx++},'YYYY-MM-DD')`);
params.push(filter.contract_start_date);
}
if (filter.contract_end_date) {
conditions.push(`TO_DATE(${dueDateCoalesce},'YYYY-MM-DD') <= TO_DATE($${idx++},'YYYY-MM-DD')`);
params.push(filter.contract_end_date);
}
if (filter.pm_user_id) { conditions.push(`T.PM_USER_ID = $${idx++}`); params.push(filter.pm_user_id); }
if (filter.location) { conditions.push(`UPPER(T.LOCATION) LIKE UPPER($${idx++})`); params.push(`%${filter.location}%`); }
if (filter.setup) { conditions.push(`UPPER(T.SETUP) LIKE UPPER($${idx++})`); params.push(`%${filter.setup}%`); }
// wace L4249: area_cd — CODE_NAME(area_cd) 라벨 비교 ('국내'/'해외')
if (filter.area_cd) {
conditions.push(`CODE_NAME(T.AREA_CD) = $${idx++}`);
params.push(filter.area_cd);
}
// wace L4253: free_of_charge — contract_mgmt.paid_type → '유상'/'무상' 매핑 비교
if (filter.free_of_charge) {
conditions.push(`(SELECT
CASE
WHEN O.PAID_TYPE = 'paid' THEN '유상'
WHEN O.PAID_TYPE = 'free' THEN '무상'
ELSE O.PAID_TYPE
END
FROM contract_mgmt AS O WHERE O.OBJID = T.CONTRACT_OBJID) = $${idx++}`);
params.push(filter.free_of_charge);
}
// 영업관리 패턴 통일: search_partObjId 단일 (PartSelect의 part_mng.objid 값 매칭)
// wace 매퍼는 product_item_code/_name LIKE 텍스트 검색이지만 RPS는 part_objid 직접 매칭.
if (filter.search_partObjId) {
conditions.push(`T.PART_OBJID = $${idx++}`);
params.push(filter.search_partObjId);
}
// wace L4271: serial_no — EXISTS contract_item_serial
if (filter.serial_no) {
conditions.push(`EXISTS (
SELECT 1 FROM contract_item_serial CIS
WHERE CIS.item_objid::varchar = T.CONTRACT_ITEM_OBJID
AND UPPER(CIS.status) = 'ACTIVE'
AND UPPER(CIS.serial_no) LIKE UPPER($${idx++})
)`);
params.push(`%${filter.serial_no}%`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
// wace projectMgmtWbsGridList 활성 본문 1:1 (그리드 표시 18셀 + 필수 부속)
const sql = `
SELECT
T.OBJID
,T.CATEGORY_CD
,CODE_NAME(T.CATEGORY_CD) AS CATEGORY_NAME
,T.CUSTOMER_OBJID
,C.customer_name AS CUSTOMER_NAME
,T.PRODUCT
,CODE_NAME(T.PRODUCT) AS PRODUCT_NAME
,T.STATUS_CD
,CODE_NAME(T.STATUS_CD) AS STATUS_NAME
,T.RESULT_CD
,CODE_NAME(T.RESULT_CD) AS RESULT_NAME
,T.DUE_DATE
,T.LOCATION
,T.SETUP
,T.FACILITY
,CODE_NAME(T.FACILITY) AS FACILITY_NAME
,T.FACILITY_QTY
,T.FACILITY_TYPE
,T.FACILITY_DEPTH
,T.PROJECT_NO
,T.PM_USER_ID
,(SELECT user_name FROM user_info WHERE user_id = (SELECT pm_user_id FROM contract_mgmt WHERE OBJID = T.CONTRACT_OBJID)) AS PM_USER_NAME
,T.CONTRACT_PRICE
,T.CONTRACT_PRICE_CURRENCY
,T.CONTRACT_CURRENCY
,CODE_NAME(T.CONTRACT_CURRENCY) AS CONTRACT_CURRENCY_NAME
,T.REGDATE
,TO_CHAR(T.REGDATE,'YYYY-MM-DD') AS REG_DATE
,T.WRITER
,(SELECT user_name FROM user_info WHERE user_id = T.WRITER) AS WRITER_NAME
,(SELECT COUNT(1) FROM attach_file_info WHERE target_objid = T.OBJID AND doc_type='contractMgmt01' AND UPPER(status)='ACTIVE') AS CU01_CNT
,(SELECT COUNT(1) FROM attach_file_info WHERE target_objid = T.OBJID AND doc_type='contractMgmt02' AND UPPER(status)='ACTIVE') AS CU02_CNT
,T.CONTRACT_NO
,T.CUSTOMER_EQUIP_NAME
,T.CONTRACT_DEL_DATE
,T.CONTRACT_COMPANY
,CODE_NAME(T.CONTRACT_COMPANY) AS CONTRACT_COMPANY_NAME
,T.CONTRACT_DATE
,T.PO_NO
,T.MANUFACTURE_PLANT
,CODE_NAME(T.MANUFACTURE_PLANT) AS MANUFACTURE_PLANT_NAME
,T.CONTRACT_RESULT
,CODE_NAME(T.CONTRACT_RESULT) AS CONTRACT_RESULT_NAME
,CODE_NAME(T.AREA_CD) AS AREA_NAME
,T.PROJECT_NAME
-- 운영판 그리드 핵심 컬럼들
,(SELECT CASE
WHEN O.PAID_TYPE = 'paid' THEN '유상'
WHEN O.PAID_TYPE = 'free' THEN '무상'
ELSE O.PAID_TYPE
END
FROM contract_mgmt AS O WHERE O.OBJID = T.CONTRACT_OBJID) AS FREE_OF_CHARGE
,COALESCE(NULLIF(T.QUANTITY,'')::numeric, 0) AS CONTRACT_QTY
,T.PART_NO AS PRODUCT_ITEM_CODE
,T.PART_NAME AS PRODUCT_ITEM_NAME
-- S/N: contract_item_serial (CIS.item_objid = T.contract_item_objid)
,(SELECT
CASE
WHEN COUNT(*) = 0 THEN ''
WHEN COUNT(*) = 1 THEN MIN(CIS.serial_no)
ELSE MIN(CIS.serial_no) || ' 외 ' || (COUNT(*) - 1)::text || '건'
END
FROM contract_item_serial CIS
WHERE CIS.item_objid::varchar = T.CONTRACT_ITEM_OBJID
AND UPPER(CIS.status) = 'ACTIVE'
AND CIS.serial_no IS NOT NULL
AND CIS.serial_no != ''
) AS SERIAL_NO
-- 요청납기: contract_item.due_date → project_mgmt.due_date → contract_mgmt.due_date
,COALESCE(
(SELECT CI.due_date FROM contract_item CI
WHERE CI.contract_objid = T.CONTRACT_OBJID
AND CI.part_objid = T.PART_OBJID
AND CI.status = 'ACTIVE'
ORDER BY CI.objid DESC LIMIT 1),
T.DUE_DATE,
(SELECT CM.due_date FROM contract_mgmt CM WHERE CM.OBJID = T.CONTRACT_OBJID LIMIT 1)
) AS REQ_DEL_DATE
,T.EBOM_STATUS
,T.MBOM_STATUS
-- 발주일: contract_mgmt.order_date
,(SELECT O.order_date FROM contract_mgmt AS O WHERE O.OBJID = T.CONTRACT_OBJID) AS ORDER_DATE
,T.RECEIVING_RATE
,T.PRODUCTION_TEAM_12
,T.PRODUCTION_TEAM_3
-- 출하일: sales_registration.shipping_date (project_no 매칭)
,COALESCE(
(SELECT TO_CHAR(SR.shipping_date, 'YYYY-MM-DD')
FROM sales_registration SR
WHERE SR.project_no = T.PROJECT_NO
ORDER BY SR.sale_no DESC LIMIT 1),
''
) AS SHIPMENT_DATE
,T.MECHANICAL_TYPE
,T.OVERHAUL_ORDER
,T.CONTRACT_OBJID
-- P2: PMS_WBS_TASK 의존 컬럼들은 그리드 표시엔 없으므로 자리만
,0 AS TOTAL_RATE
,0 AS DESIGN_RATE
,0 AS PURCHASE_RATE
,0 AS PRODUCE_RATE
,0 AS SELFINS_RATE
,0 AS FINALINS_RATE
,0 AS SHIP_RATE
,0 AS SETUP_RATE
,0 AS WBS_CNT
,NULL::text AS ASSEMBLY
,NULL::text AS VERIFICATION
FROM project_mgmt T
LEFT JOIN customer_mng C
ON C.customer_code = CASE WHEN T.CUSTOMER_OBJID LIKE 'C_%' THEN substring(T.CUSTOMER_OBJID, 3) ELSE T.CUSTOMER_OBJID END
${where}
ORDER BY SUBSTRING(T.PROJECT_NO, POSITION('-' IN T.PROJECT_NO)+1) DESC,
T.OVERHAUL_ORDER DESC NULLS LAST
`;
const res = await pool.query(sql, params);
logger.info("진행관리 목록 조회", { count: res.rowCount, filter });
return res.rows;
}
// ─── 옵션: 프로젝트번호 셀렉트박스용 ──────────────────────────
// wace `common.getCusProjectNoList` 대응 — 진행관리 메뉴 검색폼 project_no 옵션 채움.
export async function listProjectNoOptions() {
const pool = getPool();
const sql = `
SELECT OBJID AS value, PROJECT_NO AS label
FROM project_mgmt
WHERE PROJECT_NO IS NOT NULL AND PROJECT_NO != ''
ORDER BY SUBSTRING(PROJECT_NO, POSITION('-' IN PROJECT_NO)+1) DESC
`;
const res = await pool.query(sql);
return res.rows;
}
// ─── 단건/수정: P1.5에서 영업관리 OrderRegistDialog 재사용 결정 시 제거 가능 ──
export async function getById(objid: string) {
const pool = getPool();
const sql = `
SELECT
T.OBJID
,T.PROJECT_NO
,T.PROJECT_NAME
,T.FACILITY
,CODE_NAME(T.FACILITY) AS FACILITY_NAME
,T.FACILITY_QTY
,T.FACILITY_DEPTH
,T.CONTRACT_DEL_DATE
,T.CONTRACT_CURRENCY
,CODE_NAME(T.CONTRACT_CURRENCY) AS CONTRACT_CURRENCY_NAME
,T.CONTRACT_PRICE
,T.CONTRACT_PRICE_CURRENCY
FROM project_mgmt T
WHERE T.OBJID = $1
LIMIT 1
`;
const res = await pool.query(sql, [objid]);
return res.rows[0] ?? null;
}
export interface ProjectUpdateBody {
project_no?: string;
project_name?: string;
facility?: string;
facility_qty?: string;
facility_depth?: string;
contract_del_date?: string;
contract_currency?: string;
contract_price_currency?: string;
contract_price?: string;
}
export async function updateProject(objid: string, body: ProjectUpdateBody) {
const pool = getPool();
const sql = `
UPDATE project_mgmt
SET FACILITY_QTY = $1
,PROJECT_NO = $2
,CONTRACT_PRICE = $3
,CONTRACT_PRICE_CURRENCY = $4
,CONTRACT_CURRENCY = $5
,PROJECT_NAME = $6
,FACILITY = $7
,FACILITY_DEPTH = $8
,CONTRACT_DEL_DATE = $9
WHERE OBJID = $10
`;
const params = [
body.facility_qty ?? "",
body.project_no ?? "",
body.contract_price ?? "",
body.contract_price_currency ?? "",
body.contract_currency ?? "",
body.project_name ?? "",
body.facility ?? "",
body.facility_depth ?? "",
body.contract_del_date ?? "",
objid,
];
const res = await pool.query(sql, params);
logger.info("진행관리 수정", { objid, rowCount: res.rowCount });
return res.rowCount ?? 0;
}
+195
View File
@@ -0,0 +1,195 @@
# 프로젝트관리 이식 GAP 분석 (원본 wace_plm 대비)
> 작성: 2026-05-11 / 작성자: hjjeong
> **개정 2026-05-11**: 운영판 실 화면은 `projectMgmtList.jsp`(옛 화면)가 **아니라** `projectMgmtWbsList3.jsp` + 매퍼 `projectMgmtWbsGridList`인 것이 확인되어 GAP 전면 개정.
---
## 0. 한 문장 요약
운영판 진행관리는 **`/project/projectMgmtWbsList3.do`** endpoint가 반환하는 화면이고, 그리드 SQL은 매퍼 `projectMgmtWbsGridList`(project.xml:3854~4280). 그리드 컬럼은 8그룹/18셀 + 검색폼은 11필드. 의존 테이블 대부분 RPS에 보유 — `project_mgmt`/`contract_mgmt`/`contract_item`/`contract_item_serial`/`customer_mng`/`user_info`/`comm_code`/`attach_file_info`/`sales_registration`/`part_mng`. `pms_wbs_task`/`setup_wbs_task`만 부재(P2 의존, 그리드 표시 컬럼엔 영향 거의 없음). **P1에서 그리드 18셀 전부 실데이터로 채울 수 있음.**
## 0.1 이식 원칙
> JSP/Java/매퍼XML 안의 `/* */`, `<!-- -->`, `//` 주석 블록은 비활성. 활성 코드만 이식.
- 운영 화면(waceplm.esgrin.com) = 진실의 기준.
- wace 컨트롤러에 `projectMgmtList.do`(옛) + `projectMgmtList1.do` + **`projectMgmtWbsList3.do`(운영판)** 셋이 동시 존재 — 메뉴가 가리키는 것이 무엇인지 확인 필수.
- `client_mng`/`supply_mng` → RPS는 `customer_mng`로 통합. CASE WHEN LIKE 'C_%' 분기는 모두 customer_mng 단일 LEFT JOIN으로 변환.
- `CODE_NAME()` 함수 RPS DB에 존재. 다만 영업관리 패턴(`LEFT JOIN comm_code CC_X ON CC_X.code_id=...`)으로 통일.
- 금액 1,234.00 / 수량 1,234 / 모든 숫자 right-align.
---
## 1. 운영판 진행관리 흐름
### 1.1 endpoint
| URL | Controller | view / 결과 |
|---|---|---|
| `/project/projectMgmtWbsList3.do` | `projectMgmtWbsList3` (L3243) | view 반환 + 코드맵(`category_cd`/`customer_cd`/`project_no`/`product_cd`/`status_cd`/`result_cd`/`pm_user_id`) |
| `/project/projectMgmtWbsGridList.do` | (`projectMgmtWbsGridList` 매퍼 호출) | 그리드 JSON |
| `fn_openSaleRegPopup(orderNo)``/salesMgmt/salesRegForm.do?orderNo={PROJECT_NO}` | (영업관리 주문서 등록 화면) | 행 클릭 → 영업관리 주문서 등록 폼 오픈. **진행관리 자체 수정 폼 없음.** |
→ 본 이식의 RPS 측 라우트:
- `GET /api/project/progress/list` — 그리드
- 행 클릭 동작은 **P1.5에서 결정** (영업관리 `OrderRegistDialog` 재사용 vs 미연결)
### 1.2 그리드 컬럼 (운영판 그대로)
8그룹 / 18셀 (frozen 1개 포함):
| # | dataField | 라벨 | 의존 | P1 채움 |
|---|---|---|---|---|
| 1 | **PROJECT_NO** (frozen) | 프로젝트번호 | project_mgmt | ✅ |
| 2 | CATEGORY_NAME | 주문유형 | comm_code(0000167) | ✅ |
| 3 | PRODUCT_NAME | 제품구분 | comm_code(0000001) | ✅ |
| 4 | AREA_NAME | 국내/해외 | comm_code(area_cd) | ✅ |
| 5 | REG_DATE | 접수일 | TO_CHAR(regdate,'YYYY-MM-DD') | ✅ |
| 6 | CUSTOMER_NAME | 고객사 | customer_mng | ✅ |
| 7 | FREE_OF_CHARGE | 유/무상 | contract_mgmt.paid_type → '유상'/'무상' | ✅ |
| 8 | PRODUCT_ITEM_CODE | 품번 | project_mgmt.part_no | ✅ |
| 9 | PRODUCT_ITEM_NAME | 품명 | project_mgmt.part_name | ✅ |
| 10 | SERIAL_NO | S/N | contract_item_serial (CIS.item_objid=T.contract_item_objid) | ✅ |
| 11 | CONTRACT_QTY | 수주수량 | project_mgmt.quantity | ✅ |
| 12 | REQ_DEL_DATE | 요청납기 | COALESCE(contract_item.due_date, project_mgmt.due_date, contract_mgmt.due_date) | ✅ |
| 13 | EBOM_STATUS | E-BOM | project_mgmt.ebom_status | ✅ (대부분 빈값, 운영DB도 빈값) |
| 14 | MBOM_STATUS | M-BOM | project_mgmt.mbom_status | ✅ (대부분 빈값) |
| 15 | ORDER_DATE | 발주일 | contract_mgmt.order_date | ✅ |
| 16 | RECEIVING_RATE | 입고율 | project_mgmt.receiving_rate | ✅ |
| 17 | PRODUCTION_TEAM_12 | 제조1,2팀 | project_mgmt.production_team_12 | ✅ |
| 18 | PRODUCTION_TEAM_3 | 제조3팀 | project_mgmt.production_team_3 | ✅ |
| 19 | ASSEMBLY | 조립 | (wace SQL에 없음 — 빈값) | (빈값) |
| 20 | VERIFICATION | 검증 | (wace SQL에 없음 — 빈값) | (빈값) |
| 21 | SHIPMENT_DATE | 출하일 | sales_registration.shipping_date (project_no 매칭) | ✅ |
**18셀 모두 P1에서 채움 가능** (장비 조립/검증 2개는 wace 원본도 빈값). PMS_WBS_TASK 의존 컬럼들은 그리드 표시에 없음.
### 1.3 검색 폼 (11필드)
| # | name | 라벨 | 입력 | 매핑 |
|---|---|---|---|---|
| 1 | Year | 년도 | select(sysYear±4) | TO_CHAR(REGDATE,'YYYY') |
| 2 | project_nos | 프로젝트번호 | multi-select | OBJID IN (쉼표 split) |
| 3 | category_cd | 주문유형 | select(0000167) | T.CATEGORY_CD |
| 4 | customer_objid | 고객사 | select | T.CUSTOMER_OBJID |
| 5 | product | 제품구분 | select(0000001) | T.PRODUCT |
| 6 | contract_start_date / contract_end_date | 요청납기일 | date range | COALESCE(contract_item.due_date, project_mgmt.due_date, contract_mgmt.due_date) |
| 7 | area_cd | 국내/해외 | select(국내/해외 라벨) | CODE_NAME(AREA_CD) = '국내'/'해외' |
| 8 | free_of_charge | 유/무상 | select(유상/무상 라벨) | contract_mgmt.paid_type 매핑 비교 |
| 9 | product_item_code | 품번 | autocomplete | T.PART_NO ILIKE '%?%' |
| 10 | product_item_name | 품명 | autocomplete | T.PART_NAME ILIKE '%?%' |
| 11 | serial_no | S/N | text | EXISTS contract_item_serial ILIKE |
비활성(주석블록): location / setup / pm_user_id — 무시.
### 1.4 ORDER BY
```sql
ORDER BY SUBSTRING(PROJECT_NO,POSITION('-' IN PROJECT_NO)+1) DESC,
OVERHAUL_ORDER DESC NULLS LAST
```
### 1.5 행 클릭 동작
`fn_openSaleRegPopup(PROJECT_NO)``/salesMgmt/salesRegForm.do?orderNo={PROJECT_NO}` (영업관리 주문서 등록 폼). **진행관리 자체 수정 폼 없음.** RPS 이식 시 P1.5 별도 결정.
---
## 2. RPS DB 보유 매트릭스 (개정)
| 테이블 | 보유 | 진행관리에서의 용도 | P1 |
|---|---|---|---|
| project_mgmt | ✅ | 메인 (90건) | ✅ |
| contract_mgmt | ✅ | paid_type / order_date / due_date / pm_user_id | ✅ |
| **contract_item** | ✅ | 요청납기 COALESCE 1순위 | ✅ |
| **contract_item_serial** | ✅ | S/N 집계 | ✅ |
| **sales_registration** | ✅ | 출하일 COALESCE | ✅ |
| customer_mng | ✅ | 고객사명 (wace의 client_mng/supply_mng 분기 흡수) | ✅ |
| user_info | ✅ | PM명 / writer명 / chg_user 등 | ✅ |
| comm_code + CODE_NAME() | ✅ | 카테고리/제품/지역/통화 등 라벨 | ✅ |
| attach_file_info | ✅ | contractMgmt01/02 카운트 (CU01_CNT/CU02_CNT) | ✅ |
| part_mng | ✅ | (이번 P1에서는 직접 사용 안 함 — project_mgmt.part_no/part_name 직접 사용) | — |
| purchase_order_master | ✅ | (이번 P1에서는 사용 안 함 — P2의 투입원가 컬럼에서만 사용) | — |
| pms_wbs_task | ❌ | 진척율/연체 카운트 — 그리드 표시 컬럼엔 없음 | (P2) |
| setup_wbs_task | ❌ | 셋업 진척율 — 그리드 표시 컬럼엔 없음 | (P2) |
| client_mng / supply_mng | | wace 전용 (customer_mng로 흡수) | — |
**P1에서 18셀 전부 실데이터 채울 수 있음.**
---
## 3. GAP 매트릭스 (개정)
| # | 우선 | 항목 | 권장 작업 |
|---|---|---|---|
| **PRJ-1** | 🔴 | 진행관리 메뉴 자체 부재 → 운영판 화면 1:1 이식 | **본 PR (P1)** — 18셀 + 검색 11필드 |
| **PRJ-2** | 🟠 | 행 클릭 → 영업관리 주문서 등록 폼 오픈 (`fn_openSaleRegPopup`) | **P1.5**`OrderRegistDialog` 재사용 vs 미연결. 사용자 결정 |
| **PRJ-3** | 🟠 | 다중 선택 프로젝트번호 검색 (multi-select) | **본 PR** — frontend SmartSelect multi 모드, backend는 `project_nos` 쉼표 split |
| **PRJ-4** | 🟡 | 검색 코드맵 옵션 (`project_no` 다중 옵션 리스트) | wace는 코드맵에서 동적 생성. RPS는 grid 데이터 기반 옵션 또는 별도 API. **P1 보수적 처리**: 사용자가 OBJID 또는 PROJECT_NO 직접 입력 |
| **PRJ-5** | 🟢 | 엑셀 다운로드 | wace에 없음 — 본 PR 제외 |
| **PRJ-6** | 🟢 | 진척율(PMS_WBS_TASK 의존) — 그리드 표시 컬럼엔 영향 없으나 SQL은 계산 | **P2 (WBS 메뉴 이식 후)**. 본 P1은 진척율 컬럼 자체가 그리드에 없으므로 미반영 |
| **PRJ-7** | 🟢 | 설계/생산 ASSEMBLY/VERIFICATION 컬럼 (운영판 빈값) | 자리만, 빈값 |
---
## 4. P1 스코프 — 본 GAP 결정사항
### 4.1 백엔드 갱신 (`backend-node/src/`)
| 파일 | 동작 |
|---|---|
| `services/projectMgmtService.ts` | **전면 교체**`listProgress` SQL을 `projectMgmtWbsGridList` 1:1로 재작성. `getById`/`updateProject`는 P1.5에서 결정될 때까지 일단 유지 (사용 안 됨) |
| `controllers/projectMgmtController.ts` | 현행 유지 |
| `routes/projectMgmtRoutes.ts` | 현행 유지 (`GET /list` + 향후 추가 endpoint 자리) |
| `app.ts` | 현행 유지 (이미 마운트됨) |
### 4.2 프론트엔드 갱신 (`frontend/`)
| 파일 | 동작 |
|---|---|
| `app/(main)/COMPANY_16/project/progress/page.tsx` | **전면 교체** — 검색폼 11필드(2행) + 그리드 18셀(8그룹 평탄화) |
| `components/project/ProjectProgressEditDialog.tsx` | **폐기** (옛 jsp 기반). 행 클릭은 P1.5에서 결정 |
| `lib/api/projectMgmt.ts` | 검색 타입/결과 행 타입 갱신 |
| `components/layout/AdminPageRenderer.tsx` | 현행 유지 |
### 4.3 검색 옵션 컴포넌트 매핑
| 검색 필드 | RPS 컴포넌트 |
|---|---|
| Year | `<select>` (native, sysYear±4 동적) |
| project_nos | `<Input>` (쉼표 직렬화 텍스트) — 다중 SmartSelect는 P1.5에서 보강 가능 |
| category_cd / product | `<CommCodeSelect groupId="0000167"/"0000001">` |
| customer_objid | `<CustomerSelect>` |
| 요청납기 범위 | `<Input type="date">` × 2 |
| area_cd | `<select>` 정적 옵션 ('국내'/'해외') |
| free_of_charge | `<select>` 정적 옵션 ('유상'/'무상') |
| product_item_code / product_item_name | `<Input>` (자동완성은 P1.5 보강) |
| serial_no | `<Input>` |
### 4.4 본 PR에서 **하지 않을 것**
- WBS관리 메뉴(별도 PR P2)
- PMS_WBS_TASK / SETUP_WBS_TASK 의존 컬럼 (그리드 표시 컬럼엔 없으므로 영향 없음)
- 행 클릭 → 영업관리 주문서 등록 폼 라우팅 (P1.5 별도)
- 다중 select / 품번품명 자동완성 (P1.5 보강)
- 엑셀 / 결재 (운영판 없음)
---
## 5. 사용자 결정 사항 (2026-05-11)
| # | 항목 | 결정 |
|---|---|---|
| 1 | URL 경로 | `/COMPANY_16/project/progress` |
| 2 | 차종/OEM 마일스톤 | 영구 제외 |
| 3 | 운영판 1:1 재작성 범위 | **그리드 + 검색폼 모두 운영판(projectMgmtWbsList3) 기준 재작성** |
| 4 | 행 클릭 다이얼로그 | P1.5 — `OrderRegistDialog` 재사용 검토. P1에서는 미연결 |
| 5 | 진척율(WBS) 컬럼 | P2에서 — 그리드 표시 컬럼엔 없으므로 P1 영향 없음 |
---
## 6. 다음 단계
1. P1 운영판 1:1 재작성 → 사용자 동작 확인 (그리드 18셀이 실데이터로 채워지는지)
2. P1.5: 행 클릭 동작 결정 (영업관리 OrderRegistDialog 재사용 / 미연결 / 신규 다이얼로그)
3. P2: WBS관리 메뉴(`/COMPANY_16/project/wbs-template`) — PMS_WBS_* 운영DB DDL 추출 + 02-wbs.md
@@ -0,0 +1,238 @@
"use client";
// 진행관리 (wace projectMgmtWbsList3.jsp + projectMgmtWbsGridList 1:1 이식)
// 원본:
// - JSP: /Users/jhj/wace_plm/WebContent/WEB-INF/view/project/projectMgmtWbsList3.jsp (349줄)
// - 매퍼: wace_plm/src/com/pms/mapper/project.xml:3854 projectMgmtWbsGridList
// GAP: docs/migration/project/00-gap.md
//
// 그리드: 8그룹 18셀 평탄화 (DataGrid가 그룹 헤더 미지원 → 라벨 prefix로 표현)
// 검색폼: 11필드 (1행 6 + 2행 5)
// 행 클릭: P1.5에서 영업관리 OrderRegistDialog 재사용 검토 — 현재 미연결
import React, { useCallback, useEffect, 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 { CustomerSelect } from "@/components/common/CustomerSelect";
import { PartSelect } from "@/components/common/PartSelect";
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { projectMgmtApi, ProgressListFilter, ProgressRow } from "@/lib/api/projectMgmt";
// wace projectMgmtWbsList3.jsp 컬럼 정의 1:1 (8그룹 → 평탄화, 그룹명은 라벨 prefix)
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "project_no", label: "프로젝트번호", width: "w-[160px]", frozen: true },
// 프로젝트정보 그룹
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" },
{ key: "product_name", label: "제품구분", width: "w-[100px]", align: "left" },
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
{ key: "reg_date", label: "접수일", width: "w-[110px]", align: "center" },
{ key: "customer_name", label: "고객사", width: "w-[140px]" },
{ key: "free_of_charge", label: "유/무상", width: "w-[80px]", align: "center" },
{ key: "product_item_code", label: "품번", width: "w-[150px]" },
{ key: "product_item_name", label: "품명", width: "w-[180px]" },
{ key: "serial_no", label: "S/N", width: "w-[150px]" },
{ key: "contract_qty", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
{ key: "req_del_date", label: "요청납기", width: "w-[110px]", align: "center" },
// 설계
{ key: "ebom_status", label: "E-BOM", width: "w-[100px]", align: "center" },
// 생산관리
{ key: "mbom_status", label: "M-BOM", width: "w-[100px]", align: "center" },
// 구매
{ key: "order_date", label: "발주일", width: "w-[110px]", align: "center" },
{ key: "receiving_rate", label: "입고율", width: "w-[90px]", align: "right" },
// 생산
{ key: "production_team_12", label: "제조1,2팀", width: "w-[100px]", align: "center" },
{ key: "production_team_3", label: "제조3팀", width: "w-[100px]", align: "center" },
// 장비
{ key: "assembly", label: "조립", width: "w-[90px]", align: "center" },
{ key: "verification", label: "검증", width: "w-[90px]", align: "center" },
// 출하
{ key: "shipment_date", label: "출하일", width: "w-[110px]", align: "center" },
];
const CATEGORY_GROUP = "0000167"; // 주문유형
const PRODUCT_GROUP = "0000001"; // 제품구분
// wace L229: 시스템년도 ±4 (운영판은 sysYear-4 ~ sysYear). RPS는 sysYear±4로 여유.
const YEAR_OPTIONS = (() => {
const cur = new Date().getFullYear();
const arr: string[] = [];
for (let y = cur + 4; y >= cur - 4; y--) arr.push(String(y));
return arr;
})();
const EMPTY_FILTER: ProgressListFilter = {
Year: "", project_nos: "", category_cd: "", customer_objid: "", product: "",
contract_start_date: "", contract_end_date: "",
area_cd: "", free_of_charge: "",
search_partObjId: "", serial_no: "",
};
export default function ProjectProgressPage() {
const [rows, setRows] = useState<ProgressRow[]>([]);
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState<ProgressListFilter>(EMPTY_FILTER);
const [projectNoOptions, setProjectNoOptions] = useState<SmartSelectOption[]>([]);
const fetchList = useCallback(async () => {
setLoading(true);
try {
const data = await projectMgmtApi.list(filter);
setRows(data);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally { setLoading(false); }
}, [filter]);
useEffect(() => { fetchList(); /* eslint-disable-next-line */ }, []);
// 프로젝트번호 셀렉트 옵션 (wace common.getCusProjectNoList 대응)
useEffect(() => {
projectMgmtApi.projectNoOptions()
.then((opts) => setProjectNoOptions(opts.map((o) => ({ code: o.value, label: o.label }))))
.catch(() => {});
}, []);
const handleReset = () => {
setFilter(EMPTY_FILTER);
setTimeout(() => fetchList(), 0);
};
return (
<div className="flex flex-col h-full">
{/* 검색폼 — wace projectMgmtWbsList3.jsp:222-313 활성 11필드 */}
<div className="border-b bg-card px-4 py-3">
<div className="grid grid-cols-6 gap-3 text-sm">
{/* 1행 */}
<Field label="년도">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.Year ?? ""}
onChange={(e) => setFilter({ ...filter, Year: e.target.value })}
>
<option value=""></option>
{YEAR_OPTIONS.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</Field>
<Field label="프로젝트번호">
<SmartSelect
options={projectNoOptions}
value={filter.project_nos ?? ""}
onValueChange={(v) => setFilter({ ...filter, project_nos: v })}
placeholder="전체"
/>
</Field>
<Field label="주문유형">
<CommCodeSelect
groupId={CATEGORY_GROUP}
value={filter.category_cd ?? ""}
onValueChange={(v) => setFilter({ ...filter, category_cd: v })}
/>
</Field>
<Field label="고객사">
<CustomerSelect
value={filter.customer_objid ?? ""}
onValueChange={(v) => setFilter({ ...filter, customer_objid: v })}
/>
</Field>
<Field label="제품구분">
<CommCodeSelect
groupId={PRODUCT_GROUP}
value={filter.product ?? ""}
onValueChange={(v) => setFilter({ ...filter, product: v })}
/>
</Field>
<Field label="요청납기일">
<div className="flex items-center gap-1">
<Input type="date" value={filter.contract_start_date ?? ""} onChange={(e) => setFilter({ ...filter, contract_start_date: e.target.value })} />
<span className="text-xs text-muted-foreground">~</span>
<Input type="date" value={filter.contract_end_date ?? ""} onChange={(e) => setFilter({ ...filter, contract_end_date: e.target.value })} />
</div>
</Field>
{/* 2행 */}
<Field label="국내/해외">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.area_cd ?? ""}
onChange={(e) => setFilter({ ...filter, area_cd: e.target.value })}
>
<option value=""></option>
<option value="국내"></option>
<option value="해외"></option>
</select>
</Field>
<Field label="유/무상">
<select
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={filter.free_of_charge ?? ""}
onChange={(e) => setFilter({ ...filter, free_of_charge: e.target.value })}
>
<option value=""></option>
<option value="유상"></option>
<option value="무상"></option>
</select>
</Field>
<Field label="품번">
<PartSelect
mode="partNo"
value={filter.search_partObjId ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_partObjId: v })}
/>
</Field>
<Field label="품명">
<PartSelect
mode="partName"
value={filter.search_partObjId ?? ""}
onValueChange={(v) => setFilter({ ...filter, search_partObjId: v })}
/>
</Field>
<Field label="S/N">
<Input
value={filter.serial_no ?? ""}
onChange={(e) => setFilter({ ...filter, serial_no: e.target.value })}
placeholder="S/N LIKE"
/>
</Field>
{/* 액션 */}
<div className="flex items-end justify-end gap-2">
<Button variant="outline" size="sm" onClick={handleReset}>
<RotateCcw className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" onClick={fetchList} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</div>
</div>
</div>
{/* 그리드 (8그룹 18셀 평탄화) */}
<div className="flex-1 min-h-0 p-2">
<DataGrid
columns={GRID_COLUMNS}
data={rows}
loading={loading}
showRowNumber
emptyMessage="조건에 맞는 프로젝트가 없습니다."
gridId="project-progress-wbslist3"
/>
</div>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<Label className="mb-1 block text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}
@@ -104,6 +104,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_16/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_16/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_16/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_16/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/project/progress": dynamic(() => import("@/app/(main)/COMPANY_16/project/progress/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 }),
+109
View File
@@ -0,0 +1,109 @@
import { apiClient } from "./client";
// wace projectMgmtWbsGridList 매퍼 + 영업관리 패턴 통일
export interface ProgressListFilter {
Year?: string;
project_nos?: string; // OBJID (단일 또는 쉼표 다중)
category_cd?: string;
customer_objid?: string;
product?: string;
status_cd?: string;
result_cd?: string;
contract_start_date?: string;
contract_end_date?: string;
area_cd?: string; // '국내'/'해외'
free_of_charge?: string; // '유상'/'무상'
// 영업관리 패턴 통일 — PartSelect의 value (part_mng.objid::varchar)
search_partObjId?: string;
serial_no?: string;
pm_user_id?: string;
location?: string;
setup?: string;
}
export interface ProjectNoOption {
value: string;
label: string;
}
// 운영판 그리드 18셀 + 부속 필드
export interface ProgressRow {
objid: string;
project_no: string | null;
category_cd: string | null;
category_name: string | null;
customer_objid: string | null;
customer_name: string | null;
product: string | null;
product_name: string | null;
area_name: string | null;
reg_date: string | null;
free_of_charge: string | null;
product_item_code: string | null;
product_item_name: string | null;
serial_no: string | null;
contract_qty: number | string | null;
req_del_date: string | null;
ebom_status: string | null;
mbom_status: string | null;
order_date: string | null;
receiving_rate: string | null;
production_team_12: string | null;
production_team_3: string | null;
assembly: string | null;
verification: string | null;
shipment_date: string | null;
// 부속
contract_objid: string | null;
contract_no: string | null;
overhaul_order: string | null;
pm_user_name: string | null;
writer_name: string | null;
cu01_cnt: number | null;
cu02_cnt: number | null;
}
export interface ProgressDetail {
objid: string;
project_no: string;
project_name: string;
facility: string;
facility_name: string;
facility_qty: string;
facility_depth: string;
contract_del_date: string;
contract_currency: string;
contract_currency_name: string;
contract_price: string;
contract_price_currency: string;
}
export interface ProgressUpdateBody {
project_no?: string;
project_name?: string;
facility?: string;
facility_qty?: string;
facility_depth?: string;
contract_del_date?: string;
contract_currency?: string;
contract_price_currency?: string;
contract_price?: string;
}
export const projectMgmtApi = {
async list(filter: ProgressListFilter = {}) {
const res = await apiClient.get("/project/progress/list", { params: filter });
return (res.data?.data ?? []) as ProgressRow[];
},
async projectNoOptions(): Promise<ProjectNoOption[]> {
const res = await apiClient.get("/project/progress/project-no-options");
return (res.data?.data ?? []) as ProjectNoOption[];
},
async detail(objid: string): Promise<ProgressDetail | null> {
const res = await apiClient.get(`/project/progress/${objid}`);
return res.data?.data ?? null;
},
async update(objid: string, body: ProgressUpdateBody) {
return (await apiClient.put(`/project/progress/${objid}`, body)).data;
},
};