프로젝트관리>제품구분_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:
Generated
+105
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
},
|
||||
};
|
||||
Binary file not shown.
Reference in New Issue
Block a user