프로젝트관리>제품구분_WBS관리 메뉴 신설 — wace WBS 템플릿 1:1 이식

· 메인 그리드 5컬럼(제품구분/제목/WBS/등록자/등록일) + 통합 팝업(트리 CRUD + 엑셀 임포트 + 템플릿 다운로드)
· 운영 매핑: pms_wbs_template(헤더) + pms_wbs_task_standard(트리) — 활성 갈래 확정 (_info/_standard2 갈래는 2021년 멈춘 레거시)
· wace mergeExcelUploadWBS 1:1: 신규=헤더+트리 INSERT, 수정=트리 일괄 DELETE→INSERT (헤더 변경 없음)
· objid 채번 gen_random_uuid()::text, 엑셀 파싱 xlsx(SheetJS), 정적 템플릿 frontend/public/templates/
· DataGrid 컬럼 단위 onClick 추가 (WBS 폴더 셀 클릭용)
· DDL: 8개 테이블 162컬럼 (docs/migration/project/ddl-extracted/200_pms_wbs.sql) / GAP: docs/migration/project/02-wbs-template.md
· 프로젝트 자동 복사/진행관리 연계는 wace도 미완성 — P2 범위 외

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-12 13:43:51 +09:00
parent 7c4817b045
commit 50669a66ee
15 changed files with 2165 additions and 2 deletions
+105 -1
View File
@@ -45,7 +45,8 @@
"redis": "^4.6.10",
"socket.io": "^4.8.3",
"uuid": "^13.0.0",
"winston": "^3.11.0"
"winston": "^3.11.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
@@ -4022,6 +4023,15 @@
"node": ">=0.4.0"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -4755,6 +4765,19 @@
],
"license": "CC-BY-4.0"
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"crc-32": "~1.2.0"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4960,6 +4983,15 @@
"node": ">= 0.12.0"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/collect-v8-coverage": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz",
@@ -5198,6 +5230,18 @@
"node": ">= 0.10"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/create-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@@ -6553,6 +6597,15 @@
"node": ">= 0.6"
}
},
"node_modules/frac": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
@@ -11067,6 +11120,18 @@
"node": ">= 0.6"
}
},
"node_modules/ssf": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
"license": "Apache-2.0",
"dependencies": {
"frac": "~1.1.2"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/stack-trace": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
@@ -11991,6 +12056,24 @@
"node": ">= 6"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -12093,6 +12176,27 @@
"resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz",
"integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w=="
},
"node_modules/xlsx": {
"version": "0.18.5",
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
"license": "Apache-2.0",
"dependencies": {
"adler-32": "~1.3.0",
"cfb": "~1.2.1",
"codepage": "~1.15.0",
"crc-32": "~1.2.1",
"ssf": "~0.11.2",
"wmf": "~1.0.1",
"word": "~0.3.0"
},
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/xml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
+2 -1
View File
@@ -59,7 +59,8 @@
"redis": "^4.6.10",
"socket.io": "^4.8.3",
"uuid": "^13.0.0",
"winston": "^3.11.0"
"winston": "^3.11.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
+2
View File
@@ -176,6 +176,7 @@ import salesOrderMgmtRoutes from "./routes/salesOrderMgmtRoutes"; // 영업관
import salesSaleRoutes from "./routes/salesSaleRoutes"; // 영업관리>판매+매출 (wace_plm 도메인)
import salesCommonRoutes from "./routes/salesCommonRoutes"; // 영업관리 4개 메뉴 공통 옵션 (codes/parts/customers)
import projectMgmtRoutes from "./routes/projectMgmtRoutes"; // 프로젝트관리>진행관리 (wace_plm 도메인 이식)
import wbsTemplateRoutes from "./routes/wbsTemplateRoutes"; // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인 이식)
import erpSyncRoutes from "./routes/erpSyncRoutes"; // ERP 마스터 동기화 (사원/부서/창고/거래처/계정과목)
import ecrMngRoutes from "./routes/ecrMngRoutes"; // ECR(Engineering Change Request) 관리
import customerCsRoutes from "./routes/customerCsRoutes"; // 고객 CS 관리
@@ -421,6 +422,7 @@ app.use("/api/sales/order-mgmt", salesOrderMgmtRoutes); // 영업관리>주문
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/project/wbs-template", wbsTemplateRoutes); // 프로젝트관리>제품구분_WBS관리 (wace_plm 도메인)
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
@@ -0,0 +1,91 @@
// ============================================================
// 프로젝트관리 > 제품구분_WBS관리 controller
// projectMgmtController 패턴 따라 { success, data } envelope.
// ============================================================
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import * as svc from "../services/wbsTemplateService";
import { logger } from "../utils/logger";
// GET /api/project/wbs-template?product=...
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const data = await svc.listTemplates({
product: (req.query.product as string) || undefined,
});
return res.json({ success: true, data });
} catch (e: any) {
logger.error("WBS 템플릿 목록 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// GET /api/project/wbs-template/check-duplicate?product=...&title=...
export async function checkDuplicate(req: AuthenticatedRequest, res: Response) {
try {
const product = (req.query.product as string) || "";
const title = (req.query.title as string) || "";
if (!product || !title) {
return res.json({ success: true, data: { duplicate: false } });
}
const duplicate = await svc.checkDuplicate(product, title);
return res.json({ success: true, data: { duplicate } });
} catch (e: any) {
logger.error("WBS 템플릿 중복 체크 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// GET /api/project/wbs-template/:id (헤더 + 트리)
export async function getById(req: AuthenticatedRequest, res: Response) {
try {
const result = await svc.getById(req.params.id);
if (!result.master) {
return res.status(404).json({ success: false, message: "템플릿을 찾을 수 없습니다." });
}
return res.json({ success: true, data: result });
} catch (e: any) {
logger.error("WBS 템플릿 상세 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// POST /api/project/wbs-template (신규 + 수정 통합 — wace mergeExcelUploadWBS 1:1)
export async function save(req: AuthenticatedRequest, res: Response) {
try {
const userId = req.user?.userId || "";
const data = await svc.saveTemplate(req.body, userId);
return res.json({ success: true, message: "저장하였습니다.", data });
} catch (e: any) {
logger.error("WBS 템플릿 저장 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// DELETE /api/project/wbs-template (body { objids: string[] })
export async function remove(req: AuthenticatedRequest, res: Response) {
try {
const objids: string[] = Array.isArray(req.body?.objids) ? req.body.objids : [];
const result = await svc.deleteTemplates(objids);
return res.json({ success: true, ...result });
} catch (e: any) {
logger.error("WBS 템플릿 삭제 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
// POST /api/project/wbs-template/parse-excel (multipart, field: file)
export async function parseExcel(req: AuthenticatedRequest, res: Response) {
try {
const file = (req as any).file as Express.Multer.File | undefined;
if (!file) {
return res.status(400).json({ success: false, message: "엑셀 파일이 필요합니다." });
}
const data = svc.parseExcel(file.buffer);
return res.json({ success: true, data });
} catch (e: any) {
logger.error("WBS 엑셀 파싱 실패", { error: e.message });
return res.status(500).json({ success: false, message: e.message });
}
}
@@ -0,0 +1,29 @@
// ============================================================
// 프로젝트관리 > 제품구분_WBS관리 routes
// 영업관리/projectMgmtRoutes 패턴.
// 엑셀 파싱은 multer memoryStorage (디스크 저장 불필요 — 파싱 후 즉시 응답).
// ============================================================
import { Router } from "express";
import multer from "multer";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/wbsTemplateController";
const router = Router();
router.use(authenticateToken);
// 엑셀 파일 메모리 업로드 (10MB 제한)
const excelUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
});
router.get("/check-duplicate", ctrl.checkDuplicate);
router.post("/parse-excel", excelUpload.single("file"), ctrl.parseExcel);
router.get("/", ctrl.getList);
router.post("/", ctrl.save);
router.delete("/", ctrl.remove);
router.get("/:id", ctrl.getById);
export default router;
@@ -0,0 +1,293 @@
// ============================================================
// 프로젝트관리 > 제품구분_WBS관리 (wace_plm 도메인 이식)
// 메인 화면: wace `/project/wbsTemplateMngList.do`
// 통합 팝업: wace `/project/WBSExcelImportPopUp.do`
// 매퍼 SQL: wace_plm/src/com/pms/mapper/project.xml:5552 외
// JSP 원본: wbsTemplateMngList.jsp + WBSExcelImportPopUp.jsp
// 서비스 원본: ProjectService.java:2902 mergeExcelUploadWBS
//
// 1:1 이식 + 변경점:
// · objid 채번: wace CommonUtils.createObjId() → PG `gen_random_uuid()::text` (영업관리 패턴)
// · 엑셀 파싱: Apache POI XSSFWorkbook → npm `xlsx` (SheetJS)
// · 트랜잭션: SqlSession.commit() → PG client BEGIN/COMMIT
// ============================================================
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import * as XLSX from "xlsx";
// ─── 타입 ────────────────────────────────────────────────
export interface TemplateListFilter {
product?: string;
}
export interface WbsTaskRow {
WBS_TASK_OBJID: string;
TASK_NAME: string;
UNIT_NO: string;
UPPER_TASK_OBJID: string;
TASK_LEVEL: string;
}
export interface SaveTemplatePayload {
templateObjId?: string;
product: string;
title: string;
customer_product?: string;
tasks: WbsTaskRow[]; // TOTAL 행 포함 (TASK_LEVEL=0)
}
// ─── 1) 메인 그리드 (wace project.wbsTemplateMngGridList 1:1) ─────
export async function listTemplates(filter: TemplateListFilter) {
const pool = getPool();
const conditions: string[] = ["1=1"];
const params: any[] = [];
let idx = 1;
if (filter.product) {
conditions.push(`PRODUCT_OBJID = $${idx++}`);
params.push(filter.product);
}
const sql = `
SELECT
OBJID,
PRODUCT_OBJID,
CODE_NAME(PRODUCT_OBJID) AS PRODUCT_NAME,
TITLE,
WRITER,
(SELECT DEPT_NAME || USER_NAME FROM USER_INFO WHERE USER_ID = WRITER) AS WRITER_TITLE,
REG_DATE,
TO_CHAR(REG_DATE, 'YYYY-MM-DD') AS REG_DATE_TITLE,
(SELECT COUNT(1) FROM PMS_WBS_TASK_STANDARD PWTS WHERE PWTS.PARENT_OBJID = OBJID) AS WBS_TASK_CNT,
CUSTOMER_PRODUCT
FROM PMS_WBS_TEMPLATE
WHERE ${conditions.join(" AND ")}
ORDER BY REG_DATE DESC NULLS LAST
`;
const result = await pool.query(sql, params);
return result.rows;
}
// ─── 2) 팝업 진입: 헤더 + 트리 (wace WBSExcelImportPopUp.do 1:1) ──
export async function getById(objid: string) {
const pool = getPool();
const masterSql = `
SELECT OBJID, PRODUCT_OBJID,
CODE_NAME(PRODUCT_OBJID) AS PRODUCT_OBJID_NAME,
TITLE, WRITER, REG_DATE, CUSTOMER_PRODUCT
FROM PMS_WBS_TEMPLATE
WHERE OBJID = $1
`;
const taskSql = `
SELECT T.OBJID,
T.PARENT_OBJID,
T.TASK_NAME,
T.TASK_SEQ,
T.TASK_LEVEL,
T.USER_ID,
(SELECT USER_NAME FROM USER_INFO WHERE USER_ID = T.USER_ID) AS USER_ID_TITLE,
T.WRITER,
T.REG_DATE,
T.UNIT_NO,
T.UPPER_TASK_OBJID
FROM PMS_WBS_TASK_STANDARD AS T
WHERE T.PARENT_OBJID = $1
ORDER BY CAST(T.TASK_SEQ AS INTEGER)
`;
const [master, tasks] = await Promise.all([
pool.query(masterSql, [objid]),
pool.query(taskSql, [objid]),
]);
return {
master: master.rows[0] ?? null,
tasks: tasks.rows,
};
}
// ─── 3) 중복 체크 (wace project.getWBSTemplateProductList 1:1) ────
export async function checkDuplicate(product: string, title: string) {
const pool = getPool();
const sql = `
SELECT T.OBJID
FROM PMS_WBS_TEMPLATE T
LEFT OUTER JOIN PMS_WBS_TASK_STANDARD T1 ON T.OBJID = T1.PARENT_OBJID
WHERE T.PRODUCT_OBJID = $1
AND T.TITLE = $2
LIMIT 1
`;
const result = await pool.query(sql, [product, title]);
return result.rows.length > 0;
}
// ─── 4) 통합 저장 (wace ProjectService.mergeExcelUploadWBS 1:1) ────
export async function saveTemplate(payload: SaveTemplatePayload, writer: string) {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
let masterObjId: string;
if (payload.templateObjId) {
// 수정 모드: 헤더 UPDATE 안 함, 트리 일괄 DELETE → 재 INSERT (wace 동일)
masterObjId = payload.templateObjId;
await client.query(
`DELETE FROM PMS_WBS_TASK_STANDARD WHERE PARENT_OBJID = $1`,
[masterObjId]
);
} else {
// 신규 모드: 헤더 INSERT (wace saveWBSTaskTemp 1:1)
const masterRes = await client.query(
`INSERT INTO PMS_WBS_TEMPLATE
(OBJID, PRODUCT_OBJID, TITLE, CUSTOMER_PRODUCT, WRITER, REG_DATE)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, now())
RETURNING OBJID`,
[
payload.product,
payload.title,
payload.customer_product ?? "",
writer,
]
);
masterObjId = masterRes.rows[0].objid;
}
// 트리 INSERT (wace saveWBSTemplateTaskInfo — upsert)
for (let i = 0; i < payload.tasks.length; i++) {
const t = payload.tasks[i];
await client.query(
`INSERT INTO PMS_WBS_TASK_STANDARD
(OBJID, PARENT_OBJID, TASK_NAME, TASK_SEQ, TASK_LEVEL,
USER_ID, WRITER, REG_DATE, UNIT_NO, UPPER_TASK_OBJID)
VALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8, $9)
ON CONFLICT (OBJID) DO UPDATE SET
TASK_NAME = EXCLUDED.TASK_NAME,
TASK_SEQ = EXCLUDED.TASK_SEQ,
TASK_LEVEL = EXCLUDED.TASK_LEVEL,
USER_ID = EXCLUDED.USER_ID,
UNIT_NO = EXCLUDED.UNIT_NO,
UPPER_TASK_OBJID = EXCLUDED.UPPER_TASK_OBJID`,
[
t.WBS_TASK_OBJID,
masterObjId,
t.TASK_NAME,
String(i + 1),
t.TASK_LEVEL,
"", // user_id (wace mergeExcelUploadWBS도 미설정)
writer,
t.UNIT_NO,
t.UPPER_TASK_OBJID,
]
);
}
await client.query("COMMIT");
return { OBJID: masterObjId };
} catch (e) {
await client.query("ROLLBACK");
logger.error("[wbsTemplateService.saveTemplate] failed", { error: e });
throw e;
} finally {
client.release();
}
}
// ─── 5) 다건 삭제 (wace ProjectService.deleteWBSTemplateMaster 1:1) ─
export async function deleteTemplates(objids: string[]) {
if (!objids || objids.length === 0) {
return { result: "true", msg: "삭제할 대상이 없습니다." };
}
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const placeholders = objids.map((_, i) => `$${i + 1}`).join(",");
await client.query(
`DELETE FROM PMS_WBS_TEMPLATE WHERE OBJID IN (${placeholders})`,
objids
);
await client.query(
`DELETE FROM PMS_WBS_TASK_STANDARD WHERE PARENT_OBJID IN (${placeholders})`,
objids
);
await client.query("COMMIT");
return { result: "true", msg: "삭제하였습니다." };
} catch (e) {
await client.query("ROLLBACK");
logger.error("[wbsTemplateService.deleteTemplates] failed", { error: e });
throw e;
} finally {
client.release();
}
}
// ─── 6) 엑셀 파싱 (wace ProjectService.parsingExcelFile 1:1) ──────
// 입력: 업로드된 .xlsx Buffer (multer memoryStorage)
// 출력: List<{ WBS_OBJID, UNIT_NO, TASK_NAME }> — 클라이언트가 행 채움
//
// 엑셀 규칙 (wace 동일):
// · 0행 = "입력" 라벨, 1행 = "수준 / unit name" 헤더 → 무시
// · 2행부터 데이터: 열 0/1/2 = 수준1/2/3 셀, 열 3 = TASK_NAME
// · UNIT_NO = 수준3 → 수준2 → 수준1 우선
// · unit_no + task_name 둘 다 있어야 결과 포함
export function parseExcel(buffer: Buffer) {
const wb = XLSX.read(buffer, { type: "buffer" });
const firstSheet = wb.SheetNames[0];
const sheet = wb.Sheets[firstSheet];
// raw=false → cell 표시값(string) / defval='' → 빈 셀도 string ''
const rows = XLSX.utils.sheet_to_json<any[]>(sheet, {
header: 1,
raw: false,
defval: "",
});
const result: Array<{ WBS_OBJID: string; UNIT_NO: string; TASK_NAME: string }> = [];
// 2행(인덱스 2)부터 — wace 동일
for (let i = 2; i < rows.length; i++) {
const row = rows[i];
if (!row) continue;
const levelValues = [
String(row[0] ?? "").trim(),
String(row[1] ?? "").trim(),
String(row[2] ?? "").trim(),
];
const taskName = String(row[3] ?? "").trim();
const unitNo = levelValues[2] || levelValues[1] || levelValues[0];
if (unitNo && taskName) {
result.push({
// wace는 server측에서 OBJID 발급 (CommonUtils.createObjId)
// vexplor_rps: 클라이언트가 화면 표시용 임시 ID 발급, 저장 시 새 OBJID 부여
// → 여기서는 crypto.randomUUID()로 임시 ID 생성
WBS_OBJID: crypto.randomUUID(),
UNIT_NO: unitNo,
TASK_NAME: taskName,
});
}
}
return result;
}
+477
View File
@@ -0,0 +1,477 @@
# P2: 프로젝트관리 > 제품구분_WBS관리 (WBS 템플릿)
> 작성일: 2026-05-12
> 원본: wace_plm `/project/wbsTemplateMngList.do` + `/project/WBSExcelImportPopUp.do` 통합 워크플로
> 운영판 URL: https://waceplm.esgrin.com/main.do → 프로젝트관리 > 제품구분_WBS관리
> 대상 DB 테이블: `pms_wbs_template`(헤더), `pms_wbs_task_standard`(트리)
---
## 1. 범위 (Scope)
### 1.1 범위 안
- **메인 그리드** (5컬럼): 제품구분 / 제목 / WBS(폴더 아이콘) / 등록자 / 등록일
- **검색**: 제품구분 단일 셀렉트
- **신규 등록**: 통합 팝업 진입 (`WBSExcelImportPopUp.do?product=...`)
- **수정**: 통합 팝업 진입 (`WBSExcelImportPopUp.do?templateObjId=...`)
- **삭제**: 다건 선택 후 헤더+트리 cascade
- **통합 팝업**: 헤더(제품구분/제목) + 트리 CRUD(추가/하위추가/삭제) + 엑셀 임포트 + 템플릿 다운로드 + 저장
### 1.2 범위 밖 (의도적 제외)
- 진행관리(P1) WBS 연계 (= 프로젝트 생성 시 템플릿 자동 복사) — wace도 미완성 영역, vexplor_rps에서도 손대지 않음
- 간트차트 / FN Task 연결 / 작업확정 / 제품별 WBS / 셋업 WBS — wace의 30+ endpoint 중 진행관리/별도 메뉴용
- `pms_wbs_task` 본체(트리) — 진행관리에서 사용 예정, 본 메뉴 미사용
---
## 2. 운영판 화면 검증 (2026-05-11 캡처)
### 2.1 메인 그리드
| 영역 | 내용 |
|---|---|
| 화면 제목 | "프로젝트관리_제품구분_WBS관리" |
| 우상단 버튼 | 삭제 / 등록 / 조회 / 초기화 / [엑셀 다운로드] |
| 검색 필터 | **제품구분** select 단일 |
| 그리드 컬럼 | 체크박스 / 제품구분 / 제목 / WBS(폴더) / 등록자 / 등록일 |
| 운영 데이터 | 1건: Machine / test 생산 / [폴더] / 경영지원팀관리자 / 2026-04-08 |
### 2.2 통합 팝업 (등록/수정)
- URL: `/project/WBSExcelImportPopUp.do?templateObjId=1120026346` (운영 확인)
- 제목: 신규 시 "WBS 템플릿 등록" / 수정 시 "WBS 템플릿 수정"
- 헤더: 제품구분(수정 시 disabled) + 제목(수정 시 disabled)
- 버튼: Template Download / 추가 / 하위추가 / 삭제 / 저장 / 닫기
- 드롭존: "Drag & Drop 엑셀 템플릿"
- 트리 그리드: 선택 / 수준(1/2/3 세 칸) / Unit Name·공정 — TOTAL 행 + 자식 5행 (운영 데이터)
### 2.3 엑셀 템플릿 (`WBS_EXCEL_IMPORT_TEMPLATE.xlsx`)
| A (수준1) | B (수준2) | C (수준3) | D (unit name /공정) |
|---|---|---|---|
| 1 | | | TASK1 |
| | 1.1 | | TASK2 |
| | | 1.1.1 | TASK3 |
| ... | | | |
- 1행 = "입력" 라벨(노란색), 2행 = "수준/unit name /공정" 헤더, **3행부터 데이터**.
- 수준 위치 = depth(1/2/3), 셀 값 = UNIT_NO ("1", "1.1", "1.1.1" 형식 자유 입력).
- TASK_NAME은 D열.
---
## 3. 데이터 모델 (운영 1:1)
### 3.1 `pms_wbs_template` (헤더, 1건 운영)
| 컬럼 | 타입 | 용도 |
|---|---|---|
| `objid` | varchar PK | 헤더 키 (Java 측 채번) |
| `product_objid` | varchar | 제품구분 코드 (CODE_NAME 함수로 라벨화) |
| `title` | varchar | 템플릿 제목 |
| `writer` | varchar | 등록자 user_id |
| `reg_date` | timestamp | 등록일 |
| `customer_product` | varchar | (미사용 — 운영 비활성 컬럼 `CUSTOMER_PRODUCT`로 흔적만) |
### 3.2 `pms_wbs_task_standard` (트리, 5건 운영, 활성 갈래)
| 컬럼 | 타입 | 용도 |
|---|---|---|
| `objid` | varchar PK | task 키 |
| `parent_objid` | varchar | **`pms_wbs_template.objid` 참조** (헤더 FK) |
| `task_name` | varchar | task 이름 (TOTAL / 사용자 입력 / 엑셀 임포트) |
| `task_seq` | varchar | 폼 제출 순서 (INTEGER 캐스팅 정렬용) |
| `task_level` | varchar | depth (0=TOTAL, 1/2/3=수준) |
| `unit_no` | varchar | "1" / "1.1" / "1.1.1" 표기 — depth와 일치 |
| `upper_task_objid` | varchar | **트리 부모 objid** (TOTAL의 objid가 depth=1의 부모, 이전 depth-1 행이 depth>1의 부모) |
| `user_id` / `writer` / `reg_date` | varchar/varchar/timestamp | 메타 |
→ vexplor_rps DDL 위치: [docs/migration/project/ddl-extracted/200_pms_wbs.sql](ddl-extracted/200_pms_wbs.sql) (8개 테이블 중 2개가 본 메뉴 대상).
### 3.3 폐기 / 미사용 (참고)
| 테이블 | 운영 카운트 | 폐기 이유 |
|---|---:|---|
| `pms_wbs_task_info` | 518 (2021 멈춤) | 매퍼 사용 없음 — 레거시 |
| `pms_wbs_task_standard2` | 74 | 매퍼 사용 없음 |
| `pms_wbs_task_confirm` | 0 | 매퍼 사용 없음 |
---
## 4. wace 1:1 매핑 카탈로그
### 4.1 ProjectController.java endpoint (P2 범위 8개)
| URL | 라인 | 호출 service | 매퍼 |
|---|---:|---|---|
| `/project/wbsTemplateMngList.do` | 2242 | (forward only) + `bizMakeOptionList('0000001')` | (코드맵만) |
| `/project/wbsTemplateMngGridList.do` | 2270 | `commonService.selectListPagingNew` | `project.wbsTemplateMngGridList` |
| `/project/WBSExcelImportPopUp.do` | 2282 | `getWBSTemplateMasterInfo` + `getWBSTemplateTaskList` (수정 시) | `getWBSTemplateMasterInfo` + `getWBSTemplateTaskList` |
| `/project/getWBSTemplateTaskList.do` | 2419 | `getWBSTemplateTaskList` (AJAX, 팝업 로드 시) | `getWBSTemplateTaskList` |
| `/project/parsingExcelFile.do` | 2319 | `parsingExcelFile` | (Apache POI 직접) |
| `/project/excelImportFileProc.do` | 2336 | `commonService.insertUploadFileInfo` | (파일 메타만) |
| `/project/checkWBSTemplateProduct.do` | 2371 | `getWBSTemplateProductList` | `getWBSTemplateProductList` |
| `/project/saveExcelUploadWBS.do` | 2379 | **`mergeExcelUploadWBS`** (통합 저장) | `deleteWBSTemplateTaskByMaster` + `saveWBSTaskTemp` + `saveWBSTemplateTaskInfo` |
| `/project/deleteWBSTemplateMaster.do` | 2474 | `deleteWBSTemplateMaster` | `deleteWBSTemplateMaster` + `deleteWBSTemplateMasterTask` |
→ 9개 endpoint, 그 중 핵심 워크플로는 4개 (`grid` / `popup-forward` / `parse` / `merge-save`).
### 4.2 project.xml 매퍼 SQL (라인번호 + 본문 핵심)
#### `wbsTemplateMngGridList` (5552) — 메인 그리드
```sql
SELECT
OBJID, PRODUCT_OBJID,
CODE_NAME(PRODUCT_OBJID) AS PRODUCT_NAME,
TITLE,
WRITER,
(SELECT DEPT_NAME || USER_NAME FROM USER_INFO WHERE USER_ID = WRITER) AS WRITER_TITLE,
REG_DATE,
TO_CHAR(REG_DATE, 'YYYY-MM-DD') AS REG_DATE_TITLE,
(SELECT COUNT(1) FROM PMS_WBS_TASK_STANDARD PWTS WHERE PWTS.PARENT_OBJID = OBJID) AS WBS_TASK_CNT,
CUSTOMER_PRODUCT
FROM PMS_WBS_TEMPLATE
WHERE 1=1
<if test="product != null and product !=''">
AND PRODUCT_OBJID = #{product}
</if>
```
`CODE_NAME()` 함수 호출 (RPS DB 보유, P1 진행관리에서도 사용).
#### `getWBSTemplateMasterInfo` (5647) — 팝업 헤더
```sql
SELECT OBJID, PRODUCT_OBJID, CODE_NAME(PRODUCT_OBJID) AS PRODUCT_OBJID_NAME,
TITLE, WRITER, REG_DATE, CUSTOMER_PRODUCT
FROM PMS_WBS_TEMPLATE AS T
WHERE OBJID = #{OBJID}
```
#### `getWBSTemplateTaskList` (5661) — 팝업 트리
```sql
SELECT T.OBJID, T.PARENT_OBJID, T.TASK_NAME, T.TASK_SEQ, T.TASK_LEVEL,
T.USER_ID,
(SELECT USER_NAME FROM USER_INFO WHERE USER_ID = T.USER_ID) AS USER_ID_TITLE,
T.WRITER, T.REG_DATE, T.UNIT_NO, T.UPPER_TASK_OBJID
FROM PMS_WBS_TASK_STANDARD AS T
WHERE T.PARENT_OBJID = #{OBJID}
ORDER BY CAST(T.TASK_SEQ AS INTEGER)
```
#### `saveWBSTemplateTaskInfo` (5609) — 트리 upsert
```sql
INSERT INTO PMS_WBS_TASK_STANDARD
(OBJID, PARENT_OBJID, TASK_NAME, TASK_SEQ, TASK_LEVEL, USER_ID,
WRITER, REG_DATE, UNIT_NO, UPPER_TASK_OBJID)
VALUES (#{objid}, #{parent_objid}, #{task_name}, #{task_seq}, #{task_level},
#{user_id}, #{writer}, now(), #{unit_no}, #{upper_task_objid})
ON CONFLICT (OBJID) DO UPDATE
SET TASK_NAME = #{task_name}, TASK_SEQ = #{task_seq},
TASK_LEVEL = #{task_level}, USER_ID = #{user_id},
UNIT_NO = #{unit_no}, UPPER_TASK_OBJID = #{upper_task_objid}
```
**upsert (INSERT … ON CONFLICT DO UPDATE)** — neon/node-postgres에서 동일 패턴 사용.
#### `saveWBSTaskTemp` (5587) — 헤더 INSERT
```sql
INSERT INTO PMS_WBS_TEMPLATE
(OBJID, PRODUCT_OBJID, TITLE, CUSTOMER_PRODUCT, WRITER, REG_DATE)
VALUES (#{objid}, #{product}, #{title}, #{customer_product}, #{writer}, now())
```
#### `deleteWBSTemplateTaskByMaster` (5643) — 수정 시 트리 cascade clear
```sql
DELETE FROM PMS_WBS_TASK_STANDARD WHERE PARENT_OBJID = #{parent_objid}
```
#### `deleteWBSTemplateMaster` (5726) / `deleteWBSTemplateMasterTask` (5734)
```sql
DELETE FROM PMS_WBS_TEMPLATE WHERE OBJID IN (...)
DELETE FROM PMS_WBS_TASK_STANDARD WHERE PARENT_OBJID IN (...)
```
→ 헤더 다건 삭제 + cascade. 트랜잭션 1개.
#### `getWBSTemplateProductList` (5576) — 중복 체크
```sql
SELECT *
FROM PMS_WBS_TEMPLATE AS T
LEFT OUTER JOIN PMS_WBS_TASK_STANDARD AS T1 ON T.OBJID = T1.PARENT_OBJID
WHERE T.PRODUCT_OBJID = #{PRODUCT}
AND T.TITLE = #{TITLE}
```
→ 신규 등록 시 동일 (제품구분 + 제목) 조합 있는지 사전 체크.
### 4.3 ProjectService 핵심 메소드
#### `mergeExcelUploadWBS` (line 2902, 통합 저장)
```
PersonBean.userId → writer
request.parameter("product"|"title"|"templateObjId"|"customer_product")
분기:
if templateObjId != "":
wbsMasterObjId = templateObjId # 헤더 재사용 (UPDATE 안 함!)
sqlSession.delete("deleteWBSTemplateTaskByMaster", {parent_objid: wbsMasterObjId})
else:
wbsMasterObjId = CommonUtils.createObjId()
sqlSession.insert("saveWBSTaskTemp", {objid, product, title, customer_product, writer})
루프 (WBS_TASK_OBJID 배열):
for i, wbsObjId in enumerate(WBS_TASK_OBJID):
{TASK_NAME, UNIT_NO, UPPER_TASK_OBJID, TASK_LEVEL} = request 각 hidden
sqlSession.insert("saveWBSTemplateTaskInfo", {
objid, task_name, task_seq=i+1, task_level, unit_no,
upper_task_objid, parent_objid=wbsMasterObjId, writer
})
sqlSession.commit()
```
**핵심 패턴**:
- 수정 모드는 **헤더 UPDATE 안 함** (product/title 변경 불가). 트리만 일괄 DELETE → INSERT.
- 신규 모드는 헤더 INSERT + 트리 INSERT.
- 트리 순서(task_seq)는 폼 제출 순서.
#### `parsingExcelFile` (line 2779)
```
fileList = commonService.getFileList({targetObjId, docType: "WBS_EXCEL_IMPORT"})
첫 파일을 XSSFWorkbook으로 읽음.
루프 rowIndex = 2 ~ end (3행부터):
열 0/1/2 → levelValues[0..2] # 수준1/2/3 셀
열 3 → taskName
unitNo = levelValues[2] or levelValues[1] or levelValues[0] # depth 3→2→1 우선
if unitNo and taskName:
result.add({WBS_OBJID = createObjId(), UNIT_NO = unitNo, TASK_NAME = taskName})
```
→ depth는 클라이언트가 unit_no의 `.` 개수로 계산 (`.match(/\./g) || []).length + 1`).
→ 파싱 결과는 클라이언트에 List 리턴, **DB 저장은 사용자가 "저장" 버튼 누를 때 mergeExcelUploadWBS에서**.
#### `deleteWBSTemplateMaster` (line 3284)
```
SqlSession (transactional)
sqlSession.delete("deleteWBSTemplateMaster", {checkArr})
sqlSession.delete("deleteWBSTemplateMasterTask", {checkArr})
commit
```
---
## 5. UI 동작 명세 (`WBSExcelImportPopUp.jsp` 1:1)
### 5.1 모드 분기
```js
isEditMode = (templateObjId !== "")
if isEditMode:
loadExistingTasks() // AJAX → /project/getWBSTemplateTaskList.do
title.value = masterInfo.TITLE
product.value = masterInfo.PRODUCT_OBJID
else:
addTotalRow() // 빈 트리에 TOTAL 행 1개만
```
### 5.2 트리 행 구조
```html
<tr id="row_{objId}" data-depth="?">
<td><input type="checkbox" name="rowCheck" value="{objId}"></td>
<input type="hidden" name="WBS_TASK_OBJID" value="{objId}">
<input type="hidden" name="UNIT_NO_{objId}" value="">
<input type="hidden" name="UPPER_TASK_OBJID_{objId}" value="">
<input type="hidden" name="TASK_LEVEL_{objId}" value="">
<td><input class="lvl_input" data-level="1"></td> <!-- 수준1 -->
<td><input class="lvl_input" data-level="2"></td> <!-- 수준2 -->
<td><input class="lvl_input" data-level="3"></td> <!-- 수준3 -->
<td><input name="TASK_NAME_{objId}" value="..."></td>
</tr>
```
- TOTAL 행은 hidden TASK_LEVEL=0 / UNIT_NO=0 / TASK_NAME=TOTAL / 체크박스 없음 / "TOTAL" 텍스트 표시.
- **수준 1/2/3은 셋 중 하나에만 값 입력 가능** (`bindLevelInput`): 한 칸 입력 시 다른 두 칸 비우고 UNIT_NO + TASK_LEVEL 동기화.
### 5.3 버튼 핸들러
| 버튼 | 함수 | 동작 |
|---|---|---|
| Template Download | `templateDownload.click` | `location.href="/template/WBS_EXCEL_IMPORT_TEMPLATE.xlsx"` (정적 파일) |
| 추가 | `addRow()` | 선택된 행 다음에 같은 depth 행 추가. 선택 없으면 마지막에 append. |
| 하위추가 | `addChildRow()` | 선택된 행의 하위(depth+1) 행을 자식 마지막 다음에 추가. depth>=3 거부. |
| 삭제 | `deleteRow()` | 선택된 행 + 하위 후손 행 일괄 삭제. cascade 확인 alert. |
| 저장 | `saveWBS()` | 검증 → calculateParentRelations() → POST `/project/saveExcelUploadWBS.do` |
| 닫기 | `self.close()` | 팝업 닫기 |
### 5.4 저장 검증 로직 (`saveWBS()`)
```
1. title 빈값 거부
2. WBS_TASK_OBJID 0개 거부 (등록할 항목 없음)
3. 각 행에서 UNIT_NO + TASK_NAME 빈값 거부 (TOTAL 행 제외)
4. 신규 모드일 때 fn_checkWBSTemplateRevision() — (PRODUCT, TITLE) 중복 거부
5. calculateParentRelations() — 모든 행 순회하며 UPPER_TASK_OBJID 채우기:
- depth=1 → totalObjId (TOTAL 행 objid)
- depth>1 → 이전 prevAll 중 depth-1인 가장 가까운 행 objid
6. POST form serialize → opener.fn_search() + self.close()
```
### 5.5 엑셀 임포트 흐름
```
사용자 파일 드롭 → fileUploadPreProc() → preFileDelete() (기존 삭제 alert)
fnc_setFileDropZone POST /project/excelImportFileProc.do (Multipart)
↓ 업로드 완료 콜백 → setExcelFileArea() → setUploadTemplateFile()
↓ getFileList.do AJAX → 첨부 표시 + parsingExcelFile()
↓ parsingExcelFile() POST /project/parsingExcelFile.do
↓ 응답 List(WBS_OBJID, UNIT_NO, TASK_NAME)
↓ 각 row를 #wbsTaskList 에 append (depth는 unit_no의 '.' 개수로 계산)
```
### 5.6 그리드 행 클릭 (메인 화면)
- WBS 폴더 아이콘 클릭 → `fn_openWBSTaskListPopUp(objid)``/project/WBSExcelImportPopUp.do?templateObjId={objid}` 팝업 (1340x700)
- 등록 버튼 → `/project/WBSExcelImportPopUp.do?product={product}` 팝업
- 제품구분 미선택 시 alert "제품은 필수값입니다 제품을 선택해 주세요"
---
## 6. vexplor_rps 이식 매핑 (구현 계획)
### 6.1 backend-node
| wace | vexplor_rps | 비고 |
|---|---|---|
| `ProjectController` WBS template 9개 | `backend-node/src/controllers/wbsTemplateController.ts` | REST 통합 |
| `ProjectService.mergeExcelUploadWBS` | `backend-node/src/services/wbsTemplateService.ts` `saveTemplate(payload)` | upsert 패턴 그대로 |
| `ProjectService.parsingExcelFile` | `backend-node/src/services/wbsTemplateService.ts` `parseExcelFile(buffer)` | Apache POI → **`xlsx`** npm 패키지 |
| `CODE_NAME(PRODUCT_OBJID)` | DB 함수 직접 호출 (P1 진행관리와 동일) | RPS DB 보유 |
| `commonService.getFileList` | 영업관리 첨부 패턴 재사용 또는 임포트 시 메모리 처리 |
#### REST endpoint 매핑
```
GET /api/project/wbs-template — 메인 그리드 (product 필터)
GET /api/project/wbs-template/:id — 헤더 + 트리 (팝업 진입)
POST /api/project/wbs-template — 신규 저장 (헤더 + 트리)
PUT /api/project/wbs-template/:id — 수정 저장 (트리만 일괄 DELETE→INSERT)
DELETE /api/project/wbs-template — 다건 삭제 (헤더 + cascade)
POST /api/project/wbs-template/parse-excel — 엑셀 파일 multipart → 파싱 결과 JSON
GET /api/project/wbs-template/check-duplicate?product=&title= — 중복 체크
GET /api/project/wbs-template/excel-template — 엑셀 템플릿 다운로드 (`public/templates/WBS_EXCEL_IMPORT_TEMPLATE.xlsx` 정적)
```
#### objid 채번
wace는 `CommonUtils.createObjId()` (시퀀스 함수 기반 string). vexplor_rps는 `nanoid()` 또는 `crypto.randomUUID()` → 영업관리 패턴 확인 후 통일.
### 6.2 frontend
| wace | vexplor_rps | 비고 |
|---|---|---|
| `wbsTemplateMngList.jsp` | `frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx` | 메인 페이지 |
| 통합 팝업 `WBSExcelImportPopUp.jsp` | `frontend/components/project/WbsTemplateDialog.tsx` | shadcn Dialog + 트리 테이블 + 파일 드롭존 |
| 메인 그리드 | `DataGrid` 5컬럼 (영업관리 패턴 재사용) | frozen 제품구분 |
| API 클라이언트 | `frontend/lib/api/wbsTemplate.ts` | 영업관리 패턴 |
| 메뉴 | `AdminPageRenderer.tsx` dynamic 등록 | 라우트 `/COMPANY_16/project/wbs-template` |
#### 트리 UI 결정 사항
운영판은 jQuery 기반 단순 테이블 + hidden input 직렬화. vexplor_rps는 React 상태로 트리 행 관리:
- 행 배열 + depth 필드 (1~3) + `objid` 클라이언트 측 생성 (`nanoid()`)
- 추가/하위추가/삭제는 배열 조작
- 수준 1/2/3 컬럼 단일 입력 보장
- 저장 시 task_seq = index+1, upper_task_objid 자동 계산 (depth=1→TOTAL, depth>1→이전 depth-1 항목 objid)
#### 엑셀 라이브러리
- `xlsx` (SheetJS) — backend-node에서 파싱
- 정적 엑셀 템플릿 파일은 `frontend/public/templates/WBS_EXCEL_IMPORT_TEMPLATE.xlsx`에 wace 원본 그대로 배치
---
## 7. 함정 & 결정 메모
### 7.1 수정 모드 헤더 변경 불가
운영판 mergeExcelUploadWBS는 templateObjId 있으면 헤더 UPDATE를 호출하지 않음. 화면에서 product1 disabled / title은 표시만. vexplor_rps도 동일하게 disabled 처리 — **헤더 수정 기능은 추가하지 않음**.
### 7.2 트리 일괄 DELETE → INSERT
수정 시 운영은 `deleteWBSTemplateTaskByMaster`로 트리 전체 삭제 후 폼 데이터로 재삽입. vexplor_rps도 같은 패턴 (트랜잭션 1개). 부분 update/delete 분기 없음 — 간단하고 안전.
### 7.3 task_seq는 INTEGER 캐스팅 정렬
SQL `ORDER BY CAST(T.TASK_SEQ AS INTEGER)`. wace는 폼 제출 순서로 1, 2, 3, ... 매김. vexplor_rps도 동일.
### 7.4 upper_task_objid 자동 계산
클라이언트 측 `calculateParentRelations()` — 백엔드로 보내기 전 결정. depth=1 행의 부모는 TOTAL 행 objid. 운영판 그대로.
### 7.5 CUSTOMER_PRODUCT 컬럼은 비활성
운영판 메인 그리드의 "고객사_장비목적" 컬럼은 JSP 주석 처리됨. `saveWBSTemplateMasterInfo`(UPDATE)는 CUSTOMER_PRODUCT만 수정하는데 호출하는 곳이 비활성 마스터 폼뿐. vexplor_rps 초기 이식에서는 **빼고**, customer_product는 DB에 보존만 함.
### 7.6 엑셀 1행/2행 무시
파싱 루프 `for(rowIndex = 2 ; ...)` — 1행(입력 라벨) + 2행(수준/unit name 헤더) 무시, **3행부터 데이터**. vexplor_rps도 동일.
### 7.7 신규 등록 시 product 필수
`wbsTemplateMngList.jsp` 라인 53-57: 등록 버튼 클릭 시 product 비었으면 alert. 운영 UX 그대로.
### 7.8 폐기 갈래 안 건드림
`pms_wbs_task_info`, `_standard2`, `_confirm`은 DDL은 보존했지만 매퍼/서비스/UI에서 절대 사용하지 않음. 향후 진행관리 P2(WBS 진행 트리) 진입 시 `pms_wbs_task` 본체만 추가 매핑.
### 7.9 진행관리 P1과의 연결은 P2 범위 외
프로젝트(주문) 생성 시 템플릿 자동 복사 흐름은 wace도 미완성 — 사용자 명시. vexplor_rps도 추후 별도 단계로.
---
## 8. 검증 베이스라인 (운영DB)
운영DB에 1건만 있어 화면 검증 단순:
```sql
-- 메인 그리드 조회 (운영 wbsTemplateMngGridList 1:1)
SELECT OBJID, CODE_NAME(PRODUCT_OBJID) AS PRODUCT_NAME, TITLE, WRITER,
TO_CHAR(REG_DATE,'YYYY-MM-DD') AS REG_DATE_TITLE,
(SELECT COUNT(1) FROM pms_wbs_task_standard WHERE parent_objid = t.objid) AS WBS_TASK_CNT
FROM pms_wbs_template t;
-- 운영: 1건 (Machine / test 생산 / 경영지원팀관리자 / 2026-04-08 / WBS_TASK_CNT=5)
-- 트리 조회 (운영 getWBSTemplateTaskList 1:1)
SELECT objid, task_name, task_seq, task_level, unit_no, upper_task_objid
FROM pms_wbs_task_standard
WHERE parent_objid = '1120026346'
ORDER BY CAST(task_seq AS INTEGER);
-- 운영: 5건 (TOTAL + ㅁㅁ4건)
```
vexplor_rps 측은 운영 데이터 시드 없이 빈 상태에서 시작 — 화면 검증은 사용자가 직접 등록·저장·수정·삭제로.
---
## 9. 산출물 체크리스트
- [x] DDL: `docs/migration/project/ddl-extracted/200_pms_wbs.sql` (8개 테이블)
- [x] DDL README: `docs/migration/project/ddl-extracted/README.md`
- [x] GAP 문서: 본 파일
- [ ] backend service: `backend-node/src/services/wbsTemplateService.ts`
- [ ] backend controller/route: `backend-node/src/controllers/wbsTemplateController.ts` + `routes/wbsTemplateRoutes.ts`
- [ ] frontend page: `frontend/app/(main)/COMPANY_16/project/wbs-template/page.tsx`
- [ ] frontend dialog: `frontend/components/project/WbsTemplateDialog.tsx`
- [ ] frontend api: `frontend/lib/api/wbsTemplate.ts`
- [ ] AdminPageRenderer dynamic 등록
- [ ] 엑셀 템플릿 정적 파일: `frontend/public/templates/WBS_EXCEL_IMPORT_TEMPLATE.xlsx`
- [ ] 검증: 운영판 1:1 등록/수정/삭제/엑셀 임포트 동작
@@ -0,0 +1,333 @@
-- ============================================================
-- WBS관리(P2) 운영 DDL — wace_plm 운영DB(211.115.91.141:11133/waceplm) 추출
-- 추출일: 2026-05-11
-- 추출 방법: information_schema 쿼리 (pg_dump 14.19 ↔ PG 16.8 mismatch)
-- 대상 테이블 8개 (운영 카운트):
-- pms_wbs_task 58 cols / 0건
-- pms_wbs_task_info 22 cols / 518건 ← 일반 task 리스트 (실사용)
-- pms_wbs_task_confirm 7 cols / 0건 (objid numeric)
-- pms_wbs_task_standard 10 cols / 5건 (트리 표준)
-- pms_wbs_task_standard2 20 cols / 74건 ← 일반 task 표준 (실사용)
-- pms_wbs_template 6 cols / 1건
-- setup_wbs_task 20 cols / 2,576건 ← 진척율 데이터
-- setup_wbs_task_standard 19 cols / 46건
--
-- 비고:
-- · 운영 스키마 1:1 보존 — 길이 명시 없는 varchar 그대로(무제한).
-- · objid 컬럼은 wace Java 측에서 UUID/시퀀스 생성(시퀀스 없음).
-- · company_code 분기 없음(vexplor_rps는 COMPANY_16 단독).
-- ============================================================
BEGIN;
-- ------------------------------------------------------------
-- 1) pms_wbs_task (WBS 트리 — 설계/구매/제작/자체검사/최종검사/출하/셋업 단계별)
-- ------------------------------------------------------------
DROP TABLE IF EXISTS pms_wbs_task CASCADE;
CREATE TABLE pms_wbs_task (
objid varchar,
contract_objid varchar,
parent_objid varchar,
task_name varchar(1000) DEFAULT NULL,
task_seq varchar,
design_user_id varchar,
design_plan_start varchar,
design_plan_end varchar,
design_act_start varchar,
design_act_end varchar,
purchase_user_id varchar,
purchase_plan_start varchar,
purchase_plan_end varchar,
purchase_act_start varchar,
purchase_act_end varchar,
produce_user_id varchar,
produce_plan_start varchar,
produce_plan_end varchar,
produce_act_start varchar,
produce_act_end varchar,
selfins_user_id varchar,
selfins_plan_start varchar,
selfins_plan_end varchar,
selfins_act_start varchar,
selfins_act_end varchar,
finalins_user_id varchar,
finalins_plan_start varchar,
finalins_plan_end varchar,
finalins_act_start varchar,
finalins_act_end varchar,
ship_user_id varchar,
ship_plan_start varchar,
ship_plan_end varchar,
ship_act_start varchar,
ship_act_end varchar,
setup_user_id varchar,
setup_plan_start varchar,
setup_plan_end varchar,
setup_act_start varchar,
setup_act_end varchar,
writer varchar,
design_rate varchar DEFAULT '0',
purchase_rate varchar DEFAULT '0',
produce_rate varchar DEFAULT '0',
selfins_rate varchar DEFAULT '0',
finalins_rate varchar DEFAULT '0',
ship_rate varchar DEFAULT '0',
setup_rate varchar DEFAULT '0',
unit_no varchar,
reg_date timestamp,
update_date timestamp,
modifier varchar,
task_level varchar(10) DEFAULT '',
wbs_type varchar(20) DEFAULT '',
remark text DEFAULT '',
upper_task_objid varchar(255) DEFAULT '',
template_task_objid varchar(255) DEFAULT '',
progress varchar(10) DEFAULT ''
);
CREATE UNIQUE INDEX wbs_task_pk ON pms_wbs_task USING btree (objid);
CREATE INDEX pms_wbs_task_contract_objid_idx ON pms_wbs_task USING btree (contract_objid);
COMMENT ON COLUMN pms_wbs_task.objid IS '';
COMMENT ON COLUMN pms_wbs_task.contract_objid IS '계약키값';
COMMENT ON COLUMN pms_wbs_task.parent_objid IS '부모키';
COMMENT ON COLUMN pms_wbs_task.task_name IS 'task이름';
COMMENT ON COLUMN pms_wbs_task.task_seq IS 'task순번';
COMMENT ON COLUMN pms_wbs_task.design_user_id IS '설계담당';
COMMENT ON COLUMN pms_wbs_task.design_plan_start IS '설계계획시작일';
COMMENT ON COLUMN pms_wbs_task.design_plan_end IS '설계계획종료일';
COMMENT ON COLUMN pms_wbs_task.design_act_start IS '설계시작일';
COMMENT ON COLUMN pms_wbs_task.design_act_end IS '설계종료일';
COMMENT ON COLUMN pms_wbs_task.purchase_user_id IS '구매담당';
COMMENT ON COLUMN pms_wbs_task.purchase_plan_start IS '구매계획시작일';
COMMENT ON COLUMN pms_wbs_task.purchase_plan_end IS '구매계획종료일';
COMMENT ON COLUMN pms_wbs_task.purchase_act_start IS '구매시작일';
COMMENT ON COLUMN pms_wbs_task.purchase_act_end IS '구매종료일';
COMMENT ON COLUMN pms_wbs_task.produce_user_id IS '제작담당자';
COMMENT ON COLUMN pms_wbs_task.produce_plan_start IS '제작계획시작일';
COMMENT ON COLUMN pms_wbs_task.produce_plan_end IS '제작계획종료일';
COMMENT ON COLUMN pms_wbs_task.produce_act_start IS '제작시작일';
COMMENT ON COLUMN pms_wbs_task.produce_act_end IS '제작종료일';
COMMENT ON COLUMN pms_wbs_task.writer IS '작성자';
COMMENT ON COLUMN pms_wbs_task.design_rate IS '설계진척율';
COMMENT ON COLUMN pms_wbs_task.reg_date IS '등록일';
COMMENT ON COLUMN pms_wbs_task.update_date IS '수정일';
COMMENT ON COLUMN pms_wbs_task.modifier IS '수정자';
-- ------------------------------------------------------------
-- 2) pms_wbs_task_info (일반 task 리스트 — 실사용 518건)
-- ------------------------------------------------------------
DROP TABLE IF EXISTS pms_wbs_task_info CASCADE;
CREATE TABLE pms_wbs_task_info (
objid varchar(64) NOT NULL,
target_objid varchar(64),
task_step varchar(32),
task_name varchar(256),
task_seq varchar(32),
dept_code varchar(32),
manager_user_id varchar(32),
task_perform_day varchar(32),
plan_start_date varchar(64),
plan_end_date varchar(64),
result_start_date varchar(64),
result_end_date varchar(64),
expected_point varchar(32),
standard_doc_name varchar(512),
task_status varchar(32),
pm_user_id varchar(32),
pm_confirm_status varchar(32),
pm_confirm_date varchar(64),
remark varchar(256),
writer varchar(32),
reg_date timestamp,
update_date timestamp,
CONSTRAINT pms_wbs_task_info_pkey PRIMARY KEY (objid)
);
COMMENT ON COLUMN pms_wbs_task_info.objid IS '유일키';
COMMENT ON COLUMN pms_wbs_task_info.target_objid IS '프로젝트 유일키';
COMMENT ON COLUMN pms_wbs_task_info.task_step IS '테스크 단계(Phase)';
COMMENT ON COLUMN pms_wbs_task_info.task_name IS '테스크 명';
COMMENT ON COLUMN pms_wbs_task_info.task_seq IS '테스트 순서';
COMMENT ON COLUMN pms_wbs_task_info.dept_code IS '부서코드';
COMMENT ON COLUMN pms_wbs_task_info.manager_user_id IS '담당자코드';
COMMENT ON COLUMN pms_wbs_task_info.task_perform_day IS '수행소요일';
COMMENT ON COLUMN pms_wbs_task_info.plan_start_date IS '계획 시작일';
COMMENT ON COLUMN pms_wbs_task_info.plan_end_date IS '계획 종료일';
COMMENT ON COLUMN pms_wbs_task_info.result_start_date IS '실적 시작일';
COMMENT ON COLUMN pms_wbs_task_info.result_end_date IS '실적 종료일';
COMMENT ON COLUMN pms_wbs_task_info.expected_point IS '예상시점';
COMMENT ON COLUMN pms_wbs_task_info.standard_doc_name IS '표준문서명';
COMMENT ON COLUMN pms_wbs_task_info.task_status IS '테스트 상태';
COMMENT ON COLUMN pms_wbs_task_info.pm_user_id IS 'PM 아이디';
COMMENT ON COLUMN pms_wbs_task_info.pm_confirm_status IS 'PM 승인 상태';
COMMENT ON COLUMN pms_wbs_task_info.pm_confirm_date IS 'PM 승인 일자';
COMMENT ON COLUMN pms_wbs_task_info.remark IS '비고';
COMMENT ON COLUMN pms_wbs_task_info.writer IS '작성자';
COMMENT ON COLUMN pms_wbs_task_info.reg_date IS '등록일';
COMMENT ON COLUMN pms_wbs_task_info.update_date IS '수정일';
-- ------------------------------------------------------------
-- 3) pms_wbs_task_confirm (작업 확정 — objid numeric, 0건)
-- ------------------------------------------------------------
DROP TABLE IF EXISTS pms_wbs_task_confirm CASCADE;
CREATE TABLE pms_wbs_task_confirm (
objid numeric,
target_objid numeric,
confirm_type varchar(32),
contents varchar(4000),
result varchar(32),
regdate timestamp,
writer varchar(32)
);
-- ------------------------------------------------------------
-- 4) pms_wbs_task_standard (트리 표준 — 5건)
-- ------------------------------------------------------------
DROP TABLE IF EXISTS pms_wbs_task_standard CASCADE;
CREATE TABLE pms_wbs_task_standard (
objid varchar NOT NULL,
parent_objid varchar,
task_name varchar,
task_seq varchar,
user_id varchar,
writer varchar,
reg_date timestamp,
unit_no varchar,
upper_task_objid varchar,
task_level varchar,
CONSTRAINT pms_wbs_task_standard_pkey PRIMARY KEY (objid)
);
-- ------------------------------------------------------------
-- 5) pms_wbs_task_standard2 (일반 task 표준 — 실사용 74건)
-- ------------------------------------------------------------
DROP TABLE IF EXISTS pms_wbs_task_standard2 CASCADE;
CREATE TABLE pms_wbs_task_standard2 (
task_step varchar(32),
task_name varchar(256),
task_seq varchar(32),
dept_code varchar(32),
manager_user_id varchar(32),
task_perform_day varchar(32),
plan_start_date varchar(64),
plan_end_date varchar(64),
result_start_date varchar(64),
result_end_date varchar(64),
expected_point varchar(64),
standard_doc_name varchar(512),
task_status varchar(32),
pm_user_id varchar(32),
pm_confirm_status varchar(32),
pm_confirm_date varchar(64),
remark varchar(256),
writer varchar(32),
reg_date timestamp,
update_date timestamp
);
COMMENT ON COLUMN pms_wbs_task_standard2.task_step IS '테스크 단계(Phase)';
COMMENT ON COLUMN pms_wbs_task_standard2.task_name IS '테스크 명';
COMMENT ON COLUMN pms_wbs_task_standard2.dept_code IS '부서코드';
COMMENT ON COLUMN pms_wbs_task_standard2.manager_user_id IS '담당자코드';
COMMENT ON COLUMN pms_wbs_task_standard2.task_perform_day IS '수행소요일';
COMMENT ON COLUMN pms_wbs_task_standard2.plan_start_date IS '계획 시작일';
COMMENT ON COLUMN pms_wbs_task_standard2.plan_end_date IS '계획 종료일';
COMMENT ON COLUMN pms_wbs_task_standard2.result_start_date IS '실적 시작일';
COMMENT ON COLUMN pms_wbs_task_standard2.result_end_date IS '실적 종료일';
COMMENT ON COLUMN pms_wbs_task_standard2.expected_point IS '예상시점';
COMMENT ON COLUMN pms_wbs_task_standard2.standard_doc_name IS '표준문서명';
COMMENT ON COLUMN pms_wbs_task_standard2.task_status IS '테스트 상태';
COMMENT ON COLUMN pms_wbs_task_standard2.pm_user_id IS 'PM 아이디';
COMMENT ON COLUMN pms_wbs_task_standard2.pm_confirm_status IS 'PM 승인 상태';
COMMENT ON COLUMN pms_wbs_task_standard2.pm_confirm_date IS 'PM 승인 일자';
COMMENT ON COLUMN pms_wbs_task_standard2.remark IS '비고';
COMMENT ON COLUMN pms_wbs_task_standard2.writer IS '작성자';
COMMENT ON COLUMN pms_wbs_task_standard2.reg_date IS '등록일';
COMMENT ON COLUMN pms_wbs_task_standard2.update_date IS '수정일';
-- ------------------------------------------------------------
-- 6) pms_wbs_template (제품별 WBS 템플릿 — 1건)
-- ------------------------------------------------------------
DROP TABLE IF EXISTS pms_wbs_template CASCADE;
CREATE TABLE pms_wbs_template (
objid varchar NOT NULL,
product_objid varchar,
title varchar,
writer varchar,
reg_date timestamp,
customer_product varchar,
CONSTRAINT pms_wbs_template_pkey PRIMARY KEY (objid)
);
-- ------------------------------------------------------------
-- 7) setup_wbs_task (셋업 작업 진척 — 운영 2,576건)
-- ------------------------------------------------------------
DROP TABLE IF EXISTS setup_wbs_task CASCADE;
CREATE TABLE setup_wbs_task (
objid varchar,
contract_objid varchar,
parent_objid varchar,
task_category varchar,
task_name varchar(1000) DEFAULT NULL,
standard_objid varchar,
setup_plan_start varchar,
setup_plan_end varchar,
setup_act_start varchar,
setup_act_end varchar,
setup_delaye_day varchar,
writer varchar,
employees_in varchar,
employees_out varchar,
employees_total varchar,
setup_rate varchar DEFAULT '0',
unit_no varchar,
task_seq varchar,
proj_step varchar,
regdate timestamp
);
CREATE UNIQUE INDEX setup_wbs_task_pk ON setup_wbs_task USING btree (objid);
CREATE INDEX setup_wbs_task_contract_objid_idx ON setup_wbs_task USING btree (contract_objid);
COMMENT ON COLUMN setup_wbs_task.objid IS 'objid';
COMMENT ON COLUMN setup_wbs_task.contract_objid IS 'project_objid';
COMMENT ON COLUMN setup_wbs_task.parent_objid IS 'task부모키';
COMMENT ON COLUMN setup_wbs_task.task_category IS 'TASK구분';
COMMENT ON COLUMN setup_wbs_task.task_name IS 'TASK명';
COMMENT ON COLUMN setup_wbs_task.setup_plan_start IS '계획시작일';
COMMENT ON COLUMN setup_wbs_task.setup_plan_end IS '계획완료일';
COMMENT ON COLUMN setup_wbs_task.setup_act_start IS '실적시작일';
COMMENT ON COLUMN setup_wbs_task.setup_act_end IS '실적완료일';
COMMENT ON COLUMN setup_wbs_task.writer IS '작성자';
COMMENT ON COLUMN setup_wbs_task.employees_in IS '자사투입인원';
COMMENT ON COLUMN setup_wbs_task.employees_out IS '외주투입인원';
COMMENT ON COLUMN setup_wbs_task.regdate IS '등록일';
-- ------------------------------------------------------------
-- 8) setup_wbs_task_standard (셋업 표준 — 46건)
-- ------------------------------------------------------------
DROP TABLE IF EXISTS setup_wbs_task_standard CASCADE;
CREATE TABLE setup_wbs_task_standard (
objid varchar,
contract_objid varchar,
parent_objid varchar,
task_category varchar,
task_name varchar(1000) DEFAULT NULL,
setup_user_id varchar,
setup_plan_start varchar,
setup_plan_end varchar,
setup_act_start varchar,
setup_act_end varchar,
setup_delaye_day varchar,
writer varchar,
employees_in varchar,
employees_out varchar,
employees_total varchar,
setup_rate varchar DEFAULT '0',
unit_no varchar,
task_seq varchar,
proj_step varchar
);
-- 운영 인덱스 이름에 공백 포함됨(타이포로 추정): "setup_wbs_task_standard _objid_key"
CREATE UNIQUE INDEX "setup_wbs_task_standard _objid_key" ON setup_wbs_task_standard USING btree (objid);
COMMIT;
@@ -0,0 +1,102 @@
# 추출된 DDL (wace_plm 운영 DB) — 프로젝트관리(P2)
> 추출일: 2026-05-11
> 출처: `211.115.91.141:11133/waceplm` (PostgreSQL 16.8)
> 적용 대상: `211.115.91.141:11134/vexplor_rps`
> 추출 방법: information_schema 쿼리 (pg_dump 14.19 ↔ 16.8 버전 불일치로 직접 추출 불가 — 영업관리 패턴과 동일)
vexplor_rps에 부재한 WBS 계열 8개 테이블을 운영DB에서 추출해 P2(WBS관리)용 베이스 스키마 구성.
## 파일
| 파일 | 테이블 | 컬럼 | 운영 데이터 |
|---|---|---:|---:|
| `200_pms_wbs.sql` | `pms_wbs_task` (트리 — 설계/구매/제작/자체검사/최종검사/출하/셋업) | 58 | 0건 |
| | `pms_wbs_task_info` (**일반 task 리스트 — 실사용**) | 22 | 518건 |
| | `pms_wbs_task_confirm` (작업 확정, objid numeric) | 7 | 0건 |
| | `pms_wbs_task_standard` (트리 표준) | 10 | 5건 |
| | `pms_wbs_task_standard2` (**일반 task 표준 — 실사용**) | 20 | 74건 |
| | `pms_wbs_template` (제품별 템플릿) | 6 | 1건 |
| | `setup_wbs_task` (**셋업 진척 — 최다 사용**) | 20 | 2,576건 |
| | `setup_wbs_task_standard` (셋업 표준) | 19 | 46건 |
운영 컬럼 합 162개 / vexplor_rps 적용 후 162개 — 100% 일치 확인.
## 핵심 발견
### 1. 두 갈래 WBS 구조 (트리 vs 평면 리스트)
운영DB에 **두 종류 task 테이블이 공존**:
| 갈래 | 구조 | 운영 데이터 |
|---|---|---|
| **`pms_wbs_task` + `_standard`** | parent_objid 트리 / 단계별(설계/구매/제작/자체검사/최종검사/출하/셋업) plan/act/rate 컬럼 | 0건 / 5건 ← **사실상 미사용** |
| **`pms_wbs_task_info` + `_standard2`** | 평면 리스트 / task_step+task_seq 정렬 / PM 승인 흐름 | 518건 / 74건 ← **실사용** |
**운영판이 실제 어느 화면에서 어느 갈래를 쓰는지** wace JSP 분석 + 운영판 화면 캡처로 결정해야 함. `_info` 갈래가 데이터량 압도적이라 메인 후보지만, JSP 화면이 `pms_wbs_task`(트리)를 가리킬 가능성도 있음 — 두 갈래 모두 분석.
### 2. setup_wbs_task가 진척율 데이터 (P1 진행관리 그리드와 연결)
`setup_wbs_task` 2,576건이 운영에서 가장 큰 WBS 데이터. 진행관리 P1 그리드 컬럼 `제조1,2팀 / 제조3팀 / 조립 / 검증 / 출하일` 자리가 P1에선 빈 자리였는데, **이게 setup_wbs_task에서 채워지는 데이터**일 가능성 높음 (P1 GAP 문서 확인 필요).
### 3. 인덱스 명 typo
`setup_wbs_task_standard _objid_key` (`_standard` 뒤에 공백 한 칸) — 운영 그대로 1:1 보존 (`"…"` 따옴표 처리).
### 4. PK/UNIQUE 정책 일관성 없음
- PK: `pms_wbs_task_info`, `pms_wbs_task_standard`, `pms_wbs_template` (objid)
- UNIQUE 인덱스만: `pms_wbs_task`(wbs_task_pk), `setup_wbs_task`(setup_wbs_task_pk), `setup_wbs_task_standard`
- 제약 0: `pms_wbs_task_confirm`, `pms_wbs_task_standard2`
운영 1:1 보존. PostgreSQL 측에서는 UNIQUE 인덱스만 있어도 ON CONFLICT 등 동작에 영향 없음.
### 5. objid 채번 방식
운영DB에 WBS 시퀀스 없음 → wace Java 측에서 `UUID.randomUUID()` 또는 자체 시퀀스 함수로 생성한 문자열 사용. vexplor_rps에서는 nanoid/uuid 등 backend-node 측 채번 패턴 적용 예정 (영업관리 sales_no 패턴과는 다름).
## 운영 데이터 시드 (선택)
운영 데이터를 vexplor_rps에 가져오려면(데이터량 작은 표준/템플릿만):
```bash
# pg_dump 직접 사용 불가(버전 mismatch) — psql COPY 또는 INSERT 추출
PGPASSWORD='waceplm0909!!' psql -h 211.115.91.141 -p 11133 -U postgres -d waceplm \
-c "COPY (SELECT * FROM pms_wbs_task_standard2) TO STDOUT WITH CSV HEADER" \
> /tmp/pms_wbs_task_standard2.csv
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps \
-c "\COPY pms_wbs_task_standard2 FROM '/tmp/pms_wbs_task_standard2.csv' WITH CSV HEADER"
```
`setup_wbs_task` 2,576건도 동일 방식. 실 데이터 의존성(contract_objid가 운영 project_mgmt.objid를 참조) 때문에 vexplor_rps 측 데이터와 맞지 않을 가능성 — 시드는 화면 검증 단계에서 케이스별 판단.
## 추출 명령 재현
```bash
# 컬럼 정보
PGPASSWORD='waceplm0909!!' psql -h 211.115.91.141 -p 11133 -U postgres -d waceplm -t -A -F '|' -c "
SELECT table_name||'|'||ordinal_position||'|'||column_name||'|'||data_type||'|'||COALESCE(character_maximum_length::text,'')||'|'||COALESCE(numeric_precision::text,'')||'|'||COALESCE(numeric_scale::text,'')||'|'||is_nullable||'|'||COALESCE(column_default,'')
FROM information_schema.columns
WHERE table_schema='public'
AND table_name IN ('pms_wbs_task','pms_wbs_task_info','pms_wbs_task_confirm','pms_wbs_task_standard','pms_wbs_task_standard2','pms_wbs_template','setup_wbs_task','setup_wbs_task_standard')
ORDER BY table_name, ordinal_position;"
# 제약조건
... information_schema.table_constraints JOIN key_column_usage
# 인덱스
... pg_indexes WHERE schemaname='public'
# 컬럼 코멘트
... information_schema.columns LEFT JOIN pg_statio_all_tables JOIN pg_description
```
## 적용
```bash
PGPASSWORD='vexplor0909!!' psql -h 211.115.91.141 -p 11134 -U postgres -d vexplor_rps \
-v ON_ERROR_STOP=1 -f 200_pms_wbs.sql
```
8개 테이블 모두 IDEMPOTENT (`DROP TABLE IF EXISTS … CASCADE`로 재실행 안전).
@@ -0,0 +1,173 @@
"use client";
// 프로젝트관리 > 제품구분_WBS관리 (wace wbsTemplateMngList.jsp 1:1 이식)
// 원본:
// - JSP: /Users/jhj/wace_plm/WebContent/WEB-INF/view/project/wbsTemplateMngList.jsp (378줄)
// - 매퍼: wace_plm/src/com/pms/mapper/project.xml:5552 wbsTemplateMngGridList
// GAP: docs/migration/project/02-wbs-template.md
//
// 그리드: 5컬럼 (제품구분 / 제목 / WBS(folder) / 등록자 / 등록일)
// 검색: 제품구분 단일
// 등록/수정 통합 다이얼로그: WbsTemplateDialog (wace WBSExcelImportPopUp.jsp 1:1)
import React, { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Search, Loader2, RotateCcw, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { Label } from "@/components/ui/label";
import { wbsTemplateApi, TemplateRow } from "@/lib/api/wbsTemplate";
import { WbsTemplateDialog } from "@/components/project/WbsTemplateDialog";
const PRODUCT_GROUP = "0000001"; // 제품구분
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "product_name", label: "제품구분", width: "w-[200px]", frozen: true },
{ key: "title", label: "제목", minWidth: "min-w-[260px]" },
{
key: "wbs_task_cnt",
label: "WBS",
width: "w-[100px]",
align: "center",
renderType: "folder", // wace fnc_getFolderIcon
},
{ key: "writer_title", label: "등록자", width: "w-[180px]" },
{ key: "reg_date_title", label: "등록일", width: "w-[130px]", align: "center" },
];
export default function WbsTemplatePage() {
const [rows, setRows] = useState<TemplateRow[]>([]);
const [loading, setLoading] = useState(false);
const [filterProduct, setFilterProduct] = useState<string>("");
const [checkedIds, setCheckedIds] = useState<string[]>([]);
// 다이얼로그 상태
const [dialogOpen, setDialogOpen] = useState(false);
const [editObjId, setEditObjId] = useState<string | null>(null);
const [defaultProduct, setDefaultProduct] = useState<string>("");
const fetchList = useCallback(async (product?: string) => {
setLoading(true);
try {
const data = await wbsTemplateApi.list(product || undefined);
setRows(data);
setCheckedIds([]);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchList();
}, [fetchList]);
const handleSearch = () => fetchList(filterProduct);
const handleReset = () => {
setFilterProduct("");
fetchList();
};
// 등록 (wace btnRegist click — product 선택 필수)
const handleRegist = () => {
if (!filterProduct) {
toast.error("제품은 필수값입니다. 제품을 선택해 주세요.");
return;
}
setEditObjId(null);
setDefaultProduct(filterProduct);
setDialogOpen(true);
};
// 수정 (wace fn_openWBSTaskListPopUp — WBS 폴더 컬럼 클릭)
const handleOpenEdit = (row: any) => {
setEditObjId(row.objid);
setDefaultProduct("");
setDialogOpen(true);
};
// 삭제 (wace fn_delete — 체크된 행 다건 삭제)
const handleDelete = async () => {
if (checkedIds.length === 0) {
toast.error("선택된 대상이 없습니다.");
return;
}
if (!confirm("삭제하시겠습니까?")) return;
try {
const res = await wbsTemplateApi.remove(checkedIds);
toast.success(res?.msg ?? "삭제하였습니다.");
fetchList(filterProduct);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "삭제 실패");
}
};
// DataGrid 컬럼에 folder 클릭 핸들러 주입
const columns: DataGridColumn[] = GRID_COLUMNS.map((c) =>
c.key === "wbs_task_cnt" ? { ...c, onClick: handleOpenEdit } : c
);
return (
<div className="flex flex-col h-full">
{/* 검색폼 — wace wbsTemplateMngList.jsp:361-371 (제품구분 1필드) */}
<div className="border-b bg-card px-4 py-3">
<div className="flex items-end gap-4">
<div className="min-w-[260px]">
<Label className="mb-1 block text-xs text-muted-foreground"></Label>
<CommCodeSelect
groupId={PRODUCT_GROUP}
value={filterProduct}
onValueChange={setFilterProduct}
/>
</div>
<div className="ml-auto flex items-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={handleSearch} disabled={loading}>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
<Button size="sm" variant="default" onClick={handleRegist}>
<Plus className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleDelete}
disabled={checkedIds.length === 0}
>
<Trash2 className="h-4 w-4" /><span className="ml-1"></span>
</Button>
</div>
</div>
</div>
{/* 그리드 (5컬럼) */}
<div className="flex-1 min-h-0 p-2">
<DataGrid
columns={columns}
data={rows}
loading={loading}
showRowNumber
showCheckbox
checkedIds={checkedIds}
onCheckedChange={setCheckedIds}
emptyMessage="등록된 WBS 템플릿이 없습니다."
gridId="project-wbs-template"
/>
</div>
{/* 통합 팝업 */}
<WbsTemplateDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
templateObjId={editObjId}
defaultProduct={defaultProduct}
onSaved={() => fetchList(filterProduct)}
/>
</div>
);
}
+4
View File
@@ -752,6 +752,8 @@ export function DataGrid({
{columns.map((col) => {
const w = columnWidths[col.key];
const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined;
// 일반 텍스트 셀에 컬럼 단위 onClick 지원 (clip/folder 외)
const cellClickable = !!col.onClick && !col.editable;
return (
<TableCell
key={col.key}
@@ -759,9 +761,11 @@ export function DataGrid({
className={cn(
w == null && col.width, w == null && col.minWidth, "py-2.5",
col.editable && "cursor-text",
cellClickable && "cursor-pointer hover:underline text-primary",
isSelected && "bg-accent",
col.frozen && cn("sticky z-[5]", frozenLeftClass, stickyBgClass),
)}
onClick={cellClickable ? (e) => { e.stopPropagation(); col.onClick!(row); } : undefined}
onDoubleClick={(e) => {
if (col.editable) {
e.stopPropagation();
@@ -105,6 +105,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/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/project/wbs-template": dynamic(() => import("@/app/(main)/COMPANY_16/project/wbs-template/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 }),
@@ -0,0 +1,440 @@
"use client";
// 프로젝트관리 > 제품구분_WBS관리 통합 팝업
// wace WBSExcelImportPopUp.jsp (613줄) 1:1 이식
// - 트리 CRUD (추가/하위추가/삭제) + 수준 1/2/3 자동 번호 + 엑셀 임포트 + 템플릿 다운로드 + 저장
// - 헤더(제품구분/제목): 수정 모드에서 disabled (wace mergeExcelUploadWBS는 헤더 UPDATE 안 함)
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
import { Loader2, Download, Plus, CornerDownRight, Trash2, Save, Upload } from "lucide-react";
import { toast } from "sonner";
import { wbsTemplateApi, WbsTaskInput } from "@/lib/api/wbsTemplate";
const PRODUCT_GROUP = "0000001";
const TEMPLATE_DOWNLOAD_URL = "/templates/WBS_EXCEL_IMPORT_TEMPLATE.xlsx";
// 클라이언트 행 모델
interface WbsRow {
objid: string; // 클라이언트 임시 ID (UUID) — 저장 시 그대로 DB OBJID
depth: number; // 0=TOTAL, 1~3
unitNo: string; // "1", "1.1", "1.1.1" (자동 renumber로 채움)
taskName: string;
checked: boolean;
}
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
templateObjId: string | null; // null=신규
defaultProduct: string; // 신규 시 메인의 검색 product
onSaved: () => void;
}
function newObjId(): string {
return crypto.randomUUID();
}
function makeTotalRow(): WbsRow {
return { objid: newObjId(), depth: 0, unitNo: "0", taskName: "TOTAL", checked: false };
}
export function WbsTemplateDialog({ open, onOpenChange, templateObjId, defaultProduct, onSaved }: Props) {
const isEditMode = !!templateObjId;
const fileInputRef = useRef<HTMLInputElement>(null);
const [product, setProduct] = useState<string>("");
const [title, setTitle] = useState<string>("");
const [rows, setRows] = useState<WbsRow[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
// ─── 모드 분기 (wace WBSExcelImportPopUp.jsp:21-30) ──────
useEffect(() => {
if (!open) return;
if (isEditMode && templateObjId) {
loadExistingTasks(templateObjId);
} else {
setProduct(defaultProduct);
setTitle("");
setRows([makeTotalRow()]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
const loadExistingTasks = async (objid: string) => {
setLoading(true);
try {
const detail = await wbsTemplateApi.detail(objid);
if (!detail) {
toast.error("템플릿을 찾을 수 없습니다.");
onOpenChange(false);
return;
}
setProduct(detail.master.product_objid);
setTitle(detail.master.title);
// wace loadExistingTasks 1:1: TASK_LEVEL=0 → TOTAL 행, 그 외 depth/unit_no 매핑
const list: WbsRow[] = detail.tasks.map((t) => {
const depth = parseInt(t.task_level, 10);
if (depth === 0) {
return { objid: t.objid, depth: 0, unitNo: "0", taskName: t.task_name || "TOTAL", checked: false };
}
return {
objid: t.objid,
depth,
unitNo: t.unit_no || "",
taskName: t.task_name || "",
checked: false,
};
});
// TOTAL이 없을 경우 보강
if (!list.some((r) => r.depth === 0)) list.unshift(makeTotalRow());
setRows(list);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
onOpenChange(false);
} finally {
setLoading(false);
}
};
// ─── 트리 동작 ─────────────────────────────────────────
// wace renumberAllRows 1:1: depth별 카운터로 1.1.1 형식 재생성
const renumberRows = useCallback((arr: WbsRow[]): WbsRow[] => {
const counters = [0, 0, 0];
return arr.map((r) => {
if (r.depth === 0) return r;
const d = r.depth;
if (d === 1) { counters[0] += 1; counters[1] = 0; counters[2] = 0; }
else if (d === 2) { counters[1] += 1; counters[2] = 0; }
else if (d === 3) { counters[2] += 1; }
const unit = d === 1 ? `${counters[0]}`
: d === 2 ? `${counters[0]}.${counters[1]}`
: `${counters[0]}.${counters[1]}.${counters[2]}`;
return { ...r, unitNo: unit };
});
}, []);
// 마지막 후손 인덱스 (wace findLastDescendant)
const findLastDescendantIdx = (arr: WbsRow[], parentIdx: number): number => {
const parentDepth = arr[parentIdx].depth;
let last = parentIdx;
for (let i = parentIdx + 1; i < arr.length; i++) {
if (arr[i].depth > parentDepth) last = i;
else break;
}
return last;
};
// 추가 (wace addRow): 선택된 행 다음에 같은 depth 행 추가. 선택 없으면 끝에 depth=1.
const handleAddRow = () => {
setRows((prev) => {
const arr = prev.map((r) => ({ ...r, checked: false }));
const selectedIdx = prev.map((r, i) => (r.checked ? i : -1)).filter((i) => i >= 0).pop();
let insertAt: number;
let depth: number;
if (selectedIdx !== undefined && selectedIdx >= 0) {
const sel = prev[selectedIdx];
depth = sel.depth === 0 ? 1 : sel.depth;
insertAt = findLastDescendantIdx(prev, selectedIdx) + 1;
} else {
depth = 1;
insertAt = arr.length;
}
const newRow: WbsRow = { objid: newObjId(), depth, unitNo: "", taskName: "", checked: false };
const next = [...arr.slice(0, insertAt), newRow, ...arr.slice(insertAt)];
return renumberRows(next);
});
};
// 하위추가 (wace addChildRow): depth+1 행 추가 (3 거부)
const handleAddChildRow = () => {
const selectedIdx = rows.map((r, i) => (r.checked ? i : -1)).filter((i) => i >= 0).pop();
if (selectedIdx === undefined || selectedIdx < 0) {
toast.error("부모 행을 선택해 주세요.");
return;
}
const sel = rows[selectedIdx];
if (sel.depth >= 3) {
toast.error("수준 3 이하로는 추가할 수 없습니다.");
return;
}
setRows((prev) => {
const arr = prev.map((r) => ({ ...r, checked: false }));
const insertAt = findLastDescendantIdx(prev, selectedIdx) + 1;
const newRow: WbsRow = { objid: newObjId(), depth: sel.depth + 1, unitNo: "", taskName: "", checked: false };
const next = [...arr.slice(0, insertAt), newRow, ...arr.slice(insertAt)];
return renumberRows(next);
});
};
// 삭제 (wace deleteRow): 선택 행 + 모든 후손 cascade
const handleDeleteRow = () => {
const checkedIdx = rows.map((r, i) => (r.checked ? i : -1)).filter((i) => i >= 0);
if (checkedIdx.length === 0) {
toast.error("삭제할 행을 선택해 주세요.");
return;
}
const removeSet = new Set<number>();
let hasChildren = false;
for (const idx of checkedIdx) {
if (rows[idx].depth === 0) continue; // TOTAL 보호
removeSet.add(idx);
const lastDesc = findLastDescendantIdx(rows, idx);
if (lastDesc > idx) {
hasChildren = true;
for (let j = idx + 1; j <= lastDesc; j++) removeSet.add(j);
}
}
const msg = hasChildren ? "하위 항목도 함께 삭제됩니다. 삭제하시겠습니까?" : "삭제하시겠습니까?";
if (!confirm(msg)) return;
setRows((prev) => renumberRows(prev.filter((_, i) => !removeSet.has(i))));
};
// 체크박스 토글
const toggleCheck = (idx: number, val: boolean) => {
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, checked: val } : r)));
};
// taskName 수정
const updateTaskName = (idx: number, name: string) => {
setRows((prev) => prev.map((r, i) => (i === idx ? { ...r, taskName: name } : r)));
};
// ─── 엑셀 임포트 (wace parsingExcelFile 1:1) ────────────
const handleExcelFile = async (file: File) => {
setLoading(true);
try {
const parsed = await wbsTemplateApi.parseExcel(file);
// 결과를 행으로 변환 (depth는 unit_no의 '.' 개수+1)
const total = makeTotalRow();
const newRows: WbsRow[] = parsed.map((p) => {
const dotCount = (p.UNIT_NO.match(/\./g) || []).length;
const depth = Math.max(1, Math.min(3, dotCount + 1));
return { objid: newObjId(), depth, unitNo: "", taskName: p.TASK_NAME, checked: false };
});
setRows(renumberRows([total, ...newRows]));
toast.success(`${newRows.length}건 임포트 완료`);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "엑셀 파싱 실패");
} finally {
setLoading(false);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (!confirm("파일을 업로드하시겠습니까?\n기존에 입력된 항목은 삭제됩니다.")) {
e.target.value = "";
return;
}
handleExcelFile(file);
}
e.target.value = "";
};
// ─── 저장 (wace saveWBS 1:1) ──────────────────────────
const handleSave = async () => {
if (!product) { toast.error("제품구분을 선택해 주세요."); return; }
if (!title.trim()) { toast.error("제목을 입력해 주세요."); return; }
const dataRows = rows.filter((r) => r.depth > 0);
if (dataRows.length === 0) { toast.error("등록할 항목을 추가해 주세요."); return; }
for (const r of dataRows) {
if (!r.unitNo || !r.taskName.trim()) {
toast.error("수준과 Unit Name / 공정을 모두 입력해 주세요.");
return;
}
}
// 신규 모드: 중복 체크
if (!isEditMode) {
const dup = await wbsTemplateApi.checkDuplicate(product, title.trim());
if (dup) {
toast.error("이미 해당 제목으로 등록된 정보가 존재합니다.");
return;
}
}
// wace calculateParentRelations 1:1
const totalIdx = rows.findIndex((r) => r.depth === 0);
const totalObjId = totalIdx >= 0 ? rows[totalIdx].objid : "";
const tasks: WbsTaskInput[] = rows.map((r, i) => {
let upper = "";
if (r.depth === 0) {
upper = "";
} else if (r.depth === 1) {
upper = totalObjId;
} else {
for (let j = i - 1; j >= 0; j--) {
if (rows[j].depth === r.depth - 1) { upper = rows[j].objid; break; }
}
}
return {
WBS_TASK_OBJID: r.objid,
TASK_NAME: r.depth === 0 ? "TOTAL" : r.taskName,
UNIT_NO: r.depth === 0 ? "0" : r.unitNo,
UPPER_TASK_OBJID: upper,
TASK_LEVEL: String(r.depth),
};
});
if (!confirm("저장하시겠습니까?")) return;
setSaving(true);
try {
await wbsTemplateApi.save({
templateObjId: templateObjId ?? undefined,
product,
title: title.trim(),
tasks,
});
toast.success("저장하였습니다.");
onSaved();
onOpenChange(false);
} catch (e: any) {
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
} finally {
setSaving(false);
}
};
const titleText = useMemo(() => (isEditMode ? "WBS 템플릿 수정" : "WBS 템플릿 등록"), [isEditMode]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[1340px] w-[95vw] max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle>{titleText}</DialogTitle>
</DialogHeader>
{/* 헤더 — 제품구분 + 제목 (수정 시 disabled) */}
<div className="grid grid-cols-2 gap-4 px-1 py-2 border-b">
<div>
<Label className="mb-1 block text-xs text-muted-foreground"></Label>
<CommCodeSelect
groupId={PRODUCT_GROUP}
value={product}
onValueChange={setProduct}
disabled={isEditMode}
/>
</div>
<div>
<Label className="mb-1 block text-xs text-muted-foreground"></Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isEditMode}
placeholder="템플릿 제목"
/>
</div>
</div>
{/* 액션 버튼 */}
<div className="flex flex-wrap items-center gap-2 px-1 py-2 border-b">
<Button variant="outline" size="sm" asChild>
<a href={TEMPLATE_DOWNLOAD_URL} download>
<Download className="h-4 w-4" /><span className="ml-1">Template Download</span>
</a>
</Button>
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={loading}>
<Upload className="h-4 w-4" /><span className="ml-1"> </span>
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
className="hidden"
onChange={handleFileChange}
/>
<div className="ml-auto flex items-center gap-2">
<Button size="sm" onClick={handleAddRow}>
<Plus className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="secondary" onClick={handleAddChildRow}>
<CornerDownRight className="h-4 w-4" /><span className="ml-1"></span>
</Button>
<Button size="sm" variant="destructive" onClick={handleDeleteRow}>
<Trash2 className="h-4 w-4" /><span className="ml-1"></span>
</Button>
</div>
</div>
{/* 트리 그리드 */}
<div className="flex-1 min-h-0 overflow-auto border rounded">
<table className="w-full text-sm border-collapse">
<thead className="bg-muted sticky top-0">
<tr>
<th rowSpan={2} className="border px-2 py-1 w-[5%]"></th>
<th colSpan={3} className="border px-2 py-1 w-[24%]"></th>
<th rowSpan={2} className="border px-2 py-1 w-[71%]">Unit Name / </th>
</tr>
<tr>
<th className="border px-2 py-1 w-[8%]">1</th>
<th className="border px-2 py-1 w-[8%]">2</th>
<th className="border px-2 py-1 w-[8%]">3</th>
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td colSpan={5} className="text-center py-6">
<Loader2 className="h-5 w-5 animate-spin inline" /> ...
</td>
</tr>
)}
{!loading && rows.map((r, idx) => (
<tr key={r.objid} className={r.depth === 0 ? "bg-muted/50 font-semibold" : ""}>
<td className="border px-2 py-1 text-center">
{r.depth > 0 && (
<Checkbox
checked={r.checked}
onCheckedChange={(v) => toggleCheck(idx, v === true)}
/>
)}
</td>
{/* 수준 1/2/3 — depth 위치에만 unitNo 표시 */}
<td className="border px-2 py-1 text-center">{r.depth === 1 ? r.unitNo : ""}</td>
<td className="border px-2 py-1 text-center">{r.depth === 2 ? r.unitNo : ""}</td>
<td className="border px-2 py-1 text-center">{r.depth === 3 ? r.unitNo : ""}</td>
<td className="border px-2 py-1">
{r.depth === 0 ? (
<div className="text-center">TOTAL</div>
) : (
<Input
value={r.taskName}
onChange={(e) => updateTaskName(idx, e.target.value)}
className="h-7"
/>
)}
</td>
</tr>
))}
{!loading && rows.length === 0 && (
<tr><td colSpan={5} className="text-center py-6 text-muted-foreground"> .</td></tr>
)}
</tbody>
</table>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
<span className="ml-1"></span>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { apiClient } from "./client";
// ─── 타입 (wace project.xml wbsTemplateMngGridList 1:1) ─────
export interface TemplateRow {
objid: string;
product_objid: string | null;
product_name: string | null; // CODE_NAME(PRODUCT_OBJID)
title: string | null;
writer: string | null;
writer_title: string | null; // DEPT_NAME || USER_NAME
reg_date: string | null;
reg_date_title: string | null; // YYYY-MM-DD
wbs_task_cnt: number | string | null;
customer_product: string | null;
}
export interface TemplateMaster {
objid: string;
product_objid: string;
product_objid_name: string;
title: string;
writer: string;
reg_date: string;
customer_product: string;
}
export interface TemplateTask {
objid: string;
parent_objid: string;
task_name: string;
task_seq: string;
task_level: string;
user_id: string;
user_id_title: string;
writer: string;
reg_date: string;
unit_no: string;
upper_task_objid: string;
}
export interface TemplateDetail {
master: TemplateMaster;
tasks: TemplateTask[];
}
// 저장 payload — wace mergeExcelUploadWBS의 폼 hidden 직렬화에 대응
export interface WbsTaskInput {
WBS_TASK_OBJID: string;
TASK_NAME: string;
UNIT_NO: string;
UPPER_TASK_OBJID: string;
TASK_LEVEL: string;
}
export interface SaveTemplatePayload {
templateObjId?: string; // 있으면 수정, 없으면 신규
product: string;
title: string;
customer_product?: string;
tasks: WbsTaskInput[]; // TOTAL 행 포함 (TASK_LEVEL=0)
}
// 엑셀 파싱 결과
export interface ParsedExcelRow {
WBS_OBJID: string;
UNIT_NO: string;
TASK_NAME: string;
}
// ─── API ────────────────────────────────────────────────
export const wbsTemplateApi = {
async list(product?: string): Promise<TemplateRow[]> {
const res = await apiClient.get("/project/wbs-template", {
params: product ? { product } : {},
});
return (res.data?.data ?? []) as TemplateRow[];
},
async detail(objid: string): Promise<TemplateDetail | null> {
const res = await apiClient.get(`/project/wbs-template/${objid}`);
return res.data?.data ?? null;
},
async checkDuplicate(product: string, title: string): Promise<boolean> {
const res = await apiClient.get("/project/wbs-template/check-duplicate", {
params: { product, title },
});
return Boolean(res.data?.data?.duplicate);
},
async save(payload: SaveTemplatePayload) {
const res = await apiClient.post("/project/wbs-template", payload);
return res.data;
},
async remove(objids: string[]) {
const res = await apiClient.delete("/project/wbs-template", {
data: { objids },
});
return res.data;
},
async parseExcel(file: File): Promise<ParsedExcelRow[]> {
const form = new FormData();
form.append("file", file);
const res = await apiClient.post("/project/wbs-template/parse-excel", form, {
headers: { "Content-Type": "multipart/form-data" },
});
return (res.data?.data ?? []) as ParsedExcelRow[];
},
};