Merge pull request 'hjjeong' (#9) from hjjeong into main
Reviewed-on: https://g.wace.me/chpark/vexplor_rps/pulls/9
This commit is contained in:
@@ -117,6 +117,26 @@ export async function deploy(req: AuthenticatedRequest, res: Response) {
|
||||
// 운영판 wace: openPartExcelImportPopUp.jsp → partParsingExcelFile.do + partUploadSave.do
|
||||
// 본 RPS 구현: 파일을 메모리 파싱 → 검증 결과(NOTE 포함) 반환 / 저장 시 신규 part_no 만 INSERT.
|
||||
|
||||
// PART 자동완성 옵션 (IS_LAST='1' 전체) — wace select2-part 1:1
|
||||
// GET /api/development/part/options
|
||||
// response: { rows: [{ objid, part_no, part_name }] }
|
||||
export async function partOptions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const pool = (await import("../database/db")).getPool();
|
||||
const r = await pool.query(
|
||||
`SELECT OBJID::varchar AS objid, PART_NO AS part_no, PART_NAME AS part_name
|
||||
FROM PART_MNG
|
||||
WHERE COALESCE(IS_LAST,'') = '1'
|
||||
AND PART_NO IS NOT NULL AND PART_NO <> ''
|
||||
ORDER BY PART_NO`
|
||||
);
|
||||
return res.json({ success: true, data: { rows: r.rows } });
|
||||
} catch (e: any) {
|
||||
logger.error("PART options 조회 실패", { error: e.message });
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function excelParse(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const file = (req as any).file as Express.Multer.File | undefined;
|
||||
@@ -148,6 +168,43 @@ export async function excelSave(req: AuthenticatedRequest, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 도면 다중 업로드 (wace btnDrawingUpload 1:1) ───────────
|
||||
|
||||
export async function drawingMultiUpload(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const userId = (req.user as any)?.userId ?? "system";
|
||||
const companyCode = (req.user as any)?.companyCode ?? "COMPANY_16";
|
||||
const files = (req.files as Express.Multer.File[]) ?? [];
|
||||
if (files.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "업로드할 파일이 없습니다." });
|
||||
}
|
||||
// multipart body — partNoList 는 JSON 문자열로 전달됨.
|
||||
// 지정 → 그 목록만 매칭 후보 (M1 현재 그리드 한정)
|
||||
// 미지정/빈 배열 → IS_LAST='1' 전체 매칭 (M2 조회 — 페이지 밖도 허용)
|
||||
let partNoList: string[] | null = null;
|
||||
const raw = req.body?.partNoList;
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) partNoList = parsed.map(String).filter(Boolean);
|
||||
} catch {
|
||||
return res.status(400).json({ success: false, message: "partNoList JSON 파싱 실패" });
|
||||
}
|
||||
} else if (Array.isArray(raw)) {
|
||||
partNoList = raw.map(String).filter(Boolean);
|
||||
}
|
||||
const result = await svc.drawingMultiUpload(userId, companyCode, files, partNoList);
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: `업로드 완료 — 성공 ${result.successCount} / 미매칭 ${result.notFoundCount} / 실패 ${result.failCount}`,
|
||||
});
|
||||
} catch (e: any) {
|
||||
logger.error("도면 다중 업로드 실패", { error: e.message });
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 다중 삭제 ──────────────────────────────────────────────
|
||||
|
||||
export async function removeMany(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { Router } from "express";
|
||||
import multer from "multer";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import * as ctrl from "../controllers/devPartController";
|
||||
|
||||
@@ -16,6 +18,14 @@ const excelUpload = multer({
|
||||
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB
|
||||
});
|
||||
|
||||
// 도면 다중 업로드 — 임시 디스크 저장. 매칭 성공 시 서비스에서 최종 위치로 이동.
|
||||
const drawingTempDir = path.join(process.cwd(), "uploads", "temp");
|
||||
if (!fs.existsSync(drawingTempDir)) fs.mkdirSync(drawingTempDir, { recursive: true });
|
||||
const drawingUpload = multer({
|
||||
dest: drawingTempDir,
|
||||
limits: { fileSize: 200 * 1024 * 1024 }, // 파일당 200MB
|
||||
});
|
||||
|
||||
// M1 — 임시(등록) 그리드
|
||||
router.get("/part-temp/list", ctrl.getTempList);
|
||||
router.post("/part-temp/deploy", ctrl.deploy);
|
||||
@@ -27,6 +37,16 @@ router.get("/part/list", ctrl.getList);
|
||||
router.post("/part/excel-parse", excelUpload.single("file"), ctrl.excelParse);
|
||||
router.post("/part/excel-save", ctrl.excelSave);
|
||||
|
||||
// 도면 다중 업로드 (M1·M2 공용) — /:objid 보다 위
|
||||
router.post(
|
||||
"/part/drawing-multi-upload",
|
||||
drawingUpload.array("files", 500),
|
||||
ctrl.drawingMultiUpload
|
||||
);
|
||||
|
||||
// PART 자동완성 옵션 (select2-part 1:1) — /:objid 보다 위
|
||||
router.get("/part/options", ctrl.partOptions);
|
||||
|
||||
// 다중 삭제 (body: { objids: string[] }) — /:objid 보다 위
|
||||
router.delete("/part", ctrl.removeMany);
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface BomTreeFilter {
|
||||
unit_code?: string;
|
||||
search_part_no?: string;
|
||||
search_part_name?: string;
|
||||
search_level?: string | number; // wace 1:1 — 1~5 표시 레벨 (lev <= search_level)
|
||||
}
|
||||
|
||||
// ─── 공용 파라미터 빌더 ────────────────────────────────────
|
||||
@@ -99,11 +100,12 @@ export async function list(filter: BomReportListFilter) {
|
||||
T.CONTRACT_OBJID, PM.CUSTOMER_PROJECT_NAME, PM.PROJECT_NO,
|
||||
T.UNIT_CODE, COALESCE(WT.UNIT_NO || '-' || WT.TASK_NAME, '') AS UNIT_NAME,
|
||||
T.STATUS,
|
||||
-- 운영판 wace 매퍼 1:1 (CREATE/CHANGEDESIGN/DEPLOY 만 라벨, 그 외 'Y'/'N' 등은 raw 표시)
|
||||
CASE UPPER(T.STATUS)
|
||||
WHEN 'CREATE' THEN '등록중'
|
||||
WHEN 'CHANGEDESIGN' THEN '설계변경미배포'
|
||||
WHEN 'DEPLOY' THEN '배포완료'
|
||||
ELSE '' END AS STATUS_TITLE,
|
||||
ELSE COALESCE(T.STATUS, '') END AS STATUS_TITLE,
|
||||
T.WRITER, UI.dept_name AS DEPT_NAME, UI.user_name AS USER_NAME,
|
||||
COALESCE(UI.dept_name || '/' || UI.user_name, '') AS DEPT_USER_NAME,
|
||||
T.REGDATE, TO_CHAR(T.REGDATE, 'YYYY-MM-DD') AS REG_DATE,
|
||||
@@ -161,15 +163,18 @@ export async function getByObjid(objid: string) {
|
||||
|
||||
export async function updateStatus(userId: string, objid: string, body: BomReportStatusBody) {
|
||||
if (!body.status) throw new Error("status는 필수입니다.");
|
||||
// RPS 정책: 상태를 'Y' 로 변경한 시점을 확정일(DEPLOY_DATE) 로 기록.
|
||||
// 'N' 으로 변경 시는 기존 DEPLOY_DATE 보존 (마지막 확정 기록 유지).
|
||||
const sql = `
|
||||
UPDATE PART_BOM_REPORT
|
||||
SET PRODUCT_CD = COALESCE($1, PRODUCT_CD),
|
||||
PART_NO = COALESCE($2, PART_NO),
|
||||
PART_NAME = COALESCE($3, PART_NAME),
|
||||
REVISION = COALESCE($4, REVISION),
|
||||
STATUS = $5,
|
||||
editer = $6,
|
||||
edit_date = NOW()
|
||||
SET PRODUCT_CD = COALESCE($1, PRODUCT_CD),
|
||||
PART_NO = COALESCE($2, PART_NO),
|
||||
PART_NAME = COALESCE($3, PART_NAME),
|
||||
REVISION = COALESCE($4, REVISION),
|
||||
STATUS = $5::varchar,
|
||||
DEPLOY_DATE = CASE WHEN UPPER($5::varchar) = 'Y' THEN TO_CHAR(NOW(), 'YYYY-MM-DD') ELSE DEPLOY_DATE END,
|
||||
editer = $6,
|
||||
edit_date = NOW()
|
||||
WHERE OBJID = $7
|
||||
`;
|
||||
const r = await getPool().query(sql, [
|
||||
@@ -214,7 +219,9 @@ export async function ascending(filter: BomTreeFilter) {
|
||||
const conds: string[] = [];
|
||||
let idx = 1;
|
||||
|
||||
// 시작점 필터: 명시적 bom_report_objid 또는 part_bom_report 필터로 좁힘
|
||||
// 시작점 필터 (anchor): 명시적 bom_report_objid 또는 part_bom_report 필터로 좁힘.
|
||||
// 품번/품명 검색은 결과 필터가 아니라 매칭된 PART 가 들어있는 BOM_REPORT 전체를 anchor 로
|
||||
// 잡아야 트리 자식들이 같이 풀림 (wace 운영판 동작 1:1).
|
||||
if (filter.bom_report_objid) {
|
||||
conds.push(`BP.bom_report_objid = $${idx++}`);
|
||||
params.push(filter.bom_report_objid);
|
||||
@@ -224,40 +231,62 @@ export async function ascending(filter: BomTreeFilter) {
|
||||
if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); }
|
||||
conds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`);
|
||||
}
|
||||
const startWhere = conds.length ? conds.join(" AND ") : "1=1";
|
||||
|
||||
// PART 검색 필터는 결과 단계 적용
|
||||
const finalConds: string[] = [];
|
||||
if (filter.search_part_no) {
|
||||
finalConds.push(`UPPER(PM.part_no) LIKE UPPER($${idx++})`);
|
||||
conds.push(`BP.bom_report_objid IN (
|
||||
SELECT DISTINCT BQ.bom_report_objid FROM bom_part_qty BQ
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM part_mng PMS
|
||||
WHERE PMS.objid::varchar = BQ.part_no
|
||||
AND UPPER(PMS.part_no) LIKE UPPER($${idx++})
|
||||
)
|
||||
)`);
|
||||
params.push(`%${filter.search_part_no}%`);
|
||||
}
|
||||
if (filter.search_part_name) {
|
||||
finalConds.push(`UPPER(PM.part_name) LIKE UPPER($${idx++})`);
|
||||
conds.push(`BP.bom_report_objid IN (
|
||||
SELECT DISTINCT BQ.bom_report_objid FROM bom_part_qty BQ
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM part_mng PMS
|
||||
WHERE PMS.objid::varchar = BQ.part_no
|
||||
AND UPPER(PMS.part_name) LIKE UPPER($${idx++})
|
||||
)
|
||||
)`);
|
||||
params.push(`%${filter.search_part_name}%`);
|
||||
}
|
||||
const startWhere = conds.length ? conds.join(" AND ") : "1=1";
|
||||
|
||||
// 결과 단계 필터 — search_level 만 (트리 깊이 제한)
|
||||
const finalConds: string[] = [];
|
||||
if (filter.search_level) {
|
||||
finalConds.push(`T.lev <= $${idx++}::int`);
|
||||
params.push(filter.search_level);
|
||||
}
|
||||
const finalWhere = finalConds.length ? `WHERE ${finalConds.join(" AND ")}` : "";
|
||||
|
||||
const sql = `
|
||||
WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, seq, status, lev, path, cycle) AS (
|
||||
WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, item_qty, seq, status, lev, path, cycle) AS (
|
||||
SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid,
|
||||
BP.part_no, BP.qty, BP.seq, BP.status,
|
||||
BP.part_no, BP.qty, BP.item_qty, BP.seq, BP.status,
|
||||
1, ARRAY[BP.objid::varchar], FALSE
|
||||
FROM bom_part_qty BP
|
||||
WHERE (BP.parent_objid IS NULL OR BP.parent_objid = '')
|
||||
AND ${startWhere}
|
||||
UNION ALL
|
||||
SELECT B.bom_report_objid, B.objid, B.parent_objid, B.child_objid,
|
||||
B.part_no, B.qty, B.seq, B.status,
|
||||
B.part_no, B.qty, B.item_qty, B.seq, B.status,
|
||||
T.lev + 1, T.path || B.objid::varchar, B.objid::varchar = ANY(T.path)
|
||||
FROM bom_part_qty B
|
||||
JOIN TREE T ON B.parent_objid = T.child_objid AND NOT T.cycle
|
||||
)
|
||||
SELECT T.bom_report_objid, T.objid, T.parent_objid, T.child_objid, T.part_no, T.qty, T.seq, T.status,
|
||||
T.lev, T.path,
|
||||
T.item_qty AS p_qty,
|
||||
PM.part_no AS pm_part_no,
|
||||
PM.part_name AS pm_part_name,
|
||||
PM.spec, PM.material, PM.weight, PM.remark,
|
||||
PM.heat_treatment_hardness, PM.heat_treatment_method, PM.surface_treatment,
|
||||
PM.maker, PM.part_type,
|
||||
CC.code_name AS part_type_title,
|
||||
PM.edit_date,
|
||||
PM.eo_no, PM.revision,
|
||||
(SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='3D_CAD') AS cu01_cnt,
|
||||
@@ -265,7 +294,8 @@ export async function ascending(filter: BomTreeFilter) {
|
||||
(SELECT COUNT(1) FROM attach_file_info F WHERE F.target_objid = PM.objid::varchar AND F.doc_type='2D_PDF_CAD') AS cu03_cnt,
|
||||
(SELECT COALESCE(MAX(lev), 0) FROM TREE) AS max_level
|
||||
FROM TREE T
|
||||
LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar
|
||||
LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar
|
||||
LEFT JOIN comm_code CC ON CC.code_id = PM.part_type
|
||||
${finalWhere}
|
||||
ORDER BY T.path
|
||||
`;
|
||||
@@ -296,18 +326,33 @@ function buildAscendingExcelSql(filter: BomTreeFilter, startIdx: number) {
|
||||
if (filter.unit_code) { subConds.push(`unit_code = $${idx++}`); params.push(filter.unit_code); }
|
||||
conds.push(`BP.bom_report_objid IN (SELECT objid FROM part_bom_report WHERE ${subConds.join(" AND ")})`);
|
||||
}
|
||||
const startWhere = conds.length ? conds.join(" AND ") : "1=1";
|
||||
|
||||
const finalConds: string[] = [];
|
||||
// 품번/품명 검색 — anchor 단계로 (매칭 PART 가 들어있는 BOM 전체 트리 표시)
|
||||
if (filter.search_part_no) {
|
||||
finalConds.push(`UPPER(PM.part_no) LIKE UPPER($${idx++})`);
|
||||
conds.push(`BP.bom_report_objid IN (
|
||||
SELECT DISTINCT BQ.bom_report_objid FROM bom_part_qty BQ
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM part_mng PMS
|
||||
WHERE PMS.objid::varchar = BQ.part_no
|
||||
AND UPPER(PMS.part_no) LIKE UPPER($${idx++})
|
||||
)
|
||||
)`);
|
||||
params.push(`%${filter.search_part_no}%`);
|
||||
}
|
||||
if (filter.search_part_name) {
|
||||
finalConds.push(`UPPER(PM.part_name) LIKE UPPER($${idx++})`);
|
||||
conds.push(`BP.bom_report_objid IN (
|
||||
SELECT DISTINCT BQ.bom_report_objid FROM bom_part_qty BQ
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM part_mng PMS
|
||||
WHERE PMS.objid::varchar = BQ.part_no
|
||||
AND UPPER(PMS.part_name) LIKE UPPER($${idx++})
|
||||
)
|
||||
)`);
|
||||
params.push(`%${filter.search_part_name}%`);
|
||||
}
|
||||
const finalWhere = finalConds.length ? `WHERE ${finalConds.join(" AND ")}` : "";
|
||||
const startWhere = conds.length ? conds.join(" AND ") : "1=1";
|
||||
|
||||
// 엑셀은 search_level 적용 안 함 (전체 트리 다운로드가 자연스러움). 필요 시 추가.
|
||||
const finalWhere = "";
|
||||
|
||||
return { params, startWhere, finalWhere };
|
||||
}
|
||||
@@ -445,6 +490,14 @@ export async function descending(filter: BomTreeFilter) {
|
||||
}
|
||||
const anchorWhere = anchorConds.join(" AND ");
|
||||
|
||||
// 표시 레벨 필터 (wace search_level 1:1)
|
||||
const levelWhereParts: string[] = [];
|
||||
if (filter.search_level) {
|
||||
levelWhereParts.push(`T.lev <= $${idx++}::int`);
|
||||
params.push(filter.search_level);
|
||||
}
|
||||
const levelWhere = levelWhereParts.length ? `WHERE ${levelWhereParts.join(" AND ")}` : "";
|
||||
|
||||
const sql = `
|
||||
WITH RECURSIVE TREE(bom_report_objid, objid, parent_objid, child_objid, part_no, qty, seq, status, lev, path, cycle) AS (
|
||||
SELECT BP.bom_report_objid, BP.objid, BP.parent_objid, BP.child_objid,
|
||||
@@ -472,6 +525,7 @@ export async function descending(filter: BomTreeFilter) {
|
||||
(SELECT COALESCE(MAX(lev), 0) FROM TREE) AS max_level
|
||||
FROM TREE T
|
||||
LEFT JOIN part_mng PM ON T.part_no = PM.objid::varchar
|
||||
${levelWhere}
|
||||
ORDER BY T.path
|
||||
`;
|
||||
const r = await pool.query(sql, params);
|
||||
|
||||
@@ -19,9 +19,12 @@
|
||||
// ============================================================
|
||||
|
||||
import { PoolClient } from "pg";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import { getPool, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { createObjId } from "../utils/objidUtil";
|
||||
import { generateUUID } from "../utils/generateId";
|
||||
import { PART_BASE_SIMPLE } from "./devPartSqlFragments";
|
||||
|
||||
// ─── 필터/바디 타입 ──────────────────────────────────────────
|
||||
@@ -511,3 +514,253 @@ export async function removeMany(objids: string[]): Promise<number> {
|
||||
);
|
||||
return r.rowCount ?? 0;
|
||||
}
|
||||
|
||||
// ─── 도면 다중 업로드 ─────────────────────────────────────────
|
||||
// wace partMngTempList.jsp btnDrawingUpload + PartMngController.uploadDrawingFilesForPartList 1:1.
|
||||
//
|
||||
// 흐름:
|
||||
// 1) IS_LAST='1' part_mng 전체 조회 → PART_NO → OBJID 맵
|
||||
// 2) 각 파일별:
|
||||
// a) 확장자(STP/STEP/DWG/DXF/PDF) → doc_type 결정
|
||||
// (STP/STEP=3D_CAD, DWG/DXF=2D_DRAWING_CAD, PDF=2D_PDF_CAD)
|
||||
// b) 파일명에서 알려진 확장자 반복 제거 (.idw .dwg .dxf .stp .step .pdf .chg)
|
||||
// c) PART_NO 매칭: 정확 일치 우선 → 안 되면 startsWith (가장 긴 prefix)
|
||||
// d) 매칭 성공 → attach_file_info INSERT (target_objid = part_mng.objid)
|
||||
// e) 매칭 실패 → notFoundCount++ 파일 삭제
|
||||
|
||||
const DRAWING_KNOWN_EXTS = [".idw", ".dwg", ".dxf", ".stp", ".step", ".pdf", ".chg"];
|
||||
|
||||
function removeDrawingExtensions(fileName: string): string {
|
||||
let result = fileName;
|
||||
let removed = true;
|
||||
while (removed) {
|
||||
removed = false;
|
||||
const lastDot = result.lastIndexOf(".");
|
||||
if (lastDot > 0) {
|
||||
const ext = result.substring(lastDot).toLowerCase();
|
||||
if (DRAWING_KNOWN_EXTS.includes(ext)) {
|
||||
result = result.substring(0, lastDot);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findMatchingPartNo(
|
||||
fileNameWithoutExt: string,
|
||||
partNoSet: Set<string>
|
||||
): string | null {
|
||||
if (!fileNameWithoutExt || partNoSet.size === 0) return null;
|
||||
if (partNoSet.has(fileNameWithoutExt)) return fileNameWithoutExt;
|
||||
let best: string | null = null;
|
||||
for (const pn of partNoSet) {
|
||||
if (fileNameWithoutExt.startsWith(pn)) {
|
||||
if (!best || pn.length > best.length) best = pn;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface DrawingMultiUploadDetail {
|
||||
fileName: string;
|
||||
partNo?: string;
|
||||
docType?: string;
|
||||
status: "success" | "fail" | "notFound" | "unsupported";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DrawingMultiUploadResult {
|
||||
successCount: number;
|
||||
failCount: number;
|
||||
notFoundCount: number;
|
||||
details: DrawingMultiUploadDetail[];
|
||||
}
|
||||
|
||||
export async function drawingMultiUpload(
|
||||
userId: string,
|
||||
companyCode: string,
|
||||
files: Express.Multer.File[],
|
||||
partNoList: string[] | null | undefined
|
||||
): Promise<DrawingMultiUploadResult> {
|
||||
const pool = getPool();
|
||||
|
||||
// 1) 매칭 후보 PART 조회
|
||||
// partNoList 지정 → 그 목록 IN 절로 제한 (M1 등록 화면, 현재 그리드 기반)
|
||||
// partNoList 없음 → IS_LAST='1' 전체 (M2 조회 화면 — 페이지 밖 파트도 매칭 허용)
|
||||
// (wace partMng.xml partMngListByPartNos: `<if PART_NO_LIST != null>` 1:1)
|
||||
const hasList = Array.isArray(partNoList) && partNoList.length > 0;
|
||||
const partRes = hasList
|
||||
? await pool.query<{ objid: string; part_no: string }>(
|
||||
`SELECT objid::text AS objid, part_no
|
||||
FROM part_mng
|
||||
WHERE is_last = '1'
|
||||
AND part_no = ANY($1::text[])`,
|
||||
[partNoList]
|
||||
)
|
||||
: await pool.query<{ objid: string; part_no: string }>(
|
||||
`SELECT objid::text AS objid, part_no
|
||||
FROM part_mng
|
||||
WHERE is_last = '1' AND part_no IS NOT NULL`
|
||||
);
|
||||
const partNoMap = new Map<string, string>();
|
||||
for (const r of partRes.rows) {
|
||||
if (r.part_no) partNoMap.set(r.part_no, r.objid);
|
||||
}
|
||||
const partNoSet = new Set(partNoMap.keys());
|
||||
logger.info("도면 다중 업로드 시작", {
|
||||
files: files.length,
|
||||
scope: hasList ? "visible" : "all",
|
||||
requestedPartNos: hasList ? partNoList!.length : null,
|
||||
partCandidates: partNoMap.size,
|
||||
});
|
||||
|
||||
// 2) 회사/날짜 폴더 준비 (fileController.ts 와 동일 경로 규약)
|
||||
const baseUploadDir = path.join(process.cwd(), "uploads");
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(today.getDate()).padStart(2, "0");
|
||||
const dateFolder = `${year}/${month}/${day}`;
|
||||
const actualCompanyCode = companyCode === "*" ? "company_*" : companyCode;
|
||||
const finalUploadDir = path.join(baseUploadDir, actualCompanyCode, dateFolder);
|
||||
if (!fs.existsSync(finalUploadDir)) {
|
||||
fs.mkdirSync(finalUploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
const result: DrawingMultiUploadResult = {
|
||||
successCount: 0,
|
||||
failCount: 0,
|
||||
notFoundCount: 0,
|
||||
details: [],
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
// 파일명 UTF-8 디코딩
|
||||
let originalName: string;
|
||||
try {
|
||||
originalName = Buffer.from(file.originalname, "latin1").toString("utf8");
|
||||
} catch {
|
||||
originalName = file.originalname;
|
||||
}
|
||||
|
||||
// 확장자 → doc_type
|
||||
const ext = path
|
||||
.extname(originalName)
|
||||
.toLowerCase()
|
||||
.replace(".", "")
|
||||
.toUpperCase();
|
||||
let docType = "";
|
||||
let docTypeName = "";
|
||||
if (ext === "STP" || ext === "STEP") {
|
||||
docType = "3D_CAD";
|
||||
docTypeName = "3D CAD 첨부파일";
|
||||
} else if (ext === "DWG" || ext === "DXF") {
|
||||
docType = "2D_DRAWING_CAD";
|
||||
docTypeName = "2D(Drawing) CAD 첨부파일";
|
||||
} else if (ext === "PDF") {
|
||||
docType = "2D_PDF_CAD";
|
||||
docTypeName = "2D(PDF) CAD 첨부파일";
|
||||
} else {
|
||||
result.failCount++;
|
||||
result.details.push({
|
||||
fileName: originalName,
|
||||
status: "unsupported",
|
||||
reason: `지원하지 않는 확장자: ${ext}`,
|
||||
});
|
||||
try { fs.unlinkSync(file.path); } catch {}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 파일명 ↔ part_no 매칭
|
||||
const nameWithoutExt = removeDrawingExtensions(originalName);
|
||||
const matchedPartNo = findMatchingPartNo(nameWithoutExt, partNoSet);
|
||||
if (!matchedPartNo) {
|
||||
result.notFoundCount++;
|
||||
result.details.push({
|
||||
fileName: originalName,
|
||||
status: "notFound",
|
||||
reason: `품번 매칭 실패 (${nameWithoutExt})`,
|
||||
});
|
||||
try { fs.unlinkSync(file.path); } catch {}
|
||||
continue;
|
||||
}
|
||||
const targetObjid = partNoMap.get(matchedPartNo)!;
|
||||
|
||||
// 임시 → 최종 위치 이동
|
||||
const sanitizedName = originalName
|
||||
.replace(/[\/\\:*?"<>|]/g, "_")
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/_{2,}/g, "_");
|
||||
const savedFileName = `${Date.now()}_${sanitizedName}`;
|
||||
const finalFilePath = path.join(finalUploadDir, savedFileName);
|
||||
try {
|
||||
fs.renameSync(file.path, finalFilePath);
|
||||
} catch (e: any) {
|
||||
logger.error("도면 파일 저장 실패", { error: e.message, file: originalName });
|
||||
result.failCount++;
|
||||
result.details.push({
|
||||
fileName: originalName,
|
||||
partNo: matchedPartNo,
|
||||
docType,
|
||||
status: "fail",
|
||||
reason: "파일 저장 실패",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = `/${actualCompanyCode}/${dateFolder}/${savedFileName}`;
|
||||
const fullFilePath = `/uploads${relativePath}`;
|
||||
|
||||
// attach_file_info INSERT
|
||||
const objidValue = parseInt(
|
||||
generateUUID().replace(/-/g, "").substring(0, 15),
|
||||
16
|
||||
);
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO attach_file_info (
|
||||
objid, target_objid, saved_file_name, real_file_name, doc_type, doc_type_name,
|
||||
file_size, file_ext, file_path, company_code, writer, regdate, status
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), 'ACTIVE')`,
|
||||
[
|
||||
objidValue,
|
||||
targetObjid,
|
||||
savedFileName,
|
||||
originalName,
|
||||
docType,
|
||||
docTypeName,
|
||||
file.size,
|
||||
ext.toLowerCase(),
|
||||
fullFilePath,
|
||||
companyCode,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
result.successCount++;
|
||||
result.details.push({
|
||||
fileName: originalName,
|
||||
partNo: matchedPartNo,
|
||||
docType,
|
||||
status: "success",
|
||||
});
|
||||
} catch (e: any) {
|
||||
logger.error("attach_file_info INSERT 실패", {
|
||||
error: e.message,
|
||||
file: originalName,
|
||||
});
|
||||
result.failCount++;
|
||||
result.details.push({
|
||||
fileName: originalName,
|
||||
partNo: matchedPartNo,
|
||||
docType,
|
||||
status: "fail",
|
||||
reason: e.message,
|
||||
});
|
||||
try { fs.unlinkSync(finalFilePath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("도면 다중 업로드 완료", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
BEGIN;
|
||||
|
||||
-- ─── PART_BOM_REPORT staging + INSERT ──
|
||||
CREATE TEMP TABLE pbr_stage (
|
||||
objid varchar,
|
||||
customer_objid varchar,
|
||||
contract_objid varchar,
|
||||
unit_code varchar,
|
||||
status varchar,
|
||||
writer varchar,
|
||||
regdate timestamp,
|
||||
multi_yn varchar,
|
||||
multi_master_yn varchar,
|
||||
multi_break_yn varchar,
|
||||
multi_master_objid varchar,
|
||||
product_cd varchar,
|
||||
part_no varchar,
|
||||
part_name varchar,
|
||||
revision varchar,
|
||||
note varchar,
|
||||
editer varchar,
|
||||
edit_date timestamp,
|
||||
deploy_date varchar
|
||||
);
|
||||
\copy pbr_stage FROM '/tmp/pbr_long.csv' WITH CSV HEADER
|
||||
|
||||
INSERT INTO part_bom_report (
|
||||
objid, customer_objid, contract_objid, unit_code, status, writer, regdate,
|
||||
multi_yn, multi_master_yn, multi_break_yn, multi_master_objid,
|
||||
product_cd, part_no, part_name, revision, note, editer, edit_date, deploy_date
|
||||
)
|
||||
SELECT
|
||||
objid, customer_objid, contract_objid, unit_code, status, writer, regdate,
|
||||
multi_yn, multi_master_yn, multi_break_yn, multi_master_objid,
|
||||
product_cd, part_no, part_name, revision, note, editer, edit_date, deploy_date
|
||||
FROM pbr_stage
|
||||
ON CONFLICT (objid) DO NOTHING;
|
||||
|
||||
-- ─── BOM_PART_QTY staging + INSERT ──
|
||||
CREATE TEMP TABLE bpq_stage (
|
||||
bom_report_objid varchar,
|
||||
objid varchar,
|
||||
parent_objid varchar,
|
||||
child_objid varchar,
|
||||
parent_part_no varchar,
|
||||
part_no varchar,
|
||||
qty numeric,
|
||||
item_qty numeric,
|
||||
qty_temp numeric,
|
||||
regdate timestamp,
|
||||
writer varchar,
|
||||
seq bigint,
|
||||
status varchar,
|
||||
last_part_objid varchar,
|
||||
deploy_user_id varchar,
|
||||
deploy_date varchar
|
||||
);
|
||||
\copy bpq_stage FROM '/tmp/bpq_long.csv' WITH CSV HEADER
|
||||
|
||||
INSERT INTO bom_part_qty (
|
||||
bom_report_objid, objid, parent_objid, child_objid, parent_part_no, part_no,
|
||||
qty, item_qty, qty_temp, regdate, writer, seq, status, last_part_objid,
|
||||
deploy_user_id, deploy_date
|
||||
)
|
||||
SELECT
|
||||
bom_report_objid, objid, parent_objid, child_objid, parent_part_no, part_no,
|
||||
qty, item_qty, qty_temp, regdate, writer, seq, status, last_part_objid,
|
||||
deploy_user_id, deploy_date
|
||||
FROM bpq_stage
|
||||
ON CONFLICT (objid) DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
|
||||
SELECT 'PART_BOM_REPORT:' AS label, COUNT(*) FROM part_bom_report WHERE objid = '1038014721'
|
||||
UNION ALL
|
||||
SELECT 'BOM_PART_QTY:', COUNT(*) FROM bom_part_qty WHERE bom_report_objid = '1038014721';
|
||||
@@ -13,6 +13,7 @@
|
||||
**왜 필요했나**: 2026-05-12 PART 상세 다이얼로그 검증 중 발견. 품번/품명만 표시되고 재료/규격/계정구분/조달구분/재고단위/관리단위/환산수량/LOT구분/사용여부/검사여부/SET품여부/의뢰여부 등 거의 모든 컬럼이 NULL. 운영DB 같은 part_no는 정상적으로 채워져 있어서 마이그레이션 누락이 원인.
|
||||
|
||||
**동기화 대상 컬럼 20개**:
|
||||
|
||||
- 재료/형상: `material` / `heat_treatment_hardness` / `heat_treatment_method` / `surface_treatment`
|
||||
- 기본: `maker` / `part_type` / `spec`
|
||||
- ERP 분류: `acctfg` / `odrfg` / `unit_dc` / `unitmang_dc` / `unitchng_nb`
|
||||
@@ -67,3 +68,35 @@ SELECT part_no, material, spec, part_type, acctfg, odrfg, unit_dc, unitmang_dc,
|
||||
**1:1 정합성**: 운영DB의 컬럼 값을 그대로 복사. NULL 인 운영 컬럼은 RPS 도 NULL 유지 (덮어쓰기 안 함). `unitchng_nb` 만 numeric 캐스팅.
|
||||
|
||||
**재실행 안전**: idempotent — 같은 데이터로 다시 UPDATE 만 일어남.
|
||||
|
||||
---
|
||||
|
||||
## 02_sequences.sql
|
||||
|
||||
wace 매퍼에서 쓰는 시퀀스 5종 중 RPS 에 없던 4종 신규 생성 + 운영 last_value 보다 큰 값으로 setval (PK 충돌 방지). 자세한 내용은 파일 상단 주석 참조.
|
||||
|
||||
| 시퀀스 | last_value (RPS) | 매퍼 사용처 |
|
||||
| --- | ---: | --- |
|
||||
| `seq_bom_qty` | 200,000 | `partMng.relatePartInfo` (BOM_PART_QTY.SEQ) |
|
||||
| `seq_as_no` | 1,000 | 영업관리 AS 번호 |
|
||||
| `seq_comm_code` | 10,000 | comm_code 신규 |
|
||||
| `seq_eo_no` | 1,000 | EO_NO 일부 매퍼 |
|
||||
| `seq_ecr_no` | 33 (기존) | 설계변경 ECR 번호 |
|
||||
|
||||
---
|
||||
|
||||
## 03_long_bom_sample.sql
|
||||
|
||||
**대상**: BOM 트리 화면 검증용 긴 BOM 1건을 운영DB → RPS 복사.
|
||||
|
||||
**선정**: `part_bom_report.objid = '1038014721'` / `21008-0109` / "BS030-120H4A11-EN"
|
||||
· 126 행 / 4 레벨 (L1=1 / L2=37 / L3=62 / L4=26)
|
||||
|
||||
**파일**:
|
||||
|
||||
- `03_long_bom_sample.sql` — TEMP staging + ON CONFLICT DO NOTHING INSERT
|
||||
- `pbr_long.csv` — part_bom_report 1행
|
||||
- `bpq_long.csv` — bom_part_qty 126행
|
||||
|
||||
**용도**: E-BOM 조회(M4) 트리 그리드의 동적 LEVEL 컬럼 / 토글 / search_level 등 검증.
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
bom_report_objid,objid,parent_objid,child_objid,parent_part_no,part_no,qty,item_qty,qty_temp,regdate,writer,seq,status,last_part_objid,deploy_user_id,deploy_date
|
||||
1038014721,1559666144,"",-1521588791,"",1868257241,0,0,0,2026-05-08 08:14:28.083737,ljh0920,178720,deploy,,,
|
||||
1038014721,-79232032,-1521588791,-548673548,1868257241,1868257116,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178721,deploy,,,
|
||||
1038014721,-1208707322,-548673548,479232996,1868257116,1868256711,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178722,deploy,,,
|
||||
1038014721,-554492118,-548673548,-1062058696,1868257116,1868253980,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178723,deploy,,,
|
||||
1038014721,-1681690346,-548673548,-313600830,1868257116,1868254016,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178724,deploy,,,
|
||||
1038014721,182473835,-548673548,1062103299,1868257116,1868258246,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178725,deploy,,,
|
||||
1038014721,1088506840,-548673548,-1469793642,1868257116,1459128296,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178726,deploy,,,
|
||||
1038014721,-1849356384,-1521588791,-101504493,1868257241,1868255034,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178727,deploy,,,
|
||||
1038014721,-496874208,-1521588791,-525842843,1868257241,-1497736809,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178728,deploy,,,
|
||||
1038014721,-1226877300,-1521588791,523888233,1868257241,1868255027,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178729,deploy,,,
|
||||
1038014721,-1173153797,-1521588791,730726408,1868257241,1868255028,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178730,deploy,,,
|
||||
1038014721,-1224950647,-1521588791,1465483573,1868257241,1868258184,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178731,deploy,,,
|
||||
1038014721,-612476995,1465483573,-770903803,1868258184,1868257575,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178732,deploy,,,
|
||||
1038014721,-1776822234,1465483573,-1875730121,1868258184,1868253981,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178733,deploy,,,
|
||||
1038014721,-1885221953,1465483573,-457061488,1868258184,69781814,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178734,deploy,,,
|
||||
1038014721,11848010,-1521588791,693690566,1868257241,1868254590,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178735,deploy,,,
|
||||
1038014721,-1291563629,693690566,1086471646,1868254590,789989579,10,10,10,2026-05-08 08:14:28.083737,ljh0920,178736,deploy,,,
|
||||
1038014721,55839955,693690566,-660175297,1868254590,-866631301,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178737,deploy,,,
|
||||
1038014721,-1140742311,693690566,-1351446113,1868254590,1883006179,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178738,deploy,,,
|
||||
1038014721,-1799177146,-1521588791,412565572,1868257241,1868254591,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178739,deploy,,,
|
||||
1038014721,1247986216,412565572,-83067400,1868254591,1301285042,8,8,8,2026-05-08 08:14:28.083737,ljh0920,178740,deploy,,,
|
||||
1038014721,-305828681,412565572,-84477277,1868254591,69781814,13,13,13,2026-05-08 08:14:28.083737,ljh0920,178741,deploy,,,
|
||||
1038014721,-674691834,412565572,-1490280298,1868254591,1883006179,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178742,deploy,,,
|
||||
1038014721,-2131654934,-1521588791,1348902922,1868257241,1868254279,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178743,deploy,,,
|
||||
1038014721,-272710303,1348902922,-2062553432,1868254279,789989579,10,10,10,2026-05-08 08:14:28.083737,ljh0920,178744,deploy,,,
|
||||
1038014721,-563106651,1348902922,-610910948,1868254279,69781814,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178745,deploy,,,
|
||||
1038014721,-2122512954,-1521588791,1610889892,1868257241,1868254592,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178746,deploy,,,
|
||||
1038014721,561501355,1610889892,-1073466603,1868254592,-892520081,8,8,8,2026-05-08 08:14:28.083737,ljh0920,178747,deploy,,,
|
||||
1038014721,-1292299878,1610889892,-1307819463,1868254592,69781814,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178748,deploy,,,
|
||||
1038014721,-1535862889,1610889892,1396125994,1868254592,1883006179,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178749,deploy,,,
|
||||
1038014721,1180308429,1610889892,635632516,1868254592,852766930,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178750,deploy,,,
|
||||
1038014721,-908382777,1610889892,2066530264,1868254592,2016821980,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178751,deploy,,,
|
||||
1038014721,-420196801,-1521588791,1761199292,1868257241,1868255756,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178752,deploy,,,
|
||||
1038014721,-195126666,1761199292,-1547748587,1868255756,1868254593,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178753,deploy,,,
|
||||
1038014721,-188031930,1761199292,1507544955,1868255756,-543393122,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178754,deploy,,,
|
||||
1038014721,-288068797,1761199292,-502406798,1868255756,1868258356,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178755,deploy,,,
|
||||
1038014721,1495115457,-1521588791,-282885416,1868257241,1868254594,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178756,deploy,,,
|
||||
1038014721,1654449278,-282885416,808062574,1868254594,2027026726,12,12,12,2026-05-08 08:14:28.083737,ljh0920,178757,deploy,,,
|
||||
1038014721,2059483223,-282885416,-1010734071,1868254594,69781814,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178758,deploy,,,
|
||||
1038014721,2124466730,-282885416,405044485,1868254594,1883006179,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178759,deploy,,,
|
||||
1038014721,452340927,-282885416,392996411,1868254594,852766930,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178760,deploy,,,
|
||||
1038014721,-13512735,-1521588791,-503094338,1868257241,1868254595,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178761,deploy,,,
|
||||
1038014721,259035794,-503094338,1052438050,1868254595,789989579,8,8,8,2026-05-08 08:14:28.083737,ljh0920,178762,deploy,,,
|
||||
1038014721,-1619664303,-503094338,446284827,1868254595,-1463876694,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178763,deploy,,,
|
||||
1038014721,1675597636,-1521588791,-1426334519,1868257241,1868255035,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178764,deploy,,,
|
||||
1038014721,-99374109,-1521588791,632211520,1868257241,1868255029,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178765,deploy,,,
|
||||
1038014721,-16914337,632211520,-1085590517,1868255029,-1936805828,25,25,25,2026-05-08 08:14:28.083737,ljh0920,178766,deploy,,,
|
||||
1038014721,1790722312,-1521588791,504572091,1868257241,815268504,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178767,deploy,,,
|
||||
1038014721,171615785,-1521588791,540571401,1868257241,1868255030,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178768,deploy,,,
|
||||
1038014721,-1272025569,-1521588791,1821127350,1868257241,1868255031,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178769,deploy,,,
|
||||
1038014721,850232760,-1521588791,2099233225,1868257241,1868258185,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178770,deploy,,,
|
||||
1038014721,1391795032,2099233225,-1108878040,1868258185,1868257576,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178771,deploy,,,
|
||||
1038014721,625393882,2099233225,311982288,1868258185,1868253981,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178772,deploy,,,
|
||||
1038014721,997015823,2099233225,-44343182,1868258185,69781814,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178773,deploy,,,
|
||||
1038014721,-724753189,-1521588791,2146471013,1868257241,1868256976,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178774,deploy,,,
|
||||
1038014721,1032690744,2146471013,-220520918,1868256976,1868256471,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178775,deploy,,,
|
||||
1038014721,-1979776989,2146471013,-1569918870,1868256976,1868257788,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178776,deploy,,,
|
||||
1038014721,-1153608261,2146471013,-1916530498,1868256976,1868255032,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178777,deploy,,,
|
||||
1038014721,1177586890,2146471013,659859734,1868256976,1868255033,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178778,deploy,,,
|
||||
1038014721,21795217,2146471013,1147084321,1868256976,1868257577,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178779,deploy,,,
|
||||
1038014721,1627361803,2146471013,-120916919,1868256976,1868255422,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178780,deploy,,,
|
||||
1038014721,-254610461,2146471013,-774178023,1868256976,-1679287681,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178781,deploy,,,
|
||||
1038014721,1740882428,2146471013,747417918,1868256976,1431955518,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178782,deploy,,,
|
||||
1038014721,1522928195,-1521588791,2106356904,1868257241,1868254596,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178783,deploy,,,
|
||||
1038014721,1959301400,2106356904,-711768632,1868254596,-1917057884,6,6,6,2026-05-08 08:14:28.083737,ljh0920,178784,deploy,,,
|
||||
1038014721,-1501923404,2106356904,1091067136,1868254596,1693163279,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178785,deploy,,,
|
||||
1038014721,-341793378,2106356904,1866146965,1868254596,1883006179,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178786,deploy,,,
|
||||
1038014721,176040139,-1521588791,-44825979,1868257241,1868258466,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178787,deploy,,,
|
||||
1038014721,90638917,-1521588791,33570926,1868257241,1868258465,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178788,deploy,,,
|
||||
1038014721,1188002408,-1521588791,-193198154,1868257241,1868253840,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178789,deploy,,,
|
||||
1038014721,-1552304770,-193198154,1969390081,1868253840,309497336,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178790,deploy,,,
|
||||
1038014721,644618032,-193198154,-1616400233,1868253840,199236446,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178791,deploy,,,
|
||||
1038014721,1304651170,-1521588791,-1967028335,1868257241,126705965,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178792,deploy,,,
|
||||
1038014721,1820838639,-1521588791,-1197754488,1868257241,1868254282,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178793,deploy,,,
|
||||
1038014721,1834775819,-1197754488,-1824278144,1868254282,-273722357,8,8,8,2026-05-08 08:14:28.083737,ljh0920,178794,deploy,,,
|
||||
1038014721,300159773,-1521588791,-948899415,1868257241,1868255519,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178795,deploy,,,
|
||||
1038014721,1622885044,-948899415,957913261,1868255519,-1880419582,6,6,6,2026-05-08 08:14:28.083737,ljh0920,178796,deploy,,,
|
||||
1038014721,-997859223,-948899415,1605228598,1868255519,69781814,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178797,deploy,,,
|
||||
1038014721,12853128,-948899415,1081264382,1868255519,1883006179,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178798,deploy,,,
|
||||
1038014721,1428133853,-948899415,2097824408,1868255519,518957565,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178799,deploy,,,
|
||||
1038014721,548988722,-948899415,863414000,1868255519,-1467403985,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178800,deploy,,,
|
||||
1038014721,1384966071,-1521588791,-399991343,1868257241,1868255151,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178801,deploy,,,
|
||||
1038014721,-1554129280,-1521588791,1301275439,1868257241,1868256934,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178802,deploy,,,
|
||||
1038014721,1067181965,1301275439,869948546,1868256934,1868253780,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178803,deploy,,,
|
||||
1038014721,-1312260816,869948546,1709441427,1868253780,-1917057884,6,6,6,2026-05-08 08:14:28.083737,ljh0920,178804,deploy,,,
|
||||
1038014721,-394794243,869948546,1662860288,1868253780,69781814,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178805,deploy,,,
|
||||
1038014721,-1543445738,869948546,653961274,1868253780,1883006179,5,5,5,2026-05-08 08:14:28.083737,ljh0920,178806,deploy,,,
|
||||
1038014721,-135109401,869948546,-1402647890,1868253780,-1915838519,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178807,deploy,,,
|
||||
1038014721,-2081401333,1301275439,627204356,1868256934,-801178400,4,4,4,2026-05-08 08:14:28.083737,ljh0920,178808,deploy,,,
|
||||
1038014721,-556732609,1301275439,-1073642891,1868256934,1868256519,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178809,deploy,,,
|
||||
1038014721,-133221497,-1073642891,1112808004,1868256519,-69590343,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178810,deploy,,,
|
||||
1038014721,-997575795,-1073642891,1529319178,1868256519,-911071404,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178811,deploy,,,
|
||||
1038014721,387036841,-1073642891,780389519,1868256519,1351400071,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178812,deploy,,,
|
||||
1038014721,-1068853409,1301275439,-1615328870,1868256934,1868254919,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178813,deploy,,,
|
||||
1038014721,1854804733,1301275439,1187508190,1868256934,1868256427,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178814,deploy,,,
|
||||
1038014721,1942539416,1187508190,-1165929390,1868256427,518957565,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178815,deploy,,,
|
||||
1038014721,529469714,1187508190,-969879530,1868256427,-1467403985,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178816,deploy,,,
|
||||
1038014721,-1897164427,1301275439,-1206121403,1868256934,1868256520,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178817,deploy,,,
|
||||
1038014721,1275814859,-1206121403,728464068,1868256520,-911071404,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178818,deploy,,,
|
||||
1038014721,-774727537,-1206121403,-1192984331,1868256520,1351400071,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178819,deploy,,,
|
||||
1038014721,-1330895751,-1206121403,-859636087,1868256520,-385745086,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178820,deploy,,,
|
||||
1038014721,866417402,-1206121403,-1103711664,1868256520,-1205468047,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178821,deploy,,,
|
||||
1038014721,-1843274643,1301275439,-1259166155,1868256934,1868254283,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178822,deploy,,,
|
||||
1038014721,1526486834,-1259166155,-880598421,1868254283,-879076832,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178823,deploy,,,
|
||||
1038014721,-872086698,-1259166155,-92171525,1868254283,829507948,6,6,6,2026-05-08 08:14:28.083737,ljh0920,178824,deploy,,,
|
||||
1038014721,-620317895,-1259166155,1538215605,1868254283,5879525,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178825,deploy,,,
|
||||
1038014721,-1676334475,-1259166155,393652962,1868254283,-157025406,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178826,deploy,,,
|
||||
1038014721,-1295208669,-1259166155,-405141898,1868254283,-1161193983,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178827,deploy,,,
|
||||
1038014721,-1930039767,-1259166155,-1539194842,1868254283,-1784471891,3,3,3,2026-05-08 08:14:28.083737,ljh0920,178828,deploy,,,
|
||||
1038014721,-429167191,-1259166155,-445371738,1868254283,1413264321,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178829,deploy,,,
|
||||
1038014721,1373454102,-1259166155,1605103585,1868254283,93992113,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178830,deploy,,,
|
||||
1038014721,243655681,-1259166155,1550855806,1868254283,-489485631,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178831,deploy,,,
|
||||
1038014721,213493115,-1259166155,667467989,1868254283,-332507320,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178832,deploy,,,
|
||||
1038014721,1301720059,-1259166155,549000728,1868254283,-2016119642,2,2,2,2026-05-08 08:14:28.083737,ljh0920,178833,deploy,,,
|
||||
1038014721,-1245314635,-1259166155,-1713555241,1868254283,-1305054978,6,6,6,2026-05-08 08:14:28.083737,ljh0920,178834,deploy,,,
|
||||
1038014721,-1640275935,-1259166155,-1365622202,1868254283,1724129637,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178835,deploy,,,
|
||||
1038014721,1986920933,-1521588791,331838391,1868257241,1868254058,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178836,deploy,,,
|
||||
1038014721,-1949165911,331838391,-449212195,1868254058,-37088311,10,10,10,2026-05-08 08:14:28.083737,ljh0920,178837,deploy,,,
|
||||
1038014721,-916173285,-1521588791,2106704546,1868257241,1868254059,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178838,deploy,,,
|
||||
1038014721,-480521680,2106704546,1446984359,1868254059,-37088311,6,6,6,2026-05-08 08:14:28.083737,ljh0920,178839,deploy,,,
|
||||
1038014721,595768906,-1521588791,-1287071095,1868257241,-317009000,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178840,deploy,,,
|
||||
1038014721,361554923,-1521588791,-477898316,1868257241,875354438,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178841,deploy,,,
|
||||
1038014721,-2131354745,-1521588791,1283584576,1868257241,-1379081967,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178842,deploy,,,
|
||||
1038014721,2101454196,-1521588791,-5547296,1868257241,617104847,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178843,deploy,,,
|
||||
1038014721,-298365494,-1521588791,655872743,1868257241,1672174622,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178844,deploy,,,
|
||||
1038014721,1176323251,-1521588791,528759015,1868257241,127065992,1,1,1,2026-05-08 08:14:28.083737,ljh0920,178845,deploy,,,
|
||||
|
@@ -0,0 +1,2 @@
|
||||
objid,customer_objid,contract_objid,unit_code,status,writer,regdate,multi_yn,multi_master_yn,multi_break_yn,multi_master_objid,product_cd,part_no,part_name,revision,note,editer,edit_date,deploy_date
|
||||
1038014721,"","","",N,ljh0920,2026-05-07 09:02:04.887236,N,N,,,0001539,21008-0109,BS030-120H4A11-EN,"",,,,
|
||||
|
@@ -17,6 +17,7 @@ import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||
import { devBomApi, BomReportListFilter, BomReportRow } from "@/lib/api/devBom";
|
||||
import { BomReportStatusDialog } from "@/components/development/BomReportStatusDialog";
|
||||
import { DevPartSelect } from "@/components/development/DevPartSelect";
|
||||
import { BomReportExcelImportDialog } from "@/components/development/BomReportExcelImportDialog";
|
||||
import { BomReportTreeDialog } from "@/components/development/BomReportTreeDialog";
|
||||
|
||||
@@ -110,6 +111,9 @@ export default function EbomRegistPage() {
|
||||
[openTree],
|
||||
);
|
||||
|
||||
// DataGrid 는 row.id 를 키로 사용 — backend 응답은 row.objid (lowercase) 이므로 매핑
|
||||
const gridRows = useMemo(() => rows.map((r) => ({ ...r, id: r.objid })), [rows]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
@@ -132,19 +136,24 @@ export default function EbomRegistPage() {
|
||||
<option key={o.code} value={o.code}>{o.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
{/* wace structureList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */}
|
||||
<Field label="품번">
|
||||
<Input
|
||||
value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
placeholder="품번 LIKE"
|
||||
/>
|
||||
<DevPartSelect mode="partNo"
|
||||
value={filter.search_part_no ?? ""}
|
||||
onValueChange={(v, row) => setFilter((prev) => ({
|
||||
...prev,
|
||||
search_part_no: v,
|
||||
search_part_name: row?.part_name ?? prev.search_part_name,
|
||||
}))} />
|
||||
</Field>
|
||||
<Field label="품명">
|
||||
<Input
|
||||
value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
placeholder="품명 LIKE"
|
||||
/>
|
||||
<DevPartSelect mode="partName"
|
||||
value={filter.search_part_name ?? ""}
|
||||
onValueChange={(v, row) => setFilter((prev) => ({
|
||||
...prev,
|
||||
search_part_name: v,
|
||||
search_part_no: row?.part_no ?? prev.search_part_no,
|
||||
}))} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
@@ -177,7 +186,7 @@ export default function EbomRegistPage() {
|
||||
<div className="min-h-0 flex-1 p-2">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
data={gridRows}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
showCheckbox
|
||||
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
|
||||
import { devBomApi, BomTreeFilter, BomTreeRow } from "@/lib/api/devBom";
|
||||
import { DevPartSelect } from "@/components/development/DevPartSelect";
|
||||
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
|
||||
|
||||
type Direction = "ascending" | "descending";
|
||||
|
||||
const EMPTY_FILTER: BomTreeFilter = {
|
||||
project_name: "", unit_code: "",
|
||||
search_part_no: "", search_part_name: "",
|
||||
search_part_no: "", search_part_name: "", search_level: "",
|
||||
};
|
||||
|
||||
export default function EbomSearchPage() {
|
||||
@@ -29,6 +30,11 @@ export default function EbomSearchPage() {
|
||||
const [maxLevel, setMaxLevel] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
// 토글 접힘 상태: 접힌 부모 행의 child_objid 집합
|
||||
const [collapsedChildIds, setCollapsedChildIds] = useState<Set<string>>(new Set());
|
||||
// PART 상세 다이얼로그
|
||||
const [partDetailOpen, setPartDetailOpen] = useState(false);
|
||||
const [partDetailObjid, setPartDetailObjid] = useState<string | null>(null);
|
||||
|
||||
const runQuery = useCallback(async (dir: Direction) => {
|
||||
setLoading(true);
|
||||
@@ -38,6 +44,7 @@ export default function EbomSearchPage() {
|
||||
setRows(res.rows ?? []);
|
||||
setMaxLevel(Number(res.max_level) || 0);
|
||||
setDirection(dir);
|
||||
setCollapsedChildIds(new Set()); // 새 조회 시 모두 펼침
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
} finally {
|
||||
@@ -64,68 +71,150 @@ export default function EbomSearchPage() {
|
||||
}
|
||||
}, [filter]);
|
||||
|
||||
// 동적 LEVEL 컬럼: 각 레벨 컬럼은 row.lev === i 일 때만 pm_part_no 표시
|
||||
// 자식 보유 행 식별 (다른 행이 parent_objid 로 참조하는 child_objid 집합)
|
||||
const hasChildSet = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
for (const r of rows) if (r.parent_objid) s.add(String(r.parent_objid));
|
||||
return s;
|
||||
}, [rows]);
|
||||
|
||||
// 각 행의 ancestor child_objid 체인 (collapsed 검사용)
|
||||
const ancestorsByChildId = useMemo(() => {
|
||||
const byChild = new Map<string, BomTreeRow>();
|
||||
for (const r of rows) if (r.child_objid) byChild.set(String(r.child_objid), r);
|
||||
const result = new Map<string, string[]>();
|
||||
for (const r of rows) {
|
||||
if (!r.child_objid) continue;
|
||||
const list: string[] = [];
|
||||
let cur: string | null = r.parent_objid ? String(r.parent_objid) : null;
|
||||
const guard = new Set<string>();
|
||||
while (cur && !guard.has(cur)) {
|
||||
list.push(cur);
|
||||
guard.add(cur);
|
||||
const p = byChild.get(cur);
|
||||
cur = p?.parent_objid ? String(p.parent_objid) : null;
|
||||
}
|
||||
result.set(String(r.child_objid), list);
|
||||
}
|
||||
return result;
|
||||
}, [rows]);
|
||||
|
||||
// 토글 클릭 핸들러 — 부모 행의 child_objid 를 collapsed Set 에 toggle
|
||||
const toggleCollapse = useCallback((row: any) => {
|
||||
const childId = row.child_objid ? String(row.child_objid) : "";
|
||||
if (!childId || !hasChildSet.has(childId)) return;
|
||||
setCollapsedChildIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(childId)) next.delete(childId);
|
||||
else next.add(childId);
|
||||
return next;
|
||||
});
|
||||
}, [hasChildSet]);
|
||||
|
||||
// 운영판 structureAscendingList.jsp 1:1
|
||||
// - 첫 컬럼: -/+ 토글 (자식 있는 행만)
|
||||
// - L1..LMaxLevel 컬럼은 해당 레벨에만 "*" 표시 (품번 표시 X)
|
||||
// - 별도 품번 컬럼에 모든 행 part_no
|
||||
// - 3D/2D/PDF 폴더 아이콘 (renderType: "folder")
|
||||
const columns: DataGridColumn[] = useMemo(() => {
|
||||
const levelCols: DataGridColumn[] = [];
|
||||
for (let i = 1; i <= Math.max(1, maxLevel); i++) {
|
||||
levelCols.push({
|
||||
key: `__lev_${i}`,
|
||||
label: `L${i}`,
|
||||
width: "w-[140px]",
|
||||
label: String(i),
|
||||
width: "w-[36px]",
|
||||
align: "center",
|
||||
});
|
||||
}
|
||||
return [
|
||||
{ key: "__toggle", label: "", width: "w-[36px]", align: "center",
|
||||
onClick: (row: any) => toggleCollapse(row) },
|
||||
...levelCols,
|
||||
{ key: "pm_part_no", label: "품번", width: "w-[160px]", frozen: false },
|
||||
{ key: "pm_part_name", label: "품명", minWidth: "min-w-[200px]" },
|
||||
{ key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "right", formatNumber: true },
|
||||
{ key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "right", formatNumber: true },
|
||||
{ key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "right", formatNumber: true },
|
||||
{ key: "qty", label: "수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "edit_date", label: "변경일", width: "w-[120px]", align: "center" },
|
||||
{ key: "revision", label: "REV", width: "w-[60px]", align: "center" },
|
||||
{ key: "spec", label: "규격", width: "w-[120px]" },
|
||||
{ key: "material", label: "재질", width: "w-[100px]" },
|
||||
{ key: "weight", label: "중량", width: "w-[80px]", align: "right" },
|
||||
{ key: "remark", label: "비고", minWidth: "min-w-[140px]" },
|
||||
// 품번 셀 클릭 → PART 상세 (wace partMngDetailPopUp 1:1). row.part_no = part_mng.objid 임.
|
||||
{ key: "pm_part_no", label: "품번", width: "w-[160px]",
|
||||
onClick: (row: any) => {
|
||||
if (row.part_no) {
|
||||
setPartDetailObjid(String(row.part_no));
|
||||
setPartDetailOpen(true);
|
||||
}
|
||||
} },
|
||||
{ key: "pm_part_name", label: "품명", minWidth: "min-w-[200px]" },
|
||||
{ key: "qty", label: "수량", width: "w-[70px]", align: "right", formatNumber: true },
|
||||
{ key: "p_qty", label: "항목수량", width: "w-[80px]", align: "right", formatNumber: true },
|
||||
{ key: "cu01_cnt", label: "3D", width: "w-[60px]", align: "center", renderType: "folder" },
|
||||
{ key: "cu02_cnt", label: "2D", width: "w-[60px]", align: "center", renderType: "folder" },
|
||||
{ key: "cu03_cnt", label: "PDF", width: "w-[60px]", align: "center", renderType: "folder" },
|
||||
{ key: "material", label: "재료", width: "w-[100px]" },
|
||||
{ key: "heat_treatment_hardness", label: "열처리경도", width: "w-[110px]" },
|
||||
{ key: "heat_treatment_method", label: "열처리방법", width: "w-[110px]" },
|
||||
{ key: "surface_treatment", label: "표면처리", width: "w-[100px]" },
|
||||
{ key: "maker", label: "메이커", width: "w-[110px]" },
|
||||
{ key: "part_type_title", label: "범주 이름", width: "w-[100px]", align: "center" },
|
||||
{ key: "remark", label: "비고", minWidth: "min-w-[140px]" },
|
||||
];
|
||||
}, [maxLevel]);
|
||||
}, [maxLevel, toggleCollapse]);
|
||||
|
||||
// 행 데이터: __lev_{i} 가상 셀에 lev 일치 시에만 part_no 채움
|
||||
const gridData = useMemo(
|
||||
() => rows.map((r) => {
|
||||
const expanded: any = { ...r };
|
||||
for (let i = 1; i <= Math.max(1, maxLevel); i++) {
|
||||
expanded[`__lev_${i}`] = r.lev === i ? (r.pm_part_no ?? r.part_no ?? "") : "";
|
||||
}
|
||||
return expanded;
|
||||
}),
|
||||
[rows, maxLevel],
|
||||
);
|
||||
// 가시 행: collapsed 부모를 ancestor 로 가진 행은 hide
|
||||
// 행 데이터: __toggle 셀 + __lev_{i} "*" 표시
|
||||
const gridData = useMemo(() => {
|
||||
return rows
|
||||
.filter((r) => {
|
||||
if (!r.child_objid) return true;
|
||||
const ancestors = ancestorsByChildId.get(String(r.child_objid)) ?? [];
|
||||
return !ancestors.some((a) => collapsedChildIds.has(a));
|
||||
})
|
||||
.map((r) => {
|
||||
const expanded: any = { ...r };
|
||||
const lev = Number(r.lev ?? 0);
|
||||
for (let i = 1; i <= Math.max(1, maxLevel); i++) {
|
||||
expanded[`__lev_${i}`] = lev === i ? "*" : "";
|
||||
}
|
||||
const childId = r.child_objid ? String(r.child_objid) : "";
|
||||
const hasChild = childId && hasChildSet.has(childId);
|
||||
expanded.__toggle = hasChild ? (collapsedChildIds.has(childId) ? "+" : "−") : "";
|
||||
return expanded;
|
||||
});
|
||||
}, [rows, maxLevel, hasChildSet, ancestorsByChildId, collapsedChildIds]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
<div className="grid grid-cols-4 gap-3 text-sm">
|
||||
<Field label="프로젝트 OBJID">
|
||||
<Input value={filter.project_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, project_name: e.target.value })}
|
||||
placeholder="project_mgmt.objid" />
|
||||
</Field>
|
||||
<Field label="UNIT_CODE">
|
||||
<Input value={filter.unit_code ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, unit_code: e.target.value })}
|
||||
placeholder="pms_wbs_task.objid" />
|
||||
</Field>
|
||||
{/* 운영판 wace structureAscendingList.jsp 1:1 — 노출 검색 필드 3개
|
||||
(고객사/프로젝트번호/유닛명 은 운영판에서도 주석 처리되어 노출 안 됨) */}
|
||||
<div className="grid grid-cols-3 gap-3 text-sm">
|
||||
<Field label="품번">
|
||||
<Input value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
placeholder="part_no LIKE" />
|
||||
<DevPartSelect mode="partNo"
|
||||
value={filter.search_part_no ?? ""}
|
||||
onValueChange={(v, row) => setFilter((prev) => ({
|
||||
...prev,
|
||||
search_part_no: v,
|
||||
// 품번 선택 시 품명 자동 채움 (wace select2-part 1:1)
|
||||
search_part_name: row?.part_name ?? prev.search_part_name,
|
||||
}))} />
|
||||
</Field>
|
||||
<Field label="품명">
|
||||
<Input value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
placeholder="part_name LIKE" />
|
||||
<DevPartSelect mode="partName"
|
||||
value={filter.search_part_name ?? ""}
|
||||
onValueChange={(v, row) => setFilter((prev) => ({
|
||||
...prev,
|
||||
search_part_name: v,
|
||||
// 품명 선택 시 품번 자동 채움
|
||||
search_part_no: row?.part_no ?? prev.search_part_no,
|
||||
}))} />
|
||||
</Field>
|
||||
<Field label="표시 레벨">
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={String(filter.search_level ?? "")}
|
||||
onChange={(e) => setFilter({ ...filter, search_level: e.target.value })}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="1">1레벨</option>
|
||||
<option value="2">2레벨</option>
|
||||
<option value="3">3레벨</option>
|
||||
<option value="4">4레벨</option>
|
||||
<option value="5">5레벨</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
@@ -163,7 +252,7 @@ export default function EbomSearchPage() {
|
||||
</div>
|
||||
{direction === "descending" && (
|
||||
<div className="mt-2 text-xs text-amber-600">
|
||||
역전개는 PART 검색(품번/품명) 또는 BOM/프로젝트 한정 조건이 필요합니다.
|
||||
역전개는 품번 또는 품명 검색 조건이 필요합니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -178,6 +267,12 @@ export default function EbomSearchPage() {
|
||||
gridId={`development-ebom-search-${direction}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PartDetailDialog
|
||||
open={partDetailOpen}
|
||||
onOpenChange={setPartDetailOpen}
|
||||
objid={partDetailObjid}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { devPartApi, PartListFilter, PartRow } from "@/lib/api/devPart";
|
||||
import { PartFormDialog } from "@/components/development/PartFormDialog";
|
||||
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
|
||||
import { PartExcelImportDialog } from "@/components/development/PartExcelImportDialog";
|
||||
import { PartDrawingMultiUploadButton } from "@/components/development/PartDrawingMultiUploadButton";
|
||||
import { DevPartSelect } from "@/components/development/DevPartSelect";
|
||||
|
||||
// wace 23셀 + 부속 (PARENT_PART_INFO/PARTNER_TITLE/Q_QTY)
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
@@ -100,6 +102,9 @@ export default function PartRegistPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
// DataGrid 는 row.id 를 키로 사용 — backend 응답은 row.objid 이므로 매핑
|
||||
const gridRows = useMemo(() => rows.map((r) => ({ ...r, id: r.objid })), [rows]);
|
||||
|
||||
// 등록
|
||||
const handleCreate = () => {
|
||||
setFormMode("create");
|
||||
@@ -154,21 +159,26 @@ export default function PartRegistPage() {
|
||||
{/* 검색폼 — wace partMngTempList.jsp 활성 2필드 */}
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="min-w-[200px]">
|
||||
{/* wace partMngTempList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */}
|
||||
<div className="min-w-[220px]">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품번</Label>
|
||||
<Input
|
||||
value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
placeholder="품번 LIKE"
|
||||
/>
|
||||
<DevPartSelect mode="partNo"
|
||||
value={filter.search_part_no ?? ""}
|
||||
onValueChange={(v, row) => setFilter((prev) => ({
|
||||
...prev,
|
||||
search_part_no: v,
|
||||
search_part_name: row?.part_name ?? prev.search_part_name,
|
||||
}))} />
|
||||
</div>
|
||||
<div className="min-w-[200px]">
|
||||
<div className="min-w-[220px]">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품명</Label>
|
||||
<Input
|
||||
value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
placeholder="품명 LIKE"
|
||||
/>
|
||||
<DevPartSelect mode="partName"
|
||||
value={filter.search_part_name ?? ""}
|
||||
onValueChange={(v, row) => setFilter((prev) => ({
|
||||
...prev,
|
||||
search_part_name: v,
|
||||
search_part_no: row?.part_no ?? prev.search_part_no,
|
||||
}))} />
|
||||
</div>
|
||||
<div className="ml-auto flex items-end gap-2">
|
||||
<Button variant="outline" size="sm"
|
||||
@@ -193,6 +203,10 @@ export default function PartRegistPage() {
|
||||
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
|
||||
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">Excel Upload</span>
|
||||
</Button>
|
||||
<PartDrawingMultiUploadButton
|
||||
partNoList={rows.map((r) => r.part_no).filter(Boolean) as string[]}
|
||||
onUploaded={() => fetchList()}
|
||||
/>
|
||||
<Button size="sm" onClick={handleDeploy}
|
||||
disabled={checkedIds.length === 0}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
@@ -208,7 +222,7 @@ export default function PartRegistPage() {
|
||||
<div className="min-h-0 flex-1 p-2">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
data={gridRows}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
showCheckbox
|
||||
|
||||
@@ -18,6 +18,8 @@ import { devPartApi, PartListFilter, PartRow } from "@/lib/api/devPart";
|
||||
import { PartFormDialog } from "@/components/development/PartFormDialog";
|
||||
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
|
||||
import { PartExcelImportDialog } from "@/components/development/PartExcelImportDialog";
|
||||
import { PartDrawingMultiUploadButton } from "@/components/development/PartDrawingMultiUploadButton";
|
||||
import { DevPartSelect } from "@/components/development/DevPartSelect";
|
||||
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "part_no", label: "품번", width: "w-[140px]", frozen: true },
|
||||
@@ -95,6 +97,9 @@ export default function PartSearchPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
// DataGrid 는 row.id 를 키로 사용 — backend 응답은 row.objid 이므로 매핑
|
||||
const gridRows = useMemo(() => rows.map((r) => ({ ...r, id: r.objid })), [rows]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormMode("create"); setFormObjid(null); setFormOpen(true);
|
||||
};
|
||||
@@ -122,21 +127,26 @@ export default function PartSearchPage() {
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-card px-4 py-3">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="min-w-[200px]">
|
||||
{/* wace partMngList.jsp 1:1 — select2-part 자동완성 (양방향 동기) */}
|
||||
<div className="min-w-[220px]">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품번</Label>
|
||||
<Input
|
||||
value={filter.search_part_no ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_no: e.target.value })}
|
||||
placeholder="품번 LIKE"
|
||||
/>
|
||||
<DevPartSelect mode="partNo"
|
||||
value={filter.search_part_no ?? ""}
|
||||
onValueChange={(v, row) => setFilter((prev) => ({
|
||||
...prev,
|
||||
search_part_no: v,
|
||||
search_part_name: row?.part_name ?? prev.search_part_name,
|
||||
}))} />
|
||||
</div>
|
||||
<div className="min-w-[200px]">
|
||||
<div className="min-w-[220px]">
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">품명</Label>
|
||||
<Input
|
||||
value={filter.search_part_name ?? ""}
|
||||
onChange={(e) => setFilter({ ...filter, search_part_name: e.target.value })}
|
||||
placeholder="품명 LIKE"
|
||||
/>
|
||||
<DevPartSelect mode="partName"
|
||||
value={filter.search_part_name ?? ""}
|
||||
onValueChange={(v, row) => setFilter((prev) => ({
|
||||
...prev,
|
||||
search_part_name: v,
|
||||
search_part_no: row?.part_no ?? prev.search_part_no,
|
||||
}))} />
|
||||
</div>
|
||||
<div className="ml-auto flex items-end gap-2">
|
||||
<Button variant="outline" size="sm"
|
||||
@@ -161,6 +171,8 @@ export default function PartSearchPage() {
|
||||
<Button size="sm" variant="outline" onClick={() => setExcelOpen(true)}>
|
||||
<FileSpreadsheet className="h-4 w-4" /><span className="ml-1">Excel Upload</span>
|
||||
</Button>
|
||||
{/* M2 조회 — partNoList 미전달: IS_LAST='1' 전체 part_mng 매칭 (페이지 밖도 허용) */}
|
||||
<PartDrawingMultiUploadButton onUploaded={() => fetchList()} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
@@ -171,7 +183,7 @@ export default function PartSearchPage() {
|
||||
<div className="min-h-0 flex-1 p-2">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
data={gridRows}
|
||||
loading={loading}
|
||||
showRowNumber
|
||||
showCheckbox
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
// attach_file_info 기반 다중 파일 업로드 드롭존 — 도메인 무관 공통 컴포넌트.
|
||||
//
|
||||
// 운영판 wace `fnc_setFileDropZone` (common.js) + `fn_fileCallback2` +
|
||||
// `fileDelete` 흐름 1:1.
|
||||
//
|
||||
// 백엔드:
|
||||
// - GET /api/files?targetObjid=&docType= (wace getFileList.do)
|
||||
// - POST /api/files/upload (wace fileUploadProc.do)
|
||||
// - DELETE /api/files/:objid (wace deleteFileInfo.do)
|
||||
// - GET /api/files/download/:objid (wace fnc_downloadFile)
|
||||
//
|
||||
// 사용처 예시:
|
||||
// - 개발관리 PART CAD Data (3D_CAD / 2D_DRAWING_CAD / 2D_PDF_CAD)
|
||||
// - 향후 ERP/ECR/생산실적 등 attach_file_info 쓰는 어떤 화면이든 그대로 재사용.
|
||||
//
|
||||
// 호출자가 doc_type / doc_type_name 만 지정하면 도메인 독립.
|
||||
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
DragEvent,
|
||||
ChangeEvent,
|
||||
} from "react";
|
||||
import { Upload, Download, Trash2, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient, API_BASE_URL } from "@/lib/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AttachFile {
|
||||
objid: string;
|
||||
realFileName: string;
|
||||
savedFileName: string;
|
||||
fileSize: number;
|
||||
fileExt: string;
|
||||
filePath: string;
|
||||
docType: string;
|
||||
docTypeName: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
targetObjid: string | null | undefined;
|
||||
docType: string;
|
||||
docTypeName: string;
|
||||
/** 읽기 전용 — 드롭존/삭제 숨김, 다운로드만 허용 */
|
||||
readOnly?: boolean;
|
||||
/** 파일 선택창 accept 힌트 (예: ".pdf,application/pdf"). 비우면 모든 파일 */
|
||||
accept?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AttachFileDropZone({
|
||||
targetObjid,
|
||||
docType,
|
||||
docTypeName,
|
||||
readOnly = false,
|
||||
accept,
|
||||
className,
|
||||
}: Props) {
|
||||
const [files, setFiles] = useState<AttachFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// ── 목록 조회 (wace fn_fileCallback2) ───────────────────────
|
||||
const reload = useCallback(async () => {
|
||||
if (!targetObjid) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get("/files", {
|
||||
params: { targetObjid, docType },
|
||||
});
|
||||
// 응답: { success, files: [...] } — fileController.getFileList
|
||||
const raw = (res.data?.files ?? res.data?.data ?? res.data ?? []) as any[];
|
||||
const list: AttachFile[] = raw.map((f) => ({
|
||||
objid: String(f.objid),
|
||||
realFileName: f.realFileName ?? f.real_file_name ?? "",
|
||||
savedFileName: f.savedFileName ?? f.saved_file_name ?? "",
|
||||
fileSize: Number(f.fileSize ?? f.file_size ?? 0),
|
||||
fileExt: f.fileExt ?? f.file_ext ?? "",
|
||||
filePath: f.filePath ?? f.file_path ?? "",
|
||||
docType: f.docType ?? f.doc_type ?? "",
|
||||
docTypeName: f.docTypeName ?? f.doc_type_name ?? "",
|
||||
}));
|
||||
setFiles(list);
|
||||
} catch (e: any) {
|
||||
console.error("[AttachFileDropZone] reload 실패", e);
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [targetObjid, docType]);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
// ── 업로드 (wace fnc_fileMultiUpload) ───────────────────────
|
||||
const uploadFiles = useCallback(
|
||||
async (selected: FileList | File[]) => {
|
||||
if (!targetObjid) {
|
||||
toast.error("저장 대상이 지정되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
const arr = Array.from(selected);
|
||||
if (arr.length === 0) return;
|
||||
|
||||
if (!window.confirm(`${arr.length}개 파일을 업로드 하시겠습니까?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
for (const f of arr) fd.append("files", f);
|
||||
fd.append("targetObjid", String(targetObjid));
|
||||
fd.append("docType", docType);
|
||||
fd.append("docTypeName", docTypeName);
|
||||
await apiClient.post("/files/upload", fd, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
toast.success(`${arr.length}개 파일이 업로드되었습니다.`);
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
toast.error(
|
||||
e?.response?.data?.message ?? e?.message ?? "업로드 실패"
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[targetObjid, docType, docTypeName, reload]
|
||||
);
|
||||
|
||||
// ── 삭제 (wace fileDelete) ──────────────────────────────────
|
||||
const removeFile = useCallback(
|
||||
async (objid: string) => {
|
||||
if (!window.confirm("파일을 삭제하시겠습니까?")) return;
|
||||
try {
|
||||
await apiClient.delete(`/files/${objid}`);
|
||||
await reload();
|
||||
} catch (e: any) {
|
||||
toast.error(
|
||||
e?.response?.data?.message ?? e?.message ?? "삭제 실패"
|
||||
);
|
||||
}
|
||||
},
|
||||
[reload]
|
||||
);
|
||||
|
||||
// ── 다운로드 ────────────────────────────────────────────────
|
||||
const downloadHref = (objid: string) =>
|
||||
`${API_BASE_URL}/files/download/${objid}`;
|
||||
|
||||
// ── DnD 이벤트 ──────────────────────────────────────────────
|
||||
// macOS Chrome 함정: dropEffect 미지정 시 🚫 거부 커서 + drop 차단.
|
||||
const onDragEnter = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (readOnly) return;
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
||||
setDragOver(true);
|
||||
};
|
||||
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (readOnly) return;
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
||||
setDragOver(true);
|
||||
};
|
||||
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
};
|
||||
const onDrop = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOver(false);
|
||||
if (readOnly) return;
|
||||
const dropped = e.dataTransfer?.files;
|
||||
if (dropped && dropped.length > 0) uploadFiles(dropped);
|
||||
};
|
||||
|
||||
const onPickClick = () => {
|
||||
if (readOnly) return;
|
||||
inputRef.current?.click();
|
||||
};
|
||||
const onPickChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = e.target.files;
|
||||
if (picked && picked.length > 0) uploadFiles(picked);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
};
|
||||
|
||||
// ── 렌더 ────────────────────────────────────────────────────
|
||||
const showDropZone = !readOnly;
|
||||
const showEmptyHint = files.length === 0 && readOnly;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{showDropZone && (
|
||||
<div
|
||||
onDragEnter={onDragEnter}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={onPickClick}
|
||||
className={cn(
|
||||
"flex h-[60px] cursor-pointer items-center justify-center gap-2 rounded border-2 border-dashed text-sm transition-colors",
|
||||
dragOver
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-muted-foreground/30 bg-muted/20 text-muted-foreground hover:border-blue-400 hover:text-blue-600",
|
||||
(!targetObjid || uploading) && "pointer-events-none opacity-60"
|
||||
)}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>업로드 중...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4" />
|
||||
<span>
|
||||
{targetObjid
|
||||
? "여기에 파일을 끌어다 놓거나 클릭하여 선택하세요."
|
||||
: "저장 대상 미지정 — 등록 후 업로드 가능"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={onPickChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 px-1 py-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> 목록 로드 중...
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<ul className="divide-y rounded border bg-background">
|
||||
{files.map((f) => (
|
||||
<li
|
||||
key={f.objid}
|
||||
className="flex items-center justify-between gap-2 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<a
|
||||
href={downloadHref(f.objid)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={f.realFileName}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-blue-700 hover:underline"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{f.realFileName}</span>
|
||||
</a>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatBytes(f.fileSize)}
|
||||
</span>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(f.objid)}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-red-50 hover:text-red-600"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
showEmptyHint && (
|
||||
<div className="px-1 py-1 text-xs text-muted-foreground">
|
||||
등록된 파일이 없습니다.
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let v = bytes;
|
||||
let u = 0;
|
||||
while (v >= 1024 && u < units.length - 1) {
|
||||
v /= 1024;
|
||||
u++;
|
||||
}
|
||||
return `${v.toFixed(u === 0 ? 0 : 1)} ${units[u]}`;
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > E-BOM 상태 변경 다이얼로그.
|
||||
// wace structureStatusChangePopup 1:1 — STATUS select(create/changeDesign/deploy) + 부속 4필드.
|
||||
// 개발관리 > E-BOM 상태 변경 다이얼로그 — wace structureStatusChangePopup.jsp 1:1
|
||||
//
|
||||
// 운영 매퍼 updateStructureStatus 5필드 UPDATE :
|
||||
// PRODUCT_CD / PART_NO / PART_NAME / REVISION / STATUS (모두 편집 가능)
|
||||
// 상태는 Y / N 라디오 (운영판 그대로).
|
||||
//
|
||||
// 이전 구현은 read-only 요약 박스 + 상태 select(create/changeDesign/deploy) 였으나
|
||||
// 운영판과 완전히 달라 정정.
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -12,13 +18,10 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||
import { devBomApi, BomReportRow } from "@/lib/api/devBom";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ code: "create", label: "등록중" },
|
||||
{ code: "changeDesign", label: "설계변경미배포" },
|
||||
{ code: "deploy", label: "배포완료" },
|
||||
];
|
||||
const PRODUCT_GROUP = "0000001"; // 제품구분 comm_code
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -28,9 +31,11 @@ interface Props {
|
||||
}
|
||||
|
||||
export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Props) {
|
||||
const [row, setRow] = useState<BomReportRow | null>(null);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [productCd, setProductCd] = useState<string>("");
|
||||
const [partNo, setPartNo] = useState<string>("");
|
||||
const [partName, setPartName] = useState<string>("");
|
||||
const [version, setVersion] = useState<string>("");
|
||||
const [status, setStatus] = useState<string>(""); // 운영판 Y / N
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -39,16 +44,18 @@ export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Pr
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
devBomApi.detail(objid)
|
||||
.then((data) => {
|
||||
.then((data: BomReportRow | null) => {
|
||||
if (!alive) return;
|
||||
if (!data) {
|
||||
toast.error("E-BOM 보고서를 찾을 수 없습니다.");
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
setRow(data);
|
||||
setStatus(data.status ?? "");
|
||||
setProductCd(data.product_cd ?? "");
|
||||
setPartNo(data.part_no ?? "");
|
||||
setPartName(data.part_name ?? "");
|
||||
setVersion(data.revision ?? "");
|
||||
setStatus(data.status ?? "");
|
||||
})
|
||||
.catch((e: any) => {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
@@ -58,14 +65,26 @@ export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Pr
|
||||
return () => { alive = false; };
|
||||
}, [open, objid, onOpenChange]);
|
||||
|
||||
// wace fn_check 1:1 — 제품구분 / 품번 필수, 상태 필수
|
||||
const validate = (): string | null => {
|
||||
if (!productCd) return "제품구분은 필수입니다.";
|
||||
if (!partNo.trim()) return "품번은 필수입니다.";
|
||||
if (!status) return "상태를 선택하세요.";
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!objid) return;
|
||||
if (!status) return toast.error("상태를 선택하세요.");
|
||||
const err = validate();
|
||||
if (err) return toast.error(err);
|
||||
setSaving(true);
|
||||
try {
|
||||
await devBomApi.updateStatus(objid, {
|
||||
status,
|
||||
product_cd: productCd,
|
||||
part_no: partNo,
|
||||
part_name: partName,
|
||||
version: version || undefined,
|
||||
status,
|
||||
});
|
||||
toast.success("상태가 변경되었습니다.");
|
||||
onSaved();
|
||||
@@ -79,52 +98,92 @@ export function BomReportStatusDialog({ open, onOpenChange, objid, onSaved }: Pr
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>E-BOM 상태 변경</DialogTitle>
|
||||
<DialogContent className="max-w-md p-0 overflow-hidden">
|
||||
<DialogHeader className="bg-blue-600 px-4 py-3">
|
||||
<DialogTitle className="text-white">E-BOM 상태 변경</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading || !row ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
{loading ? (
|
||||
<div className="flex h-48 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-sm space-y-1">
|
||||
<div><span className="text-muted-foreground">제품구분:</span> {row.product_name ?? row.product_cd ?? "—"}</div>
|
||||
<div><span className="text-muted-foreground">품번:</span> {row.part_no ?? "—"}</div>
|
||||
<div><span className="text-muted-foreground">품명:</span> {row.part_name ?? "—"}</div>
|
||||
<div><span className="text-muted-foreground">현재상태:</span> {row.status_title ?? row.status ?? "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">변경 상태 *</Label>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="">선택</option>
|
||||
{STATUS_OPTIONS.map((o) =>
|
||||
<option key={o.code} value={o.code}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-1 block text-xs text-muted-foreground">Version</Label>
|
||||
<Input value={version} onChange={(e) => setVersion(e.target.value)} placeholder="예: RE, A, B..." />
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
{/* 운영판 colgroup 1:1 (25% / 75%) */}
|
||||
<table className="w-full border-collapse text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col style={{ width: "25%" }} />
|
||||
<col />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<Th>제품구분<Req /></Th>
|
||||
<Td>
|
||||
<CommCodeSelect groupId={PRODUCT_GROUP} withAll={false}
|
||||
value={productCd}
|
||||
onValueChange={setProductCd} />
|
||||
</Td>
|
||||
</tr>
|
||||
<tr>
|
||||
<Th>품번<Req /></Th>
|
||||
<Td><Input value={partNo} onChange={(e) => setPartNo(e.target.value)} /></Td>
|
||||
</tr>
|
||||
<tr>
|
||||
<Th>품명</Th>
|
||||
<Td><Input value={partName} onChange={(e) => setPartName(e.target.value)} /></Td>
|
||||
</tr>
|
||||
<tr>
|
||||
<Th>Version</Th>
|
||||
<Td><Input value={version} onChange={(e) => setVersion(e.target.value)} /></Td>
|
||||
</tr>
|
||||
<tr>
|
||||
<Th>상태</Th>
|
||||
<Td>
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" name="status" value="Y"
|
||||
checked={status === "Y"}
|
||||
onChange={() => setStatus("Y")} />
|
||||
<span>Y</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="radio" name="status" value="N"
|
||||
checked={status === "N"}
|
||||
onChange={() => setStatus("N")} />
|
||||
<span>N</span>
|
||||
</label>
|
||||
</div>
|
||||
</Td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
취소
|
||||
</Button>
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-center">
|
||||
<Button onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
<span className="ml-1">저장</span>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left align-middle font-medium">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
function Td({ children }: { children: React.ReactNode }) {
|
||||
return <td className="border px-3 py-1.5">{children}</td>;
|
||||
}
|
||||
function Req() {
|
||||
return <span className="ml-1 text-red-500">*</span>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 PART 자동완성 셀렉트 — wace select2-part 1:1 (영업관리 PartSelect 패턴 동일).
|
||||
//
|
||||
// 영업관리 PartSelect 는 item_info(영업 마스터) 기반.
|
||||
// 개발관리는 part_mng 기반이므로 별도 컴포넌트.
|
||||
//
|
||||
// - part_mng IS_LAST='1' 전체를 한 번 캐시 (objid 기준 단일 소스)
|
||||
// - mode='partNo' : 라벨로 part_no 표시
|
||||
// - mode='partName': 라벨로 part_name 표시
|
||||
// - 선택 시 onValueChange(part_no/part_name 텍스트, 원본 row) — ebom-search 가 LIKE 가 아닌
|
||||
// 완전일치 검색을 하므로 value 는 텍스트 그대로 전달
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface DevPartRow {
|
||||
objid: string;
|
||||
part_no: string;
|
||||
part_name: string;
|
||||
}
|
||||
|
||||
interface DevPartSelectProps {
|
||||
mode: "partNo" | "partName";
|
||||
/** 현재 선택값 — part_no 또는 part_name 텍스트 */
|
||||
value: string;
|
||||
/** 선택 시 텍스트 + 원본 row */
|
||||
onValueChange: (value: string, row?: DevPartRow) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let cachedRows: DevPartRow[] | null = null;
|
||||
let inflight: Promise<DevPartRow[]> | null = null;
|
||||
|
||||
const fetchParts = async (): Promise<DevPartRow[]> => {
|
||||
if (cachedRows) return cachedRows;
|
||||
if (inflight) return inflight;
|
||||
inflight = (async () => {
|
||||
const res = await apiClient.get("/development/part/options");
|
||||
const rows = (res.data?.data?.rows ?? []) as any[];
|
||||
cachedRows = rows
|
||||
.filter((r) => r.objid != null)
|
||||
.map((r) => ({
|
||||
objid: String(r.objid),
|
||||
part_no: r.part_no ?? "",
|
||||
part_name: r.part_name ?? "",
|
||||
}));
|
||||
return cachedRows!;
|
||||
})();
|
||||
try {
|
||||
return await inflight;
|
||||
} finally {
|
||||
inflight = null;
|
||||
}
|
||||
};
|
||||
|
||||
const toOptions = (rows: DevPartRow[], mode: DevPartSelectProps["mode"]): SmartSelectOption[] => {
|
||||
const seen = new Set<string>();
|
||||
const result: SmartSelectOption[] = [];
|
||||
for (const r of rows) {
|
||||
const label = mode === "partNo" ? r.part_no : r.part_name;
|
||||
if (!label || seen.has(label)) continue;
|
||||
seen.add(label);
|
||||
result.push({ code: label, label });
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export function DevPartSelect({
|
||||
mode, value, onValueChange,
|
||||
placeholder = mode === "partNo" ? "품번 입력하여 검색..." : "품명 입력하여 검색...",
|
||||
disabled, className,
|
||||
}: DevPartSelectProps) {
|
||||
const [options, setOptions] = useState<SmartSelectOption[]>(
|
||||
cachedRows ? toOptions(cachedRows, mode) : [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchParts()
|
||||
.then((rows) => { if (alive) setOptions(toOptions(rows, mode)); })
|
||||
.catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<SmartSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={(v) => {
|
||||
const row = cachedRows?.find((r) => (mode === "partNo" ? r.part_no : r.part_name) === v);
|
||||
onValueChange(v, row);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
// 운영판은 form 과 동일 화면을 disabled 로 표시 후 "수정" 클릭 시 활성화.
|
||||
// RPS 에서는 PartFormDialog 와 분리 유지 (호환). 본 다이얼로그는 Form 레이아웃 readonly +
|
||||
// 부속 정보 행 추가 (EO_NO / EO_DATE / EO구분(CHANGE_TYPE) / EO사유(CHANGE_OPTION)) +
|
||||
// CAD Data 영역 (3D / 2D(Drawing) / 2D(PDF)) 표시.
|
||||
// CAD Data 영역 (3D / 2D(Drawing) / 2D(PDF)) — AttachFileDropZone readonly (목록·다운로드).
|
||||
//
|
||||
// "수정" 버튼: 부모가 본 다이얼로그를 닫고 PartFormDialog(mode='edit') 오픈하도록 onEdit 콜백 호출.
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Pencil, FileText } from "lucide-react";
|
||||
import { Loader2, Pencil } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { devPartApi, PartRow } from "@/lib/api/devPart";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AttachFileDropZone } from "@/components/common/AttachFileDropZone";
|
||||
|
||||
const LABEL_ODRFG: Record<string, string> = { "0": "구매", "1": "생산", "8": "Phantom" };
|
||||
const LABEL_LOT_FG: Record<string, string> = { "0": "미사용", "1": "사용" };
|
||||
@@ -134,34 +135,45 @@ export function PartDetailDialog({ open, onOpenChange, objid, onEdit }: Props) {
|
||||
<Th>EO사유</Th><Td><Ro>{row.change_option_name ?? row.change_option}</Ro></Td>
|
||||
</Tr>
|
||||
|
||||
{/* CAD Data */}
|
||||
{/* CAD Data — readonly (목록·다운로드만) */}
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left align-top font-medium" rowSpan={3}>
|
||||
CAD Data
|
||||
</th>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">3D</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<CadCount label="3D" count={Number(row.cu01_cnt ?? 0)} />
|
||||
<AttachFileDropZone
|
||||
targetObjid={row.objid}
|
||||
docType="3D_CAD"
|
||||
docTypeName="3D CAD 첨부파일"
|
||||
readOnly
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(Drawing)</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<CadCount label="2D(Drawing)" count={Number(row.cu02_cnt ?? 0)} />
|
||||
<AttachFileDropZone
|
||||
targetObjid={row.objid}
|
||||
docType="2D_DRAWING_CAD"
|
||||
docTypeName="2D(Drawing) CAD 첨부파일"
|
||||
readOnly
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(PDF)</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<CadCount label="2D(PDF)" count={Number(row.cu03_cnt ?? 0)} />
|
||||
<AttachFileDropZone
|
||||
targetObjid={row.objid}
|
||||
docType="2D_PDF_CAD"
|
||||
docTypeName="2D(PDF) CAD 첨부파일"
|
||||
readOnly
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||
CAD Data 첨부 다운로드/미리보기는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -208,18 +220,3 @@ function Ro({ children, align }: { children: React.ReactNode; align?: "left" | "
|
||||
);
|
||||
}
|
||||
|
||||
function CadCount({ label, count }: { label: string; count: number }) {
|
||||
if (count > 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
<span>{label} 첨부 {count.toLocaleString()}건</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="border-2 border-dashed border-muted-foreground/30 rounded text-muted-foreground py-3 text-center text-xs">
|
||||
첨부된 {label} 파일 없음
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
// 개발관리 > PART 도면 다중 업로드 버튼 — wace partMngTempList.jsp btnDrawingUpload 1:1.
|
||||
//
|
||||
// 동작:
|
||||
// 1) 버튼 클릭 → 숨김 <input type="file" multiple accept=".stp,.step,.dwg,.dxf,.pdf"> 클릭
|
||||
// 2) onChange → 확장자 분류 (3D/2D/PDF) + 0개 거부 + 분류표 confirm
|
||||
// 3) 확인 → POST /api/development/part/drawing-multi-upload
|
||||
// 4) 응답 받아 결과 다이얼로그 표시 + 그리드 새로고침
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { FileImage, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
devPartApi,
|
||||
DrawingMultiUploadResult,
|
||||
DrawingMultiUploadDetail,
|
||||
} from "@/lib/api/devPart";
|
||||
|
||||
interface Props {
|
||||
/** 매칭 후보 PART_NO 범위.
|
||||
* 배열 지정 → 그 목록만 매칭 후보 (M1 등록 화면 — 현재 그리드 기반).
|
||||
* null/undefined/빈 배열 → IS_LAST='1' 전체 part_mng 매칭 (M2 조회 화면 — 페이지 밖도 허용).
|
||||
* (wace partMng.xml `<if PART_NO_LIST != null>` 분기 1:1) */
|
||||
partNoList?: string[] | null;
|
||||
onUploaded?: () => void; // 업로드 완료 후 그리드 새로고침
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ACCEPT = ".stp,.step,.dwg,.dxf,.pdf";
|
||||
|
||||
export function PartDrawingMultiUploadButton({ partNoList, onUploaded, className }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [result, setResult] = useState<DrawingMultiUploadResult | null>(null);
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
|
||||
const onClick = () => {
|
||||
if (uploading) return;
|
||||
inputRef.current?.click();
|
||||
};
|
||||
|
||||
const onChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const picked = e.target.files;
|
||||
if (!picked || picked.length === 0) return;
|
||||
|
||||
// wace fn_uploadDrawingFiles 1:1 — 확장자별 분류
|
||||
const filesByType: Record<"3D" | "2D" | "PDF", File[]> = {
|
||||
"3D": [],
|
||||
"2D": [],
|
||||
PDF: [],
|
||||
};
|
||||
for (let i = 0; i < picked.length; i++) {
|
||||
const f = picked[i];
|
||||
const dot = f.name.lastIndexOf(".");
|
||||
if (dot < 0) continue;
|
||||
const ext = f.name.substring(dot + 1).toLowerCase();
|
||||
if (ext === "stp" || ext === "step") filesByType["3D"].push(f);
|
||||
else if (ext === "dwg" || ext === "dxf") filesByType["2D"].push(f);
|
||||
else if (ext === "pdf") filesByType.PDF.push(f);
|
||||
}
|
||||
const valid = [...filesByType["3D"], ...filesByType["2D"], ...filesByType.PDF];
|
||||
|
||||
// input 초기화 (같은 파일 재선택 가능)
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
|
||||
if (valid.length === 0) {
|
||||
window.alert("업로드 가능한 파일 형식이 없습니다. (stp, dwg, dxf, pdf만 가능)");
|
||||
return;
|
||||
}
|
||||
|
||||
const msg =
|
||||
`총 ${valid.length}개의 파일을 업로드하시겠습니까?\n` +
|
||||
`- 3D (STP): ${filesByType["3D"].length}개\n` +
|
||||
`- 2D (DWG/DXF): ${filesByType["2D"].length}개\n` +
|
||||
`- PDF: ${filesByType.PDF.length}개`;
|
||||
if (!window.confirm(msg)) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const res = await devPartApi.drawingMultiUpload(valid, partNoList);
|
||||
setResult(res);
|
||||
setResultOpen(true);
|
||||
if (onUploaded) onUploaded();
|
||||
} catch (err: any) {
|
||||
toast.error(
|
||||
err?.response?.data?.message ?? err?.message ?? "도면 업로드 실패"
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onClick}
|
||||
disabled={uploading}
|
||||
className={className}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileImage className="h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-1">도면 다중 업로드</span>
|
||||
</Button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={ACCEPT}
|
||||
className="hidden"
|
||||
onChange={onChange}
|
||||
/>
|
||||
<DrawingResultDialog
|
||||
open={resultOpen}
|
||||
onOpenChange={setResultOpen}
|
||||
result={result}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 결과 다이얼로그 ────────────────────────────────────────
|
||||
|
||||
function DrawingResultDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
result,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
result: DrawingMultiUploadResult | null;
|
||||
}) {
|
||||
if (!result) return null;
|
||||
const total =
|
||||
result.successCount + result.failCount + result.notFoundCount;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[720px] w-[95vw] max-h-[80vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="bg-blue-600 px-4 py-3">
|
||||
<DialogTitle className="text-white">도면 다중 업로드 결과</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
<div className="grid grid-cols-4 gap-2 text-sm">
|
||||
<Stat label="총합" value={total} />
|
||||
<Stat label="성공" value={result.successCount} tone="success" />
|
||||
<Stat label="품번 미존재" value={result.notFoundCount} tone="warn" />
|
||||
<Stat label="실패" value={result.failCount} tone="error" />
|
||||
</div>
|
||||
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-muted/40">
|
||||
<th className="border px-2 py-1 text-left">파일명</th>
|
||||
<th className="border px-2 py-1 text-left">매칭 품번</th>
|
||||
<th className="border px-2 py-1 text-left">문서구분</th>
|
||||
<th className="border px-2 py-1 text-left">상태</th>
|
||||
<th className="border px-2 py-1 text-left">사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.details.map((d, i) => (
|
||||
<tr key={i}>
|
||||
<td className="border px-2 py-1 break-all">{d.fileName}</td>
|
||||
<td className="border px-2 py-1">{d.partNo ?? "—"}</td>
|
||||
<td className="border px-2 py-1">{d.docType ?? "—"}</td>
|
||||
<td className="border px-2 py-1">
|
||||
<StatusBadge status={d.status} />
|
||||
</td>
|
||||
<td className="border px-2 py-1 break-all text-muted-foreground">
|
||||
{d.reason ?? ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-t bg-muted/20 px-4 py-3 sm:justify-end">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone?: "success" | "warn" | "error";
|
||||
}) {
|
||||
const cls =
|
||||
tone === "success"
|
||||
? "text-emerald-700 bg-emerald-50"
|
||||
: tone === "warn"
|
||||
? "text-amber-700 bg-amber-50"
|
||||
: tone === "error"
|
||||
? "text-red-700 bg-red-50"
|
||||
: "text-foreground bg-muted/40";
|
||||
return (
|
||||
<div className={`rounded border px-2 py-2 ${cls}`}>
|
||||
<div className="text-[11px] text-muted-foreground">{label}</div>
|
||||
<div className="text-base font-semibold">{value.toLocaleString()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: DrawingMultiUploadDetail["status"] }) {
|
||||
const map: Record<
|
||||
DrawingMultiUploadDetail["status"],
|
||||
{ label: string; cls: string }
|
||||
> = {
|
||||
success: { label: "성공", cls: "bg-emerald-100 text-emerald-700" },
|
||||
notFound: { label: "품번 미존재", cls: "bg-amber-100 text-amber-700" },
|
||||
unsupported: { label: "확장자 미지원", cls: "bg-amber-100 text-amber-700" },
|
||||
fail: { label: "실패", cls: "bg-red-100 text-red-700" },
|
||||
};
|
||||
const v = map[status];
|
||||
return (
|
||||
<span className={`rounded px-1.5 py-0.5 text-[11px] ${v.cls}`}>
|
||||
{v.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,10 @@
|
||||
// ⑩ SET품여부* (0=부, 1=여) | 의뢰여부* (0=부, 1=여)
|
||||
// ⑪ 개당길이 | 개당소요량
|
||||
// ⑫ 비고 (1행)
|
||||
// ⑬ CAD Data: 3D / 2D(Drawing) / 2D(PDF) Drag&Drop — 별 PR(DEV-7) 도면업로드. 본 PR은 UI placeholder
|
||||
// ⑬ CAD Data: 3D / 2D(Drawing) / 2D(PDF) Drag&Drop — wace fnc_setFileDropZone 3종 1:1 (DEV-7)
|
||||
//
|
||||
// 신규: POST /api/development/part (운영 폼 22컬럼)
|
||||
// 신규: POST /api/development/part (운영 폼 22컬럼) — part_objid 선채번해서 전달
|
||||
// (도면이 PART INSERT 전에 attach_file_info 로 먼저 들어갈 수 있으므로 wace resultMap.OBJID 패턴)
|
||||
// 수정: PUT /api/development/part/:objid (wace updatePartDetail 21컬럼 1:1)
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
@@ -27,11 +28,13 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Save, Upload } from "lucide-react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||
import { devPartApi, PartCreateBody, PartUpdateBody, PartRow } from "@/lib/api/devPart";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createObjId } from "@/lib/utils/objidUtil";
|
||||
import { AttachFileDropZone } from "@/components/common/AttachFileDropZone";
|
||||
|
||||
// comm_code group ids (vexplor_rps DB)
|
||||
const GROUP_PART_TYPE = "0000062";
|
||||
@@ -92,6 +95,10 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }:
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
// CAD Data 업로드 target_objid:
|
||||
// - 수정 모드: editObjid 그대로
|
||||
// - 신규 모드: 다이얼로그 열릴 때 createObjId() 로 선채번 (wace partMngFormPopUp resultMap.OBJID 패턴)
|
||||
const [partObjid, setPartObjid] = useState<string | null>(null);
|
||||
|
||||
const setField = useCallback(
|
||||
<K extends keyof FormState>(key: K, value: FormState[K]) =>
|
||||
@@ -100,9 +107,17 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }:
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (isEdit && editObjid) loadDetail(editObjid);
|
||||
else setForm(EMPTY_FORM);
|
||||
if (!open) {
|
||||
setPartObjid(null);
|
||||
return;
|
||||
}
|
||||
if (isEdit && editObjid) {
|
||||
setPartObjid(editObjid);
|
||||
loadDetail(editObjid);
|
||||
} else {
|
||||
setForm(EMPTY_FORM);
|
||||
setPartObjid(createObjId());
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
@@ -176,6 +191,8 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }:
|
||||
toast.success("PART가 수정되었습니다.");
|
||||
} else {
|
||||
const body: PartCreateBody = {
|
||||
// CAD Data 도면이 선업로드 되었을 수 있으므로 선채번된 objid 전달 (wace 1:1)
|
||||
part_objid: partObjid ?? undefined,
|
||||
part_no: form.part_no,
|
||||
part_name: form.part_name,
|
||||
part_type: form.part_type,
|
||||
@@ -366,34 +383,43 @@ export function PartFormDialog({ open, onOpenChange, mode, editObjid, onSaved }:
|
||||
onChange={(e) => setField("remark", e.target.value)} /></Td>
|
||||
</Tr>
|
||||
|
||||
{/* ⑬ CAD Data — placeholder (DEV-7 도면업로드 별 PR) */}
|
||||
{/* ⑬ CAD Data — wace fnc_setFileDropZone 3종 1:1 */}
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left align-top font-medium" rowSpan={3}>
|
||||
CAD Data
|
||||
</th>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">3D</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<DropPlaceholder label="3D" />
|
||||
<AttachFileDropZone
|
||||
targetObjid={partObjid}
|
||||
docType="3D_CAD"
|
||||
docTypeName="3D CAD 첨부파일"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(Drawing)</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<DropPlaceholder label="2D(Drawing)" />
|
||||
<AttachFileDropZone
|
||||
targetObjid={partObjid}
|
||||
docType="2D_DRAWING_CAD"
|
||||
docTypeName="2D(Drawing) CAD 첨부파일"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border bg-muted/30 px-3 py-2 text-left font-medium">2D(PDF)</th>
|
||||
<td className="border px-3 py-2" colSpan={3}>
|
||||
<DropPlaceholder label="2D(PDF)" />
|
||||
<AttachFileDropZone
|
||||
targetObjid={partObjid}
|
||||
docType="2D_PDF_CAD"
|
||||
docTypeName="2D(PDF) CAD 첨부파일"
|
||||
accept=".pdf,application/pdf"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-2 text-[11px] text-muted-foreground">
|
||||
CAD Data 첨부는 DEV-7 (도면업로드) 별 PR 에서 활성화됩니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -447,15 +473,6 @@ function BasicSelect({
|
||||
</select>
|
||||
);
|
||||
}
|
||||
function DropPlaceholder({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="border-2 border-dashed border-muted-foreground/30 rounded text-muted-foreground py-6 text-center text-xs">
|
||||
<Upload className="h-5 w-5 mx-auto mb-1" />
|
||||
Drag & Drop Files Here ({label})
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PartRow → FormState ────────────────────────────────────
|
||||
|
||||
function rowToForm(r: PartRow): FormState {
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface BomTreeFilter {
|
||||
unit_code?: string;
|
||||
search_part_no?: string;
|
||||
search_part_name?: string;
|
||||
search_level?: string | number; // wace 1:1 — 1~5 표시 레벨
|
||||
}
|
||||
|
||||
export interface BomTreeRow {
|
||||
@@ -117,6 +118,14 @@ export interface BomTreeRow {
|
||||
cu02_cnt: number | string | null;
|
||||
cu03_cnt: number | string | null;
|
||||
max_level: number | string | null;
|
||||
// 풀 컬럼 (운영판 1:1)
|
||||
p_qty: string | number | null;
|
||||
heat_treatment_hardness: string | null;
|
||||
heat_treatment_method: string | null;
|
||||
surface_treatment: string | null;
|
||||
maker: string | null;
|
||||
part_type: string | null;
|
||||
part_type_title: string | null;
|
||||
}
|
||||
|
||||
export interface BomTreeResponse {
|
||||
|
||||
@@ -306,4 +306,43 @@ export const devPartApi = {
|
||||
const res = await apiClient.post("/development/part/excel-save", { rows });
|
||||
return res.data?.data as ExcelSaveResponse;
|
||||
},
|
||||
|
||||
// 도면 다중 업로드 (wace btnDrawingUpload 1:1)
|
||||
// 확장자 stp/step → 3D_CAD, dwg/dxf → 2D_DRAWING_CAD, pdf → 2D_PDF_CAD
|
||||
// 파일명 ↔ part_no 자동 매칭 (정확 일치 → longest prefix)
|
||||
// partNoList 지정 → 그 목록만 매칭 후보 (M1)
|
||||
// partNoList null/undefined → IS_LAST='1' 전체 매칭 (M2)
|
||||
async drawingMultiUpload(
|
||||
files: File[],
|
||||
partNoList?: string[] | null
|
||||
): Promise<DrawingMultiUploadResult> {
|
||||
const fd = new FormData();
|
||||
for (const f of files) fd.append("files", f);
|
||||
if (Array.isArray(partNoList) && partNoList.length > 0) {
|
||||
fd.append("partNoList", JSON.stringify(partNoList));
|
||||
}
|
||||
const res = await apiClient.post(
|
||||
"/development/part/drawing-multi-upload",
|
||||
fd,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||
);
|
||||
return res.data?.data as DrawingMultiUploadResult;
|
||||
},
|
||||
};
|
||||
|
||||
// ─── 도면 다중 업로드 결과 타입 ──────────────────────────────
|
||||
|
||||
export interface DrawingMultiUploadDetail {
|
||||
fileName: string;
|
||||
partNo?: string;
|
||||
docType?: string;
|
||||
status: "success" | "fail" | "notFound" | "unsupported";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DrawingMultiUploadResult {
|
||||
successCount: number;
|
||||
failCount: number;
|
||||
notFoundCount: number;
|
||||
details: DrawingMultiUploadDetail[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// part_mng / attach_file_info 등 wace 운영판 `objid bigint` 컬럼 채번 유틸.
|
||||
// 백엔드 `backend-node/src/utils/objidUtil.ts` 와 1:1 동일 알고리즘.
|
||||
//
|
||||
// wace java `com.pms.common.CommonUtils.createObjId()` 1:1 이식:
|
||||
// 1) UUID v4 생성
|
||||
// 2) 하이픈 제거 → 32 hex 문자열
|
||||
// 3) Java String.hashCode() (int32) 적용
|
||||
// 4) 결과 정수를 문자열로 반환
|
||||
|
||||
function javaStringHashCode(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function uuidv4(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// 폴백: getRandomValues 기반 RFC4122 v4
|
||||
const buf = new Uint8Array(16);
|
||||
(crypto as Crypto).getRandomValues(buf);
|
||||
buf[6] = (buf[6] & 0x0f) | 0x40;
|
||||
buf[8] = (buf[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
export function createObjId(): string {
|
||||
return String(javaStringHashCode(uuidv4().replace(/-/g, "")));
|
||||
}
|
||||
Reference in New Issue
Block a user