Merge pull request 'hjjeong' (#12) from hjjeong into main
Reviewed-on: https://g.wace.me/chpark/vexplor_rps/pulls/12
This commit is contained in:
@@ -52,3 +52,22 @@ export async function getTree(req: AuthenticatedRequest, res: Response) {
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// PR-B1 — 본 편집 저장 (운영 saveMbom.do 1:1)
|
||||
export async function save(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const payload = req.body as svc.MbomSavePayload;
|
||||
if (!payload?.project_obj_id) {
|
||||
return res.status(400).json({ success: false, message: "project_obj_id 누락" });
|
||||
}
|
||||
if (!Array.isArray(payload.rows)) {
|
||||
return res.status(400).json({ success: false, message: "rows 누락" });
|
||||
}
|
||||
const userId = req.user?.userId ?? "system";
|
||||
const data = await svc.save(payload, userId);
|
||||
return res.json({ success: true, data });
|
||||
} catch (e: any) {
|
||||
logger.error("M-BOM 저장 실패", { error: e.message });
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@ router.use(authenticateToken);
|
||||
router.get("/list", ctrl.getList);
|
||||
router.get("/detail/:objid", ctrl.getDetail);
|
||||
router.get("/tree/:objid", ctrl.getTree);
|
||||
router.post("/save", ctrl.save); // PR-B1 본 편집 저장
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
// ============================================================
|
||||
|
||||
import { getPool } from "../database/db";
|
||||
import { createObjId } from "../utils/objidUtil";
|
||||
|
||||
// ─── 필터/페이지 타입 ──────────────────────────────────────────
|
||||
|
||||
@@ -611,9 +612,9 @@ SELECT
|
||||
P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT,
|
||||
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title,
|
||||
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
|
||||
COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty,
|
||||
COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length
|
||||
FROM VIEW_BOM V
|
||||
@@ -686,9 +687,9 @@ SELECT
|
||||
P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT,
|
||||
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title,
|
||||
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
|
||||
COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty,
|
||||
COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length
|
||||
FROM VIEW_BOM V
|
||||
@@ -770,9 +771,9 @@ SELECT
|
||||
P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT,
|
||||
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.UNIT) AS unit_title,
|
||||
(SELECT CODE_NAME FROM COMM_CODE CC WHERE CC.CODE_ID = P.PART_TYPE) AS part_type_title,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
|
||||
COALESCE((SELECT NULLIF(MP.UNIT_QTY, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_qty,
|
||||
COALESCE((SELECT NULLIF(MP.UNIT_LENGTH, '')::numeric FROM PART_MNG MP WHERE MP.PART_NO = V.RAW_MATERIAL_PART_NO LIMIT 1), 0) AS part_unit_length
|
||||
FROM VIEW_BOM V
|
||||
@@ -780,6 +781,449 @@ LEFT JOIN PART_MNG P ON V.PART_OBJID = P.OBJID
|
||||
ORDER BY V.PATH2
|
||||
`;
|
||||
|
||||
// ─── 저장 (PR-B1) ───────────────────────────────────────────
|
||||
//
|
||||
// 운영판 ProductionPlanningController.saveMbom (1549~1645) + 서비스 saveMbom (1192~1574) 1:1.
|
||||
// 매퍼: insertMbomHeader / updateMbomHeader / insertMbomDetail / updateMbomDetail
|
||||
// / deleteMbomDetailByObjid / insertMbomHistory / updateProjectMbomStatus.
|
||||
//
|
||||
// 분기 처리:
|
||||
// isUpdate=false (최초 저장) — 새 mbom_header 생성 + child_objid 재매핑 후 detail 일괄 insert
|
||||
// + history(CREATE) + project_mgmt.mbom_status='Y'
|
||||
// isUpdate=true (수정 저장) — 기존 mbom_header.objid 조회 → updateMbomHeader
|
||||
// → mbom_data 의 objid 기준 UPSERT(insert/update) + 누락분 delete
|
||||
// + history(UPDATE, 변경 행수 description)
|
||||
|
||||
export interface MbomSaveRow {
|
||||
objid?: string | null;
|
||||
parent_objid?: string | null;
|
||||
child_objid?: string | null;
|
||||
seq?: number | string | null;
|
||||
level?: number | string | null;
|
||||
part_objid?: string | number | null;
|
||||
part_no?: string | null;
|
||||
part_name?: string | null;
|
||||
qty?: number | string | null;
|
||||
item_qty?: number | string | null;
|
||||
unit?: string | null;
|
||||
supply_type?: string | null;
|
||||
make_or_buy?: string | null;
|
||||
raw_material_no?: string | null;
|
||||
raw_material_spec?: string | null;
|
||||
raw_material?: string | null;
|
||||
size?: string | null; // tree row alias → raw_material_size
|
||||
raw_material_size?: string | null;
|
||||
processing_vendor?: string | null;
|
||||
processing_deadline?: string | null;
|
||||
grinding_deadline?: string | null;
|
||||
required_qty?: number | string | null;
|
||||
order_qty?: number | string | null;
|
||||
production_qty?: number | string | null;
|
||||
stock_qty?: number | string | null;
|
||||
shortage_qty?: number | string | null;
|
||||
vendor?: string | null;
|
||||
unit_price?: number | string | null;
|
||||
processing_unit_price?: number | string | null;
|
||||
total_price?: number | string | null;
|
||||
currency?: string | null;
|
||||
lead_time?: number | string | null;
|
||||
min_order_qty?: number | string | null;
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
export interface MbomSavePayload {
|
||||
project_obj_id: string;
|
||||
is_update: boolean;
|
||||
mbom_part_no?: string | null; // 최상위 제품 변경 시 (PR-B1 에서는 신규/유지만)
|
||||
rows: MbomSaveRow[];
|
||||
}
|
||||
|
||||
export interface MbomSaveResult {
|
||||
mode: "CREATE" | "UPDATE";
|
||||
mbom_header_objid: string;
|
||||
mbom_no: string;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
}
|
||||
|
||||
// generateMbomNo — wace generateMbomNo(EBOM/TEMPLATE) 1:1.
|
||||
// 패턴: M-{cleanPartNo}-{YYMMDD}-{NN} (NN = 동일 prefix 마지막+1, 미존재 시 01)
|
||||
async function generateMbomNo(
|
||||
client: any,
|
||||
sourceBomType: string,
|
||||
basePartNo: string,
|
||||
): Promise<string> {
|
||||
let cleanPartNo = (basePartNo || "").trim();
|
||||
if (cleanPartNo.startsWith("M-")) cleanPartNo = cleanPartNo.substring(2);
|
||||
const now = new Date();
|
||||
const yy = String(now.getFullYear()).slice(-2);
|
||||
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(now.getDate()).padStart(2, "0");
|
||||
const dateStr = `${yy}${mm}${dd}`;
|
||||
const prefix = `M-${cleanPartNo}-${dateStr}`;
|
||||
|
||||
const r = await client.query(
|
||||
`SELECT MBOM_NO FROM MBOM_HEADER
|
||||
WHERE MBOM_NO LIKE $1 || '-%'
|
||||
ORDER BY MBOM_NO DESC LIMIT 1`,
|
||||
[prefix],
|
||||
);
|
||||
let seq = 1;
|
||||
if (r.rows[0]?.mbom_no) {
|
||||
const m = String(r.rows[0].mbom_no).match(/-(\d{2})$/);
|
||||
if (m) seq = Math.min(99, Number(m[1]) + 1);
|
||||
}
|
||||
return `${prefix}-${String(seq).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const DETAIL_INSERT_SQL = `
|
||||
INSERT INTO MBOM_DETAIL (
|
||||
OBJID, MBOM_HEADER_OBJID, PARENT_OBJID, CHILD_OBJID, SEQ, LEVEL,
|
||||
PART_OBJID, PART_NO, PART_NAME, QTY, ITEM_QTY, UNIT,
|
||||
SUPPLY_TYPE, MAKE_OR_BUY,
|
||||
RAW_MATERIAL_PART_NO, RAW_MATERIAL_SPEC, RAW_MATERIAL, RAW_MATERIAL_SIZE,
|
||||
PROCESSING_VENDOR, PROCESSING_DEADLINE, GRINDING_DEADLINE,
|
||||
REQUIRED_QTY, ORDER_QTY, PRODUCTION_QTY, STOCK_QTY, SHORTAGE_QTY,
|
||||
VENDOR, UNIT_PRICE, PROCESSING_UNIT_PRICE, TOTAL_PRICE, CURRENCY,
|
||||
LEAD_TIME, MIN_ORDER_QTY,
|
||||
STATUS, WRITER, REGDATE, EDITER, EDIT_DATE, REMARK
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,
|
||||
$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,
|
||||
$27,$28,$29,$30,$31,$32,$33,
|
||||
'ACTIVE', $34, NOW(), $34, NOW(), $35
|
||||
)`;
|
||||
|
||||
const DETAIL_UPDATE_SQL = `
|
||||
UPDATE MBOM_DETAIL SET
|
||||
PARENT_OBJID = NULLIF($1, ''),
|
||||
SEQ = $2,
|
||||
LEVEL = $3,
|
||||
PART_OBJID = $4,
|
||||
PART_NO = NULLIF($5, ''),
|
||||
PART_NAME = NULLIF($6, ''),
|
||||
QTY = $7,
|
||||
ITEM_QTY = $8,
|
||||
UNIT = NULLIF($9, ''),
|
||||
SUPPLY_TYPE = NULLIF($10, ''),
|
||||
MAKE_OR_BUY = NULLIF($11, ''),
|
||||
RAW_MATERIAL_PART_NO = NULLIF($12, ''),
|
||||
RAW_MATERIAL_SPEC = NULLIF($13, ''),
|
||||
RAW_MATERIAL = NULLIF($14, ''),
|
||||
RAW_MATERIAL_SIZE = NULLIF($15, ''),
|
||||
PROCESSING_VENDOR = NULLIF($16, ''),
|
||||
PROCESSING_DEADLINE = NULLIF($17, ''),
|
||||
GRINDING_DEADLINE = NULLIF($18, ''),
|
||||
REQUIRED_QTY = $19,
|
||||
ORDER_QTY = $20,
|
||||
PRODUCTION_QTY = $21,
|
||||
STOCK_QTY = $22,
|
||||
SHORTAGE_QTY = $23,
|
||||
EDITER = $24,
|
||||
EDIT_DATE = NOW(),
|
||||
REMARK = NULLIF($25, '')
|
||||
WHERE OBJID = $26`;
|
||||
|
||||
function n(v: any): number | null {
|
||||
if (v == null || v === "") return null;
|
||||
const num = Number(v);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
function s(v: any): string | null {
|
||||
if (v == null) return null;
|
||||
const str = String(v).trim();
|
||||
return str === "" ? null : str;
|
||||
}
|
||||
function bi(v: any): string | null {
|
||||
// part_objid 는 bigint — 숫자/문자열 모두 수용, 빈값/NaN 은 null
|
||||
if (v == null || v === "") return null;
|
||||
const num = Number(v);
|
||||
return Number.isFinite(num) ? String(Math.trunc(num)) : null;
|
||||
}
|
||||
|
||||
function detailInsertParams(
|
||||
row: MbomSaveRow,
|
||||
objid: string,
|
||||
mbomHeaderObjid: string,
|
||||
childObjid: string,
|
||||
parentObjid: string | null,
|
||||
userId: string,
|
||||
): any[] {
|
||||
return [
|
||||
objid,
|
||||
mbomHeaderObjid,
|
||||
parentObjid,
|
||||
childObjid,
|
||||
n(row.seq) ?? 999,
|
||||
n(row.level) ?? 1,
|
||||
bi(row.part_objid),
|
||||
s(row.part_no),
|
||||
s(row.part_name),
|
||||
n(row.qty),
|
||||
n(row.item_qty),
|
||||
s(row.unit),
|
||||
s(row.supply_type),
|
||||
s(row.make_or_buy),
|
||||
s(row.raw_material_no),
|
||||
s(row.raw_material_spec),
|
||||
s(row.raw_material),
|
||||
s(row.raw_material_size ?? row.size),
|
||||
s(row.processing_vendor),
|
||||
s(row.processing_deadline),
|
||||
s(row.grinding_deadline),
|
||||
n(row.required_qty),
|
||||
n(row.order_qty),
|
||||
n(row.production_qty),
|
||||
n(row.stock_qty),
|
||||
n(row.shortage_qty),
|
||||
s(row.vendor),
|
||||
n(row.unit_price),
|
||||
n(row.processing_unit_price),
|
||||
n(row.total_price),
|
||||
s(row.currency) ?? "KRW",
|
||||
n(row.lead_time),
|
||||
n(row.min_order_qty),
|
||||
userId,
|
||||
s(row.remark),
|
||||
];
|
||||
}
|
||||
|
||||
function detailUpdateParams(row: MbomSaveRow, objid: string, userId: string): any[] {
|
||||
return [
|
||||
s(row.parent_objid) ?? "",
|
||||
n(row.seq) ?? 999,
|
||||
n(row.level) ?? 1,
|
||||
bi(row.part_objid),
|
||||
s(row.part_no) ?? "",
|
||||
s(row.part_name) ?? "",
|
||||
n(row.qty),
|
||||
n(row.item_qty),
|
||||
s(row.unit) ?? "",
|
||||
s(row.supply_type) ?? "",
|
||||
s(row.make_or_buy) ?? "",
|
||||
s(row.raw_material_no) ?? "",
|
||||
s(row.raw_material_spec) ?? "",
|
||||
s(row.raw_material) ?? "",
|
||||
s(row.raw_material_size ?? row.size) ?? "",
|
||||
s(row.processing_vendor) ?? "",
|
||||
s(row.processing_deadline) ?? "",
|
||||
s(row.grinding_deadline) ?? "",
|
||||
n(row.required_qty),
|
||||
n(row.order_qty),
|
||||
n(row.production_qty),
|
||||
n(row.stock_qty),
|
||||
n(row.shortage_qty),
|
||||
userId,
|
||||
s(row.remark) ?? "",
|
||||
objid,
|
||||
];
|
||||
}
|
||||
|
||||
export async function save(payload: MbomSavePayload, sessionUserId: string): Promise<MbomSaveResult> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
const projectObjId = String(payload.project_obj_id ?? "").trim();
|
||||
if (!projectObjId) throw new Error("project_obj_id 누락");
|
||||
const userId = String(sessionUserId ?? "").trim() || "system";
|
||||
|
||||
// 1) 프로젝트 + 할당 정보 조회 (sourceBomType + basePartNo)
|
||||
const proj = await client.query(
|
||||
`SELECT PM.OBJID::VARCHAR AS objid,
|
||||
PM.PART_NO AS part_no, PM.PART_NAME AS part_name,
|
||||
PM.SOURCE_BOM_TYPE AS source_bom_type,
|
||||
PM.SOURCE_EBOM_OBJID AS source_ebom_objid,
|
||||
PM.SOURCE_MBOM_OBJID AS source_mbom_objid,
|
||||
CM.PRODUCT AS product_code,
|
||||
(SELECT PBR.PART_NO FROM PART_BOM_REPORT PBR
|
||||
WHERE PBR.OBJID::VARCHAR = PM.SOURCE_EBOM_OBJID LIMIT 1) AS ebom_part_no,
|
||||
(SELECT MH.MBOM_NO FROM MBOM_HEADER MH
|
||||
WHERE MH.OBJID = PM.SOURCE_MBOM_OBJID LIMIT 1) AS source_mbom_no
|
||||
FROM PROJECT_MGMT PM
|
||||
INNER JOIN CONTRACT_MGMT CM ON PM.CONTRACT_OBJID = CM.OBJID
|
||||
WHERE PM.OBJID::VARCHAR = $1 LIMIT 1`,
|
||||
[projectObjId],
|
||||
);
|
||||
if (!proj.rows[0]) throw new Error("프로젝트 정보를 찾을 수 없습니다");
|
||||
const p = proj.rows[0];
|
||||
|
||||
let sourceBomType: string = p.source_bom_type ?? "";
|
||||
let sourceEbomObjid: string | null = null;
|
||||
let sourceMbomObjid: string | null = null;
|
||||
let basePartNo = "";
|
||||
|
||||
if (sourceBomType === "EBOM") {
|
||||
sourceEbomObjid = p.source_ebom_objid ?? null;
|
||||
basePartNo = p.ebom_part_no ?? p.part_no ?? "";
|
||||
} else if (sourceBomType === "MBOM") {
|
||||
sourceMbomObjid = p.source_mbom_objid ?? null;
|
||||
basePartNo = p.source_mbom_no ?? p.part_no ?? "";
|
||||
} else {
|
||||
// Machine 이외(product != 0000928) + part_no 가 있으면 TEMPLATE
|
||||
if (p.product_code !== "0000928" && p.part_no) {
|
||||
sourceBomType = "TEMPLATE";
|
||||
basePartNo = p.part_no;
|
||||
} else {
|
||||
throw new Error("M-BOM 기준 정보가 없습니다. BOM 복사 팝업에서 먼저 기준을 설정해주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
let mbomHeaderObjid: string;
|
||||
let mbomNo: string;
|
||||
let inserted = 0, updated = 0, deleted = 0;
|
||||
let mode: "CREATE" | "UPDATE";
|
||||
|
||||
if (payload.is_update) {
|
||||
// ── UPDATE 분기 ──
|
||||
const exist = await client.query(
|
||||
`SELECT OBJID AS objid, MBOM_NO AS mbom_no FROM MBOM_HEADER
|
||||
WHERE PROJECT_OBJID = $1 AND STATUS = 'Y'
|
||||
ORDER BY REGDATE DESC LIMIT 1`,
|
||||
[projectObjId],
|
||||
);
|
||||
if (!exist.rows[0]) throw new Error("수정 대상 M-BOM 헤더를 찾을 수 없습니다");
|
||||
mbomHeaderObjid = exist.rows[0].objid;
|
||||
mbomNo = exist.rows[0].mbom_no;
|
||||
mode = "UPDATE";
|
||||
|
||||
// 헤더 update
|
||||
await client.query(
|
||||
`UPDATE MBOM_HEADER SET
|
||||
PART_NO = $1, PART_NAME = $2,
|
||||
EDITER = $3, EDIT_DATE = NOW()
|
||||
WHERE OBJID = $4`,
|
||||
[p.part_no ?? "", p.part_name ?? "", userId, mbomHeaderObjid],
|
||||
);
|
||||
|
||||
// 기존 detail objid 수집
|
||||
const existRes = await client.query(
|
||||
`SELECT OBJID AS objid FROM MBOM_DETAIL WHERE MBOM_HEADER_OBJID = $1`,
|
||||
[mbomHeaderObjid],
|
||||
);
|
||||
const existIds = new Set<string>(existRes.rows.map((r: any) => r.objid));
|
||||
const incomingIds = new Set<string>();
|
||||
|
||||
// UPSERT
|
||||
for (const row of payload.rows ?? []) {
|
||||
let objid = s(row.objid) ?? "";
|
||||
if (!objid) objid = createObjId();
|
||||
let childObjid = s(row.child_objid) ?? objid;
|
||||
incomingIds.add(objid);
|
||||
|
||||
if (existIds.has(objid)) {
|
||||
await client.query(DETAIL_UPDATE_SQL, detailUpdateParams({ ...row, child_objid: childObjid }, objid, userId));
|
||||
updated++;
|
||||
} else {
|
||||
await client.query(
|
||||
DETAIL_INSERT_SQL,
|
||||
detailInsertParams(row, objid, mbomHeaderObjid, childObjid, s(row.parent_objid), userId),
|
||||
);
|
||||
inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
// 누락 행 delete
|
||||
for (const oldId of existIds) {
|
||||
if (!incomingIds.has(oldId)) {
|
||||
await client.query(`DELETE FROM MBOM_DETAIL WHERE OBJID = $1`, [oldId]);
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
// history UPDATE
|
||||
await insertHistory(
|
||||
client,
|
||||
mbomHeaderObjid,
|
||||
"UPDATE",
|
||||
`${inserted + updated + deleted}개 항목 처리 (insert=${inserted}, update=${updated}, delete=${deleted})`,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
// ── CREATE 분기 ──
|
||||
mbomHeaderObjid = createObjId();
|
||||
mbomNo = await generateMbomNo(client, sourceBomType, basePartNo);
|
||||
mode = "CREATE";
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO MBOM_HEADER (
|
||||
OBJID, MBOM_NO, SOURCE_BOM_TYPE, SOURCE_EBOM_OBJID, SOURCE_MBOM_OBJID,
|
||||
PROJECT_OBJID, PART_NO, PART_NAME, STATUS, MBOM_STATUS,
|
||||
WRITER, REGDATE
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'Y','DRAFT',$9,NOW())`,
|
||||
[
|
||||
mbomHeaderObjid, mbomNo, sourceBomType,
|
||||
sourceEbomObjid, sourceMbomObjid,
|
||||
projectObjId, p.part_no ?? "", p.part_name ?? "",
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
// child_objid 재매핑 (E-BOM/MBOM 의 기존 child_objid → 새 child_objid)
|
||||
const childMap = new Map<string, string>();
|
||||
for (const row of payload.rows ?? []) {
|
||||
const oldChild = s(row.child_objid);
|
||||
if (oldChild && !childMap.has(oldChild)) childMap.set(oldChild, createObjId());
|
||||
}
|
||||
|
||||
for (const row of payload.rows ?? []) {
|
||||
const oldChild = s(row.child_objid);
|
||||
const oldParent = s(row.parent_objid);
|
||||
const newChild = (oldChild && childMap.get(oldChild)) || createObjId();
|
||||
const newParent = oldParent ? (childMap.get(oldParent) ?? oldParent) : null;
|
||||
|
||||
await client.query(
|
||||
DETAIL_INSERT_SQL,
|
||||
detailInsertParams(row, newChild, mbomHeaderObjid, newChild, newParent, userId),
|
||||
);
|
||||
inserted++;
|
||||
}
|
||||
|
||||
// history CREATE
|
||||
await insertHistory(
|
||||
client,
|
||||
mbomHeaderObjid,
|
||||
"CREATE",
|
||||
`M-BOM 신규 저장 (${inserted}개 항목)`,
|
||||
userId,
|
||||
);
|
||||
|
||||
// PROJECT_MGMT.MBOM_STATUS = 'Y'
|
||||
await client.query(
|
||||
`UPDATE PROJECT_MGMT SET MBOM_STATUS = 'Y', MBOM_WRITER = $1, MBOM_REGDATE = NOW()
|
||||
WHERE OBJID::VARCHAR = $2`,
|
||||
[userId, projectObjId],
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return { mode, mbom_header_objid: mbomHeaderObjid, mbom_no: mbomNo, inserted, updated, deleted };
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK");
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function insertHistory(
|
||||
client: any,
|
||||
mbomHeaderObjid: string,
|
||||
changeType: "CREATE" | "UPDATE",
|
||||
description: string,
|
||||
userId: string,
|
||||
) {
|
||||
await client.query(
|
||||
`INSERT INTO MBOM_HISTORY (
|
||||
OBJID, MBOM_HEADER_OBJID, CHANGE_TYPE, CHANGE_DESCRIPTION,
|
||||
BEFORE_DATA, AFTER_DATA, CHANGE_USER, CHANGE_DATE
|
||||
) VALUES ($1, $2, $3, $4, NULL, NULL, $5, NOW())`,
|
||||
[createObjId(), mbomHeaderObjid, changeType, description, userId],
|
||||
);
|
||||
}
|
||||
|
||||
// 매퍼 partMng.getBOMTreeList search_type='working' 1:1.
|
||||
// E-BOM 호환 — part_no 컬럼명 충돌 회피 위해 운영판처럼 V.PART_NO 는 PART_OBJID 로 alias.
|
||||
const EBOM_WORKING_TREE_SQL = `
|
||||
@@ -844,9 +1288,9 @@ SELECT
|
||||
P.REMARK AS part_remark,
|
||||
P.THICKNESS, P.WIDTH, P.HEIGHT, P.OUT_DIAMETER, P.IN_DIAMETER, P.LENGTH,
|
||||
P.SOURCING_CODE, P.HEAT_TREATMENT_HARDNESS, P.HEAT_TREATMENT_METHOD, P.SURFACE_TREATMENT,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('3D_CAD')) AS cu01_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_DRAWING_CAD')) AS cu02_cnt,
|
||||
(SELECT COUNT(1) FROM ATTACH_FILE_INFO F WHERE P.OBJID::varchar = F.TARGET_OBJID AND F.STATUS = 'Active' AND F.DOC_TYPE IN ('2D_PDF_CAD')) AS cu03_cnt,
|
||||
-- E-BOM 분기는 생산정보가 없으므로 NULL 로 채워 SAVED 와 동일한 키셋 유지
|
||||
NULL::text AS supply_type, NULL::text AS make_or_buy,
|
||||
NULL::text AS raw_material_no, NULL::text AS raw_material_spec,
|
||||
@@ -861,6 +1305,6 @@ SELECT
|
||||
NULL::int AS lead_time, NULL::numeric AS min_order_qty,
|
||||
NULL::text AS writer, NULL::text AS regdate, NULL::text AS editer, NULL::text AS edit_date, NULL::text AS remark
|
||||
FROM VIEW_BOM V
|
||||
INNER JOIN PART_MNG P ON P.OBJID = COALESCE(V.LAST_PART_OBJID, V.PART_NO)
|
||||
INNER JOIN PART_MNG P ON P.OBJID::varchar = COALESCE(V.LAST_PART_OBJID, V.PART_NO)
|
||||
ORDER BY V.PATH2
|
||||
`;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PageHeader } from "@/components/common/PageHeader";
|
||||
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
|
||||
import { devEoHistoryApi, EoHistoryListFilter, EoHistoryRow } from "@/lib/api/devEoHistory";
|
||||
import { PartHisDetailDialog } from "@/components/development/PartHisDetailDialog";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
// comm_code 그룹 (vexplor_rps)
|
||||
const GROUP_PART_TYPE = "0000062";
|
||||
@@ -29,22 +30,22 @@ const YEAR_OPTIONS: SmartSelectOption[] = (() => {
|
||||
})();
|
||||
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "eo_no", label: "EO No", width: "w-[100px]", frozen: true },
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[120px]" },
|
||||
{ key: "eo_no", label: "EO No", width: "w-[110px]", frozen: true },
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
|
||||
{ key: "project_name", label: "프로젝트명", width: "w-[180px]" },
|
||||
{ key: "unit_name", label: "유닛명", width: "w-[160px]" },
|
||||
{ key: "parent_part_info", label: "모품번", width: "w-[160px]" },
|
||||
{ key: "part_no_disp", label: "품번", width: "w-[160px]" },
|
||||
{ key: "part_name_disp", label: "품명", minWidth: "min-w-[180px]" },
|
||||
{ key: "qty", label: "수량", width: "w-[70px]", align: "right", formatNumber: true },
|
||||
{ key: "qty_temp", label: "변경수량", width: "w-[80px]", align: "right", formatNumber: true },
|
||||
{ key: "change_type_name", label: "EO구분", width: "w-[90px]", align: "center" },
|
||||
{ key: "change_option_name", label: "EO사유", width: "w-[100px]", align: "center" },
|
||||
{ key: "revision_disp", label: "Revision", width: "w-[90px]", align: "center" },
|
||||
{ key: "eo_date", label: "EO Date", width: "w-[100px]", align: "center" },
|
||||
{ key: "part_type_name", label: "PART구분", width: "w-[90px]", align: "center" },
|
||||
{ key: "writer_name", label: "담당자", width: "w-[90px]", align: "center" },
|
||||
{ key: "his_reg_date_title", label: "실행일", width: "w-[100px]", align: "center" },
|
||||
{ key: "qty", label: "수량", width: "w-[95px]", align: "right", formatNumber: true },
|
||||
{ key: "qty_temp", label: "변경수량", width: "w-[115px]", align: "right", formatNumber: true },
|
||||
{ key: "change_type_name", label: "EO구분", width: "w-[115px]", align: "center" },
|
||||
{ key: "change_option_name", label: "EO사유", width: "w-[115px]", align: "center" },
|
||||
{ key: "revision_disp", label: "Revision", width: "w-[115px]", align: "center" },
|
||||
{ key: "eo_date", label: "EO Date", width: "w-[110px]", align: "center" },
|
||||
{ key: "part_type_name", label: "PART구분", width: "w-[115px]", align: "center" },
|
||||
{ key: "writer_name", label: "담당자", width: "w-[115px]", align: "center" },
|
||||
{ key: "his_reg_date_title", label: "실행일", width: "w-[115px]", align: "center" },
|
||||
];
|
||||
|
||||
const EMPTY_FILTER: EoHistoryListFilter = {
|
||||
@@ -69,7 +70,9 @@ export default function EoHistoryPage() {
|
||||
try {
|
||||
const f = { ...filter, ...override };
|
||||
const res = await devEoHistoryApi.list(f);
|
||||
setRows(res.rows ?? []);
|
||||
// DataGrid row 키 — objid 없을 수 있어 인덱스 fallback (id 누락 시 모든 행이 selected 로 잡힘)
|
||||
const list = (res.rows ?? []).map((r, i) => ({ ...r, id: (r as any).objid ?? `eo-${i}` }));
|
||||
setRows(list as any);
|
||||
setTotal(res.total ?? 0);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
@@ -90,8 +93,23 @@ export default function EoHistoryPage() {
|
||||
[],
|
||||
);
|
||||
|
||||
// ─── 하단 통계 ──────────────────────────────────────────────
|
||||
// 이력 건수(총·페이지) / 수량·변경수량 합계
|
||||
const eoSummary = useMemo(() => {
|
||||
const pageCount = rows.length;
|
||||
const qtySum = rows.reduce((acc, r: any) => acc + Number(r.qty || 0), 0);
|
||||
const qtyTemp = rows.reduce((acc, r: any) => acc + Number(r.qty_temp || 0), 0);
|
||||
const intFmt = (n: number) => n.toLocaleString();
|
||||
return [
|
||||
{ label: "전체 건수", value: intFmt(total), suffix: "건" },
|
||||
{ label: "페이지 건수", value: intFmt(pageCount), suffix: "건" },
|
||||
{ label: "수량 합계", value: intFmt(qtySum) },
|
||||
{ label: "변경수량 합계", value: intFmt(qtyTemp) },
|
||||
];
|
||||
}, [rows, total]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-2">
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
onSearch={() => fetchList()}
|
||||
@@ -145,7 +163,6 @@ export default function EoHistoryPage() {
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
@@ -153,8 +170,29 @@ export default function EoHistoryPage() {
|
||||
showRowNumber
|
||||
emptyMessage="설계변경 이력이 없습니다."
|
||||
gridId="development-eo-history"
|
||||
showColumnSettings
|
||||
paginationStyle="range"
|
||||
pageSizeOptions={[10, 15, 20, 50, 100, 200, 500]}
|
||||
serverPaging
|
||||
serverPage={filter.page ?? 1}
|
||||
serverPageSize={filter.page_size ?? 50}
|
||||
serverTotalItems={total}
|
||||
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
|
||||
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
|
||||
summaryStats={eoSummary}
|
||||
systemColumnKeys={["writer_name", "his_reg_date_title"]}
|
||||
onRefresh={() => fetchList()}
|
||||
onDownload={() => {
|
||||
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
||||
const exportRows = rows.map((r: any) => {
|
||||
const out: Record<string, any> = {};
|
||||
GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "설계변경_리스트.xlsx", "설계변경_리스트");
|
||||
}}
|
||||
showChart
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PartHisDetailDialog
|
||||
open={detailOpen}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { BomReportStatusDialog } from "@/components/development/BomReportStatusD
|
||||
import { DevPartSelect } from "@/components/development/DevPartSelect";
|
||||
import { BomReportExcelImportDialog } from "@/components/development/BomReportExcelImportDialog";
|
||||
import { BomReportTreeDialog } from "@/components/development/BomReportTreeDialog";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
const PRODUCT_GROUP = "0000001"; // 제품구분 (vexplor 공용)
|
||||
|
||||
@@ -33,11 +34,11 @@ const BASE_GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "part_no", label: "품번", width: "w-[210px]" },
|
||||
{ key: "part_name", label: "품명", minWidth: "min-w-[220px]" },
|
||||
// wace fnc_getFolderIcon 1:1 — 폴더 아이콘 클릭 시 BOM 구조 다이얼로그
|
||||
{ key: "bom_cnt", label: "E-BOM", width: "w-[100px]", align: "center", renderType: "folder" },
|
||||
{ key: "bom_cnt", label: "E-BOM", width: "w-[115px]", align: "center", renderType: "folder" },
|
||||
{ key: "dept_user_name", label: "등록자", width: "w-[140px]", align: "center" },
|
||||
{ key: "reg_date", label: "등록일", width: "w-[120px]", align: "center" },
|
||||
{ key: "deploy_date", label: "확정일", width: "w-[120px]", align: "center" },
|
||||
{ key: "revision", label: "Version", width: "w-[100px]", align: "center" },
|
||||
{ key: "reg_date", label: "등록일", width: "w-[125px]", align: "center" },
|
||||
{ key: "deploy_date", label: "확정일", width: "w-[125px]", align: "center" },
|
||||
{ key: "revision", label: "Version", width: "w-[115px]", align: "center" },
|
||||
{ key: "status_title", label: "상태", width: "w-[120px]", align: "center" },
|
||||
];
|
||||
|
||||
@@ -113,8 +114,29 @@ export default function EbomRegistPage() {
|
||||
// DataGrid 는 row.id 를 키로 사용 — backend 응답은 row.objid (lowercase) 이므로 매핑
|
||||
const gridRows = useMemo(() => rows.map((r) => ({ ...r, id: r.objid })), [rows]);
|
||||
|
||||
// ─── 하단 통계 ──────────────────────────────────────────────
|
||||
// BOM 건수(총·페이지) / 상태별 분포 (간이 — 상태 라벨별 카운트)
|
||||
const bomSummary = useMemo(() => {
|
||||
const pageCount = gridRows.length;
|
||||
const byStatus = new Map<string, number>();
|
||||
gridRows.forEach((r: any) => {
|
||||
const k = String(r.status_title ?? r.status ?? "-");
|
||||
byStatus.set(k, (byStatus.get(k) ?? 0) + 1);
|
||||
});
|
||||
const intFmt = (n: number) => n.toLocaleString();
|
||||
const stats: Array<{ label: string; value: string; suffix?: string }> = [
|
||||
{ label: "전체 건수", value: intFmt(total), suffix: "건" },
|
||||
{ label: "페이지 건수", value: intFmt(pageCount), suffix: "건" },
|
||||
];
|
||||
// 상태별 카운트(많이 노출되는 라벨만)
|
||||
Array.from(byStatus.entries()).slice(0, 4).forEach(([k, v]) => {
|
||||
stats.push({ label: `상태(${k})`, value: intFmt(v), suffix: "건" });
|
||||
});
|
||||
return stats;
|
||||
}, [gridRows, total]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-2">
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
onSearch={() => fetchList()}
|
||||
@@ -172,7 +194,6 @@ export default function EbomRegistPage() {
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={gridRows}
|
||||
@@ -183,8 +204,29 @@ export default function EbomRegistPage() {
|
||||
onCheckedChange={setCheckedIds}
|
||||
emptyMessage="등록된 E-BOM이 없습니다."
|
||||
gridId="development-ebom-regist"
|
||||
showColumnSettings
|
||||
paginationStyle="range"
|
||||
pageSizeOptions={[10, 15, 20, 50, 100, 200, 500]}
|
||||
serverPaging
|
||||
serverPage={filter.page ?? 1}
|
||||
serverPageSize={filter.page_size ?? 50}
|
||||
serverTotalItems={total}
|
||||
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
|
||||
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
|
||||
summaryStats={bomSummary}
|
||||
systemColumnKeys={["dept_user_name", "reg_date", "deploy_date", "revision", "status_title"]}
|
||||
onRefresh={() => fetchList()}
|
||||
onDownload={() => {
|
||||
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
||||
const exportRows = gridRows.map((r: any) => {
|
||||
const out: Record<string, any> = {};
|
||||
BASE_GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "E-BOM_등록.xlsx", "E-BOM_등록");
|
||||
}}
|
||||
showChart
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BomReportStatusDialog
|
||||
open={statusOpen}
|
||||
|
||||
@@ -23,6 +23,7 @@ const LEVEL_OPTIONS: SmartSelectOption[] = [
|
||||
{ code: "5", label: "5레벨" },
|
||||
];
|
||||
import { PartDetailDialog } from "@/components/development/PartDetailDialog";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
type Direction = "ascending" | "descending";
|
||||
|
||||
@@ -170,21 +171,38 @@ export default function EbomSearchPage() {
|
||||
const ancestors = ancestorsByChildId.get(String(r.child_objid)) ?? [];
|
||||
return !ancestors.some((a) => collapsedChildIds.has(a));
|
||||
})
|
||||
.map((r) => {
|
||||
.map((r, i) => {
|
||||
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 ? "*" : "";
|
||||
for (let j = 1; j <= Math.max(1, maxLevel); j++) {
|
||||
expanded[`__lev_${j}`] = lev === j ? "*" : "";
|
||||
}
|
||||
const childId = r.child_objid ? String(r.child_objid) : "";
|
||||
const hasChild = childId && hasChildSet.has(childId);
|
||||
expanded.__toggle = hasChild ? (collapsedChildIds.has(childId) ? "+" : "−") : "";
|
||||
// DataGrid row key — child_objid 우선, 없으면 인덱스 fallback
|
||||
expanded.id = childId || `bom-${i}`;
|
||||
return expanded;
|
||||
});
|
||||
}, [rows, maxLevel, hasChildSet, ancestorsByChildId, collapsedChildIds]);
|
||||
|
||||
// ─── 하단 통계 ──────────────────────────────────────────────
|
||||
const treeSummary = useMemo(() => {
|
||||
const intFmt = (n: number) => n.toLocaleString();
|
||||
const qtySum = gridData.reduce((acc, r: any) => acc + Number(r.qty || 0), 0);
|
||||
const pQtySum = gridData.reduce((acc, r: any) => acc + Number(r.p_qty || 0), 0);
|
||||
return [
|
||||
{ label: "모드", value: direction === "ascending" ? "정전개" : "역전개" },
|
||||
{ label: "표시 행", value: intFmt(gridData.length), suffix: "행" },
|
||||
{ label: "원본 행", value: intFmt(rows.length), suffix: "행" },
|
||||
{ label: "MAX_LEVEL", value: String(maxLevel) },
|
||||
{ label: "수량 합계", value: intFmt(qtySum) },
|
||||
{ label: "항목수량 합계", value: intFmt(pQtySum) },
|
||||
];
|
||||
}, [gridData, rows.length, maxLevel, direction]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-2">
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
onReset={() => { setFilter(EMPTY_FILTER); setRows([]); setMaxLevel(0); }}
|
||||
actions={
|
||||
@@ -257,7 +275,6 @@ export default function EbomSearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={gridData}
|
||||
@@ -265,8 +282,15 @@ export default function EbomSearchPage() {
|
||||
showRowNumber
|
||||
emptyMessage="조건에 맞는 BOM이 없습니다."
|
||||
gridId={`development-ebom-search-${direction}`}
|
||||
showColumnSettings
|
||||
paginationStyle="range"
|
||||
pageSizeOptions={[20, 50, 100, 200, 500]}
|
||||
defaultPageSize={100}
|
||||
summaryStats={treeSummary}
|
||||
onRefresh={() => runQuery(direction)}
|
||||
onDownload={() => downloadExcel(direction)}
|
||||
showChart
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PartDetailDialog
|
||||
open={partDetailOpen}
|
||||
|
||||
@@ -20,39 +20,40 @@ import { PartDetailDialog } from "@/components/development/PartDetailDialog";
|
||||
import { PartExcelImportDialog } from "@/components/development/PartExcelImportDialog";
|
||||
import { PartDrawingMultiUploadButton } from "@/components/development/PartDrawingMultiUploadButton";
|
||||
import { DevPartSelect } from "@/components/development/DevPartSelect";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
// wace 23셀 + 부속 (PARENT_PART_INFO/PARTNER_TITLE/Q_QTY)
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "part_no", label: "품번", width: "w-[140px]", frozen: true },
|
||||
{ key: "part_name", label: "품명", minWidth: "min-w-[220px]" },
|
||||
{ key: "cu01_cnt", label: "3D", width: "w-[70px]", align: "center", renderType: "folder" },
|
||||
{ key: "cu02_cnt", label: "2D", width: "w-[70px]", align: "center", renderType: "folder" },
|
||||
{ key: "cu03_cnt", label: "PDF", width: "w-[70px]", align: "center", renderType: "folder" },
|
||||
{ key: "cu01_cnt", label: "3D", width: "w-[80px]", align: "center", renderType: "folder" },
|
||||
{ key: "cu02_cnt", label: "2D", width: "w-[80px]", align: "center", renderType: "folder" },
|
||||
{ key: "cu03_cnt", label: "PDF", width: "w-[80px]", 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: "heat_treatment_hardness", label: "열처리경도", width: "w-[125px]" },
|
||||
{ key: "heat_treatment_method", label: "열처리방법", width: "w-[125px]" },
|
||||
{ key: "surface_treatment", label: "표면처리", width: "w-[115px]" },
|
||||
{ key: "maker", label: "메이커", width: "w-[100px]" },
|
||||
{ key: "part_type_title", label: "범주", width: "w-[100px]" },
|
||||
{ key: "spec", label: "규격", width: "w-[140px]" },
|
||||
{ key: "acctfg_nm", label: "계정구분", width: "w-[80px]", align: "center" },
|
||||
{ key: "odrfg_nm", label: "조달구분", width: "w-[80px]", align: "center" },
|
||||
{ key: "unit_dc_nm", label: "재고단위", width: "w-[80px]", align: "center" },
|
||||
{ key: "unitmang_dc_nm", label: "관리단위", width: "w-[80px]", align: "center" },
|
||||
{ key: "unitchng_nb", label: "환산수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "lot_fg_nm", label: "LOT구분", width: "w-[80px]", align: "center" },
|
||||
{ key: "use_yn_nm", label: "사용여부", width: "w-[80px]", align: "center" },
|
||||
{ key: "qc_fg_nm", label: "검사여부", width: "w-[80px]", align: "center" },
|
||||
{ key: "setitem_fg_nm", label: "SET품여부", width: "w-[90px]", align: "center" },
|
||||
{ key: "req_fg_nm", label: "의뢰여부", width: "w-[80px]", align: "center" },
|
||||
{ key: "unit_length", label: "개당길이", width: "w-[90px]", align: "right" },
|
||||
{ key: "unit_qty", label: "개당수량", width: "w-[90px]", align: "right" },
|
||||
{ key: "acctfg_nm", label: "계정구분", width: "w-[115px]", align: "center" },
|
||||
{ key: "odrfg_nm", label: "조달구분", width: "w-[115px]", align: "center" },
|
||||
{ key: "unit_dc_nm", label: "재고단위", width: "w-[115px]", align: "center" },
|
||||
{ key: "unitmang_dc_nm", label: "관리단위", width: "w-[115px]", align: "center" },
|
||||
{ key: "unitchng_nb", label: "환산수량", width: "w-[115px]", align: "right", formatNumber: true },
|
||||
{ key: "lot_fg_nm", label: "LOT구분", width: "w-[115px]", align: "center" },
|
||||
{ key: "use_yn_nm", label: "사용여부", width: "w-[115px]", align: "center" },
|
||||
{ key: "qc_fg_nm", label: "검사여부", width: "w-[115px]", align: "center" },
|
||||
{ key: "setitem_fg_nm", label: "SET품여부", width: "w-[120px]", align: "center" },
|
||||
{ key: "req_fg_nm", label: "의뢰여부", width: "w-[115px]", align: "center" },
|
||||
{ key: "unit_length", label: "개당길이", width: "w-[115px]", align: "right" },
|
||||
{ key: "unit_qty", label: "개당수량", width: "w-[115px]", align: "right" },
|
||||
// M1 부속
|
||||
{ key: "partner_title", label: "공급업체(시퀀스)", minWidth: "min-w-[180px]" },
|
||||
{ key: "parent_part_info", label: "상위 품번", width: "w-[120px]" },
|
||||
{ key: "q_qty", label: "수량(BOM)", width: "w-[90px]", align: "right" },
|
||||
{ key: "revision", label: "REV", width: "w-[60px]", align: "center" },
|
||||
{ key: "status", label: "상태", width: "w-[80px]", align: "center" },
|
||||
{ key: "q_qty", label: "수량(BOM)", width: "w-[115px]", align: "right" },
|
||||
{ key: "revision", label: "REV", width: "w-[80px]", align: "center" },
|
||||
{ key: "status", label: "상태", width: "w-[95px]", align: "center" },
|
||||
];
|
||||
|
||||
const EMPTY_FILTER: PartListFilter = {
|
||||
@@ -105,6 +106,23 @@ export default function PartRegistPage() {
|
||||
// DataGrid 는 row.id 를 키로 사용 — backend 응답은 row.objid 이므로 매핑
|
||||
const gridRows = useMemo(() => rows.map((r) => ({ ...r, id: r.objid })), [rows]);
|
||||
|
||||
// ─── 하단 통계 ──────────────────────────────────────────────
|
||||
// PART 건수(총·페이지) / 환산수량·개당수량·BOM 수량 합계
|
||||
const partSummary = useMemo(() => {
|
||||
const pageCount = gridRows.length;
|
||||
const unitChng = gridRows.reduce((acc, r: any) => acc + Number(r.unitchng_nb || 0), 0);
|
||||
const unitQty = gridRows.reduce((acc, r: any) => acc + Number(r.unit_qty || 0), 0);
|
||||
const qQty = gridRows.reduce((acc, r: any) => acc + Number(r.q_qty || 0), 0);
|
||||
const intFmt = (n: number) => n.toLocaleString();
|
||||
return [
|
||||
{ label: "전체 건수", value: intFmt(total), suffix: "건" },
|
||||
{ label: "페이지 건수", value: intFmt(pageCount), suffix: "건" },
|
||||
{ label: "환산수량 합계", value: intFmt(unitChng) },
|
||||
{ label: "개당수량 합계", value: intFmt(unitQty) },
|
||||
{ label: "BOM 수량 합계", value: intFmt(qQty) },
|
||||
];
|
||||
}, [gridRows, total]);
|
||||
|
||||
// 등록
|
||||
const handleCreate = () => {
|
||||
setFormMode("create");
|
||||
@@ -155,7 +173,7 @@ export default function PartRegistPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-2">
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
onSearch={() => fetchList()}
|
||||
@@ -209,7 +227,6 @@ export default function PartRegistPage() {
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={gridRows}
|
||||
@@ -220,8 +237,29 @@ export default function PartRegistPage() {
|
||||
onCheckedChange={setCheckedIds}
|
||||
emptyMessage="조건에 맞는 PART가 없습니다."
|
||||
gridId="development-part-regist"
|
||||
showColumnSettings
|
||||
paginationStyle="range"
|
||||
pageSizeOptions={[10, 15, 20, 50, 100, 200, 500]}
|
||||
serverPaging
|
||||
serverPage={filter.page ?? 1}
|
||||
serverPageSize={filter.page_size ?? 50}
|
||||
serverTotalItems={total}
|
||||
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
|
||||
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
|
||||
summaryStats={partSummary}
|
||||
systemColumnKeys={["revision", "status"]}
|
||||
onRefresh={() => fetchList()}
|
||||
onDownload={() => {
|
||||
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
||||
const exportRows = gridRows.map((r: any) => {
|
||||
const out: Record<string, any> = {};
|
||||
GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "PART_등록.xlsx", "PART_등록");
|
||||
}}
|
||||
showChart
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PartFormDialog
|
||||
open={formOpen}
|
||||
|
||||
@@ -20,35 +20,36 @@ import { PartDetailDialog } from "@/components/development/PartDetailDialog";
|
||||
import { PartExcelImportDialog } from "@/components/development/PartExcelImportDialog";
|
||||
import { PartDrawingMultiUploadButton } from "@/components/development/PartDrawingMultiUploadButton";
|
||||
import { DevPartSelect } from "@/components/development/DevPartSelect";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "part_no", label: "품번", width: "w-[140px]", frozen: true },
|
||||
{ key: "part_name", label: "품명", minWidth: "min-w-[220px]" },
|
||||
{ key: "cu01_cnt", label: "3D", width: "w-[70px]", align: "center", renderType: "folder" },
|
||||
{ key: "cu02_cnt", label: "2D", width: "w-[70px]", align: "center", renderType: "folder" },
|
||||
{ key: "cu03_cnt", label: "PDF", width: "w-[70px]", align: "center", renderType: "folder" },
|
||||
{ key: "cu01_cnt", label: "3D", width: "w-[80px]", align: "center", renderType: "folder" },
|
||||
{ key: "cu02_cnt", label: "2D", width: "w-[80px]", align: "center", renderType: "folder" },
|
||||
{ key: "cu03_cnt", label: "PDF", width: "w-[80px]", 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: "heat_treatment_hardness", label: "열처리경도", width: "w-[125px]" },
|
||||
{ key: "heat_treatment_method", label: "열처리방법", width: "w-[125px]" },
|
||||
{ key: "surface_treatment", label: "표면처리", width: "w-[115px]" },
|
||||
{ key: "maker", label: "메이커", width: "w-[100px]" },
|
||||
{ key: "part_type_title", label: "범주", width: "w-[100px]" },
|
||||
{ key: "spec", label: "규격", width: "w-[140px]" },
|
||||
{ key: "acctfg_nm", label: "계정구분", width: "w-[80px]", align: "center" },
|
||||
{ key: "odrfg_nm", label: "조달구분", width: "w-[80px]", align: "center" },
|
||||
{ key: "unit_dc_nm", label: "재고단위", width: "w-[80px]", align: "center" },
|
||||
{ key: "unitmang_dc_nm", label: "관리단위", width: "w-[80px]", align: "center" },
|
||||
{ key: "unitchng_nb", label: "환산수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "lot_fg_nm", label: "LOT구분", width: "w-[80px]", align: "center" },
|
||||
{ key: "use_yn_nm", label: "사용여부", width: "w-[80px]", align: "center" },
|
||||
{ key: "qc_fg_nm", label: "검사여부", width: "w-[80px]", align: "center" },
|
||||
{ key: "setitem_fg_nm", label: "SET품여부", width: "w-[90px]", align: "center" },
|
||||
{ key: "req_fg_nm", label: "의뢰여부", width: "w-[80px]", align: "center" },
|
||||
{ key: "unit_length", label: "개당길이", width: "w-[90px]", align: "right" },
|
||||
{ key: "unit_qty", label: "개당수량", width: "w-[90px]", align: "right" },
|
||||
{ key: "acctfg_nm", label: "계정구분", width: "w-[115px]", align: "center" },
|
||||
{ key: "odrfg_nm", label: "조달구분", width: "w-[115px]", align: "center" },
|
||||
{ key: "unit_dc_nm", label: "재고단위", width: "w-[115px]", align: "center" },
|
||||
{ key: "unitmang_dc_nm", label: "관리단위", width: "w-[115px]", align: "center" },
|
||||
{ key: "unitchng_nb", label: "환산수량", width: "w-[115px]", align: "right", formatNumber: true },
|
||||
{ key: "lot_fg_nm", label: "LOT구분", width: "w-[115px]", align: "center" },
|
||||
{ key: "use_yn_nm", label: "사용여부", width: "w-[115px]", align: "center" },
|
||||
{ key: "qc_fg_nm", label: "검사여부", width: "w-[115px]", align: "center" },
|
||||
{ key: "setitem_fg_nm", label: "SET품여부", width: "w-[120px]", align: "center" },
|
||||
{ key: "req_fg_nm", label: "의뢰여부", width: "w-[115px]", align: "center" },
|
||||
{ key: "unit_length", label: "개당길이", width: "w-[115px]", align: "right" },
|
||||
{ key: "unit_qty", label: "개당수량", width: "w-[115px]", align: "right" },
|
||||
// M2 추가
|
||||
{ key: "bom_qty", label: "BOM 수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "revision", label: "REV", width: "w-[60px]", align: "center" },
|
||||
{ key: "bom_qty", label: "BOM 수량", width: "w-[115px]", align: "right", formatNumber: true },
|
||||
{ key: "revision", label: "REV", width: "w-[80px]", align: "center" },
|
||||
{ key: "eo_no", label: "EO_NO", width: "w-[120px]" },
|
||||
];
|
||||
|
||||
@@ -100,6 +101,23 @@ export default function PartSearchPage() {
|
||||
// DataGrid 는 row.id 를 키로 사용 — backend 응답은 row.objid 이므로 매핑
|
||||
const gridRows = useMemo(() => rows.map((r) => ({ ...r, id: r.objid })), [rows]);
|
||||
|
||||
// ─── 하단 통계 ──────────────────────────────────────────────
|
||||
// PART 건수(총·페이지) / BOM 수량·환산수량·개당수량 합계
|
||||
const partSummary = useMemo(() => {
|
||||
const pageCount = gridRows.length;
|
||||
const bomQty = gridRows.reduce((acc, r: any) => acc + Number(r.bom_qty || 0), 0);
|
||||
const unitChng = gridRows.reduce((acc, r: any) => acc + Number(r.unitchng_nb || 0), 0);
|
||||
const unitQty = gridRows.reduce((acc, r: any) => acc + Number(r.unit_qty || 0), 0);
|
||||
const intFmt = (n: number) => n.toLocaleString();
|
||||
return [
|
||||
{ label: "전체 건수", value: intFmt(total), suffix: "건" },
|
||||
{ label: "페이지 건수", value: intFmt(pageCount), suffix: "건" },
|
||||
{ label: "BOM 수량 합계", value: intFmt(bomQty) },
|
||||
{ label: "환산수량 합계", value: intFmt(unitChng) },
|
||||
{ label: "개당수량 합계", value: intFmt(unitQty) },
|
||||
];
|
||||
}, [gridRows, total]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setFormMode("create"); setFormObjid(null); setFormOpen(true);
|
||||
};
|
||||
@@ -124,7 +142,7 @@ export default function PartSearchPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-2">
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
onSearch={() => fetchList()}
|
||||
@@ -171,7 +189,6 @@ export default function PartSearchPage() {
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={gridRows}
|
||||
@@ -182,8 +199,29 @@ export default function PartSearchPage() {
|
||||
onCheckedChange={setCheckedIds}
|
||||
emptyMessage="조건에 맞는 PART가 없습니다."
|
||||
gridId="development-part-search"
|
||||
showColumnSettings
|
||||
paginationStyle="range"
|
||||
pageSizeOptions={[10, 15, 20, 50, 100, 200, 500]}
|
||||
serverPaging
|
||||
serverPage={filter.page ?? 1}
|
||||
serverPageSize={filter.page_size ?? 50}
|
||||
serverTotalItems={total}
|
||||
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
|
||||
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
|
||||
summaryStats={partSummary}
|
||||
systemColumnKeys={["revision", "eo_no"]}
|
||||
onRefresh={() => fetchList()}
|
||||
onDownload={() => {
|
||||
if (gridRows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
||||
const exportRows = gridRows.map((r: any) => {
|
||||
const out: Record<string, any> = {};
|
||||
GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "PART_조회.xlsx", "PART_조회");
|
||||
}}
|
||||
showChart
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PartFormDialog
|
||||
open={formOpen}
|
||||
|
||||
@@ -51,27 +51,10 @@ const EMPTY_FILTER: MbomListFilter = {
|
||||
search_req_del_date_from: "",
|
||||
search_req_del_date_to: "",
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
page_size: 50, // DataGrid 서버 페이지네이션 — 페이지 변경/사이즈 변경 시 부모가 직접 재요청
|
||||
};
|
||||
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
|
||||
{ key: "category_name", label: "주문유형", width: "w-[100px]", align: "center" },
|
||||
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
||||
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
||||
{ key: "receipt_date", label: "접수일", width: "w-[100px]", align: "center" },
|
||||
{ key: "writer_name", label: "작성자", width: "w-[90px]", align: "center" },
|
||||
{ key: "customer_name", label: "고객사", minWidth: "min-w-[160px]" },
|
||||
{ key: "paid_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
|
||||
{ key: "part_no", label: "품번", width: "w-[150px]" },
|
||||
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
|
||||
{ key: "serial_no", label: "S/N", width: "w-[110px]", align: "center" },
|
||||
{ key: "quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "req_del_date", label: "요청납기", width: "w-[100px]", align: "center" },
|
||||
{ key: "customer_request", label: "고객사요청사항", minWidth: "min-w-[200px]" },
|
||||
{ key: "mbom_status", label: "M-BOM", width: "w-[80px]", align: "center" },
|
||||
{ key: "mbom_regdate", label: "최종저장일", width: "w-[100px]", align: "center" },
|
||||
];
|
||||
// 그리드 컬럼은 useMemo 로 컴포넌트 내부에서 생성 — onClick(openDialog) 캡처 위해.
|
||||
|
||||
export default function MbomMgmtPage() {
|
||||
const [rows, setRows] = useState<MbomRow[]>([]);
|
||||
@@ -124,11 +107,43 @@ export default function MbomMgmtPage() {
|
||||
}, []);
|
||||
|
||||
// DataGrid 키 부여 (objid + part_no 조합 — 같은 프로젝트 다중 행 unique)
|
||||
// mbom_has: folder 컬럼이 숫자 > 0 일 때 파랑. mbom_header_objid 가 있으면 저장된 M-BOM.
|
||||
const gridRows = useMemo(
|
||||
() => rows.map((r, i) => ({ ...r, id: `${r.objid}__${r.part_no ?? ""}__${i}` })),
|
||||
() => rows.map((r, i) => ({
|
||||
...r,
|
||||
id: `${r.objid}__${r.part_no ?? ""}__${i}`,
|
||||
mbom_has: r.mbom_header_objid ? 1 : 0,
|
||||
})),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const openMbomDialog = useCallback((row: any) => {
|
||||
if (!row?.objid) return;
|
||||
setDialogObjid(String(row.objid));
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const GRID_COLUMNS: DataGridColumn[] = useMemo(() => ([
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
|
||||
{ key: "category_name", label: "주문유형", width: "w-[100px]", align: "center" },
|
||||
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
||||
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
||||
{ key: "receipt_date", label: "접수일", width: "w-[100px]", align: "center" },
|
||||
{ key: "writer_name", label: "작성자", width: "w-[90px]", align: "center" },
|
||||
{ key: "customer_name", label: "고객사", minWidth: "min-w-[160px]" },
|
||||
{ key: "paid_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
|
||||
{ key: "part_no", label: "품번", width: "w-[150px]" },
|
||||
{ key: "part_name", label: "품명", minWidth: "min-w-[180px]" },
|
||||
{ key: "serial_no", label: "S/N", width: "w-[110px]", align: "center" },
|
||||
{ key: "quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "req_del_date", label: "요청납기", width: "w-[100px]", align: "center" },
|
||||
{ key: "customer_request", label: "고객사요청사항", minWidth: "min-w-[200px]" },
|
||||
// M-BOM 컬럼 — 폴더 아이콘 (저장됨=파랑, 미저장=흰색). 클릭 시 본 편집 다이얼로그.
|
||||
{ key: "mbom_has", label: "M-BOM", width: "w-[80px]", align: "center",
|
||||
renderType: "folder", onClick: openMbomDialog },
|
||||
{ key: "mbom_regdate", label: "최종저장일", width: "w-[100px]", align: "center" },
|
||||
]), [openMbomDialog]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setFilter((f) => ({ ...f, page: 1 }));
|
||||
fetchList({ page: 1 });
|
||||
@@ -227,6 +242,14 @@ export default function MbomMgmtPage() {
|
||||
showRowNumber
|
||||
emptyMessage="조건에 맞는 프로젝트가 없습니다."
|
||||
gridId="production-mbom-mgmt"
|
||||
pageSizeOptions={[25, 50, 100, 200, 500]}
|
||||
paginationStyle="range"
|
||||
serverPaging
|
||||
serverPage={filter.page ?? 1}
|
||||
serverPageSize={filter.page_size ?? 50}
|
||||
serverTotalItems={total}
|
||||
onPageChange={(p) => { setFilter(f => ({ ...f, page: p })); fetchList({ page: p }); }}
|
||||
onPageSizeChange={(n) => { setFilter(f => ({ ...f, page: 1, page_size: n })); fetchList({ page: 1, page_size: n }); }}
|
||||
onRowDoubleClick={(row: any) => {
|
||||
if (!row?.objid) return;
|
||||
setDialogObjid(String(row.objid));
|
||||
@@ -239,6 +262,7 @@ export default function MbomMgmtPage() {
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
projectObjid={dialogObjid}
|
||||
onSaved={fetchList}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { PageHeader } from "@/components/common/PageHeader";
|
||||
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
|
||||
import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog";
|
||||
import { projectMgmtApi, ProgressListFilter, ProgressRow } from "@/lib/api/projectMgmt";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
// 진행관리 row → 정규화된 ProjectInfoData 매핑
|
||||
const toProjectInfo = (r: ProgressRow): ProjectInfoData => ({
|
||||
@@ -45,32 +46,32 @@ const toProjectInfo = (r: ProgressRow): ProjectInfoData => ({
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[160px]", frozen: true },
|
||||
// 프로젝트정보 그룹
|
||||
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "product_name", label: "제품구분", width: "w-[100px]", align: "left" },
|
||||
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
||||
{ key: "reg_date", label: "접수일", width: "w-[110px]", align: "center" },
|
||||
{ key: "category_name", label: "주문유형", width: "w-[115px]", align: "center" },
|
||||
{ key: "product_name", label: "제품구분", width: "w-[115px]", align: "left" },
|
||||
{ key: "area_name", label: "국내/해외", width: "w-[115px]", align: "center" },
|
||||
{ key: "reg_date", label: "접수일", width: "w-[115px]", align: "center" },
|
||||
{ key: "customer_name", label: "고객사", width: "w-[140px]" },
|
||||
{ key: "free_of_charge", label: "유/무상", width: "w-[80px]", align: "center" },
|
||||
{ key: "free_of_charge", label: "유/무상", width: "w-[100px]", align: "center" },
|
||||
{ key: "product_item_code", label: "품번", width: "w-[150px]" },
|
||||
{ key: "product_item_name", label: "품명", width: "w-[180px]" },
|
||||
{ key: "serial_no", label: "S/N", width: "w-[150px]" },
|
||||
{ key: "contract_qty", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "req_del_date", label: "요청납기", width: "w-[110px]", align: "center" },
|
||||
{ key: "contract_qty", label: "수주수량", width: "w-[115px]", align: "right", formatNumber: true },
|
||||
{ key: "req_del_date", label: "요청납기", width: "w-[115px]", align: "center" },
|
||||
// 설계
|
||||
{ key: "ebom_status", label: "E-BOM", width: "w-[100px]", align: "center" },
|
||||
{ key: "ebom_status", label: "E-BOM", width: "w-[115px]", align: "center" },
|
||||
// 생산관리
|
||||
{ key: "mbom_status", label: "M-BOM", width: "w-[100px]", align: "center" },
|
||||
{ key: "mbom_status", label: "M-BOM", width: "w-[115px]", align: "center" },
|
||||
// 구매
|
||||
{ key: "order_date", label: "발주일", width: "w-[110px]", align: "center" },
|
||||
{ key: "receiving_rate", label: "입고율", width: "w-[90px]", align: "right" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[115px]", align: "center" },
|
||||
{ key: "receiving_rate", label: "입고율", width: "w-[100px]", align: "right" },
|
||||
// 생산
|
||||
{ key: "production_team_12", label: "제조1,2팀", width: "w-[100px]", align: "center" },
|
||||
{ key: "production_team_3", label: "제조3팀", width: "w-[100px]", align: "center" },
|
||||
{ key: "production_team_12", label: "제조1,2팀", width: "w-[120px]", align: "center" },
|
||||
{ key: "production_team_3", label: "제조3팀", width: "w-[115px]", align: "center" },
|
||||
// 장비
|
||||
{ key: "assembly", label: "조립", width: "w-[90px]", align: "center" },
|
||||
{ key: "verification", label: "검증", width: "w-[90px]", align: "center" },
|
||||
{ key: "assembly", label: "조립", width: "w-[100px]", align: "center" },
|
||||
{ key: "verification", label: "검증", width: "w-[100px]", align: "center" },
|
||||
// 출하
|
||||
{ key: "shipment_date", label: "출하일", width: "w-[110px]", align: "center" },
|
||||
{ key: "shipment_date", label: "출하일", width: "w-[115px]", align: "center" },
|
||||
];
|
||||
|
||||
const CATEGORY_GROUP = "0000167"; // 주문유형
|
||||
@@ -125,7 +126,8 @@ export default function ProjectProgressPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await projectMgmtApi.list(filter);
|
||||
setRows(data);
|
||||
// DataGrid row 키 — objid 없을 수 있어 인덱스 fallback (id 누락 시 모든 행이 selected 로 잡힘)
|
||||
setRows(data.map((r, i) => ({ ...r, id: r.objid ?? `prog-${i}` })) as any);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
} finally { setLoading(false); }
|
||||
@@ -145,8 +147,26 @@ export default function ProjectProgressPage() {
|
||||
setTimeout(() => fetchList(), 0);
|
||||
};
|
||||
|
||||
// ─── 하단 통계 ──────────────────────────────────────────────
|
||||
// 프로젝트 건수 / 수주수량 합계 / 입고율 평균
|
||||
const progressSummary = useMemo(() => {
|
||||
const count = rows.length;
|
||||
const qtySum = rows.reduce((acc, r) => acc + Number(r.contract_qty || 0), 0);
|
||||
// 입고율은 "85%" 같은 문자열 가능 → 숫자만 추출
|
||||
const rateNums = rows
|
||||
.map((r) => parseFloat(String((r as any).receiving_rate ?? "").replace(/[^0-9.]/g, "")))
|
||||
.filter((n) => !Number.isNaN(n));
|
||||
const rateAvg = rateNums.length === 0 ? 0 : rateNums.reduce((a, b) => a + b, 0) / rateNums.length;
|
||||
const intFmt = (n: number) => n.toLocaleString();
|
||||
return [
|
||||
{ label: "프로젝트 건수", value: intFmt(count), suffix: "건" },
|
||||
{ label: "수주수량 합계", value: intFmt(qtySum) },
|
||||
{ label: "입고율 평균", value: rateAvg.toFixed(1), suffix: "%" },
|
||||
];
|
||||
}, [rows]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-2 p-2">
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
onSearch={fetchList}
|
||||
@@ -237,7 +257,6 @@ export default function ProjectProgressPage() {
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
@@ -245,8 +264,22 @@ export default function ProjectProgressPage() {
|
||||
showRowNumber
|
||||
emptyMessage="조건에 맞는 프로젝트가 없습니다."
|
||||
gridId="project-progress-wbslist3"
|
||||
showColumnSettings
|
||||
paginationStyle="range"
|
||||
pageSizeOptions={[10, 15, 20, 50, 100]}
|
||||
summaryStats={progressSummary}
|
||||
onRefresh={fetchList}
|
||||
onDownload={() => {
|
||||
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
||||
const exportRows = rows.map((r) => {
|
||||
const out: Record<string, any> = {};
|
||||
GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "진행관리.xlsx", "진행관리");
|
||||
}}
|
||||
showChart
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProjectInfoDialog open={infoOpen} onOpenChange={setInfoOpen} data={infoData} />
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// 검색: 제품구분 단일
|
||||
// 등록/수정 통합 다이얼로그: WbsTemplateDialog (wace WBSExcelImportPopUp.jsp 1:1)
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -20,6 +20,7 @@ import { PageHeader } from "@/components/common/PageHeader";
|
||||
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
|
||||
import { wbsTemplateApi, TemplateRow } from "@/lib/api/wbsTemplate";
|
||||
import { WbsTemplateDialog } from "@/components/project/WbsTemplateDialog";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
const PRODUCT_GROUP = "0000001"; // 제품구분
|
||||
|
||||
@@ -29,12 +30,12 @@ const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{
|
||||
key: "wbs_task_cnt",
|
||||
label: "WBS",
|
||||
width: "w-[100px]",
|
||||
width: "w-[115px]",
|
||||
align: "center",
|
||||
renderType: "folder", // wace fnc_getFolderIcon
|
||||
},
|
||||
{ key: "writer_title", label: "등록자", width: "w-[180px]" },
|
||||
{ key: "reg_date_title", label: "등록일", width: "w-[130px]", align: "center" },
|
||||
{ key: "reg_date_title", label: "등록일", width: "w-[140px]", align: "center" },
|
||||
];
|
||||
|
||||
export default function WbsTemplatePage() {
|
||||
@@ -52,7 +53,8 @@ export default function WbsTemplatePage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await wbsTemplateApi.list(product || undefined);
|
||||
setRows(data);
|
||||
// DataGrid row 키 — objid 없을 수 있어 인덱스 fallback (id 누락 시 모든 행이 selected 로 잡힘)
|
||||
setRows(data.map((r, i) => ({ ...r, id: r.objid ?? `tpl-${i}` })) as any);
|
||||
setCheckedIds([]);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "조회 실패");
|
||||
@@ -110,8 +112,22 @@ export default function WbsTemplatePage() {
|
||||
c.key === "wbs_task_cnt" ? { ...c, onClick: handleOpenEdit } : c
|
||||
);
|
||||
|
||||
// ─── 하단 통계 ──────────────────────────────────────────────
|
||||
// 템플릿 건수 / WBS 작업 합계 / 평균 WBS 작업수
|
||||
const templateSummary = useMemo(() => {
|
||||
const count = rows.length;
|
||||
const taskSum = rows.reduce((acc, r) => acc + Number((r as any).wbs_task_cnt || 0), 0);
|
||||
const taskAvg = count === 0 ? 0 : taskSum / count;
|
||||
const intFmt = (n: number) => n.toLocaleString();
|
||||
return [
|
||||
{ label: "템플릿 건수", value: intFmt(count), suffix: "건" },
|
||||
{ label: "WBS 작업 합계", value: intFmt(taskSum) },
|
||||
{ label: "평균 WBS 작업수", value: taskAvg.toFixed(1) },
|
||||
];
|
||||
}, [rows]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-2 p-2">
|
||||
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
|
||||
<PageHeader
|
||||
loading={loading}
|
||||
onSearch={handleSearch}
|
||||
@@ -137,7 +153,6 @@ export default function WbsTemplatePage() {
|
||||
</CompactFilterField>
|
||||
</CompactFilterBar>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
<DataGrid
|
||||
columns={columns}
|
||||
data={rows}
|
||||
@@ -148,8 +163,23 @@ export default function WbsTemplatePage() {
|
||||
onCheckedChange={setCheckedIds}
|
||||
emptyMessage="등록된 WBS 템플릿이 없습니다."
|
||||
gridId="project-wbs-template"
|
||||
showColumnSettings
|
||||
paginationStyle="range"
|
||||
pageSizeOptions={[10, 15, 20, 50, 100]}
|
||||
summaryStats={templateSummary}
|
||||
systemColumnKeys={["writer_title", "reg_date_title"]}
|
||||
onRefresh={() => fetchList(filterProduct)}
|
||||
onDownload={() => {
|
||||
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
||||
const exportRows = rows.map((r) => {
|
||||
const out: Record<string, any> = {};
|
||||
GRID_COLUMNS.forEach((col) => { out[col.label] = (r as any)[col.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "WBS_템플릿.xlsx", "WBS_템플릿");
|
||||
}}
|
||||
showChart
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 통합 팝업 */}
|
||||
<WbsTemplateDialog
|
||||
|
||||
@@ -26,30 +26,31 @@ import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog"
|
||||
import { AttachmentDialog } from "@/components/common/AttachmentDialog";
|
||||
import { EstimateMailDialog } from "@/components/sales/EstimateMailDialog";
|
||||
import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
// ─── 컬럼 ─────────────────────────────────────────────────────
|
||||
|
||||
// wace_plm 원본 견적관리 그리드 컬럼 순서를 그대로 따름
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "contract_no", label: "영업번호", width: "w-[125px]", frozen: true },
|
||||
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "category_name", label: "주문유형", width: "w-[115px]", align: "center" },
|
||||
{ key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" },
|
||||
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" },
|
||||
{ key: "customer_name", label: "고객사", width: "w-[150px]" },
|
||||
{ key: "item_summary", label: "품명", width: "w-[200px]" },
|
||||
{ key: "estimate_quantity", label: "견적수량", width: "w-[80px]", align: "right", formatNumber: true },
|
||||
{ key: "paid_type_name", label: "유/무상", width: "w-[70px]", align: "center" },
|
||||
{ key: "est_total_amount", label: "공급가액", width: "w-[110px]", formatMoney: true },
|
||||
{ key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[140px]", formatMoney: true },
|
||||
{ key: "est_status", label: "견적현황", width: "w-[80px]", align: "center", renderType: "folder" },
|
||||
{ key: "add_est_cnt", label: "추가견적", width: "w-[80px]", align: "center", renderType: "clip" },
|
||||
{ key: "appr_status", label: "결재상태", width: "w-[90px]", align: "center" },
|
||||
{ key: "mail_send_status_label", label: "메일발송", width: "w-[110px]", align: "center" },
|
||||
{ key: "contract_currency_name", label: "환종", width: "w-[70px]", align: "center" },
|
||||
{ key: "exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true },
|
||||
{ key: "estimate_quantity", label: "견적수량", width: "w-[115px]", align: "right", formatNumber: true },
|
||||
{ key: "paid_type_name", label: "유/무상", width: "w-[100px]", align: "center" },
|
||||
{ key: "est_total_amount", label: "공급가액", width: "w-[130px]", formatMoney: true },
|
||||
{ key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[180px]", formatMoney: true },
|
||||
{ key: "est_status", label: "견적현황", width: "w-[115px]", align: "center", renderType: "folder" },
|
||||
{ key: "add_est_cnt", label: "추가견적", width: "w-[115px]", align: "center", renderType: "clip" },
|
||||
{ key: "appr_status", label: "결재상태", width: "w-[115px]", align: "center" },
|
||||
{ key: "mail_send_status_label", label: "메일발송", width: "w-[125px]", align: "center" },
|
||||
{ key: "contract_currency_name", label: "환종", width: "w-[95px]", align: "center" },
|
||||
{ key: "exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true },
|
||||
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
|
||||
{ key: "part_no", label: "품번", width: "w-[120px]" },
|
||||
{ key: "writer_name", label: "작성자", width: "w-[100px]" },
|
||||
{ key: "writer_name", label: "작성자", width: "w-[115px]" },
|
||||
/* wace estimateList_new.jsp 494~502 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
|
||||
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
||||
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
||||
@@ -251,6 +252,23 @@ export default function SalesEstimatePage() {
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
// ─── 하단 통계 ──────────────────────────────────────────────
|
||||
// 견적 건수 / 견적수량 합계 / 공급가액 합계 / 원화환산공급가액 합계
|
||||
const estimateSummary = useMemo(() => {
|
||||
const count = rows.length;
|
||||
const qtySum = rows.reduce((acc, r) => acc + Number(r.estimate_quantity || 0), 0);
|
||||
const amtSum = rows.reduce((acc, r) => acc + Number(r.est_total_amount || 0), 0);
|
||||
const krwSum = rows.reduce((acc, r) => acc + Number(r.est_total_amount_krw || 0), 0);
|
||||
const money = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const intFmt = (n: number) => n.toLocaleString();
|
||||
return [
|
||||
{ label: "견적 건수", value: intFmt(count), suffix: "건" },
|
||||
{ label: "견적수량 합계", value: intFmt(qtySum) },
|
||||
{ label: "공급가액 합계", value: money(amtSum) },
|
||||
{ label: "원화환산공급가액 합계", value: money(krwSum) },
|
||||
];
|
||||
}, [rows]);
|
||||
|
||||
// ─── 다이얼로그 열기 ────────────────────────────────────────
|
||||
|
||||
const openCreate = async () => {
|
||||
@@ -567,6 +585,7 @@ export default function SalesEstimatePage() {
|
||||
|
||||
{/* 그리드 — 첫 컬럼 체크박스 (행 아무 셀 클릭으로 단일 선택, 클립/폴더 등 팝업 컬럼은 stopPropagation으로 제외) */}
|
||||
<DataGrid
|
||||
gridId="sales-estimate"
|
||||
columns={gridColumns}
|
||||
data={rows}
|
||||
showCheckbox
|
||||
@@ -583,6 +602,22 @@ export default function SalesEstimatePage() {
|
||||
onRowDoubleClick={(row) => { setSelected(row); openEdit(); }}
|
||||
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
|
||||
loading={loading}
|
||||
showColumnSettings
|
||||
paginationStyle="range"
|
||||
pageSizeOptions={[10, 15, 20, 50, 100]}
|
||||
summaryStats={estimateSummary}
|
||||
systemColumnKeys={["writer_name"]}
|
||||
onRefresh={fetchList}
|
||||
onDownload={() => {
|
||||
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
||||
const exportRows = rows.map((r) => {
|
||||
const out: Record<string, any> = {};
|
||||
GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "견적관리.xlsx", "견적관리");
|
||||
}}
|
||||
showChart
|
||||
/>
|
||||
|
||||
{/* 등록/수정 Dialog */}
|
||||
|
||||
@@ -26,33 +26,34 @@ import { AttachmentDialog } from "@/components/common/AttachmentDialog";
|
||||
import { OrderFormViewDialog } from "@/components/sales/OrderFormViewDialog";
|
||||
import { OrderRegistDialog } from "@/components/sales/OrderRegistDialog";
|
||||
import { salesOrderMgmtApi, OrderRow, OrderBody, OrderItem } from "@/lib/api/salesOrderMgmt";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
// wace_plm orderMgmtList.jsp 컬럼 순서/라벨에 맞춤
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "contract_no", label: "영업번호", width: "w-[125px]", frozen: true },
|
||||
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[120px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[130px]" },
|
||||
{ key: "category_name", label: "주문유형", width: "w-[115px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[115px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[140px]" },
|
||||
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" },
|
||||
{ key: "customer_name", label: "고객사", width: "w-[160px]" },
|
||||
{ key: "item_summary", label: "품명", width: "w-[200px]" },
|
||||
{ key: "order_quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "cancel_qty_sum", label: "수주취소", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "paid_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
|
||||
{ key: "contract_result_name", label: "수주상태", width: "w-[100px]", align: "center" },
|
||||
{ key: "order_supply_price_sum", label: "공급가액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "order_vat_sum", label: "부가세", width: "w-[100px]", formatMoney: true },
|
||||
{ key: "order_quantity", label: "수주수량", width: "w-[115px]", align: "right", formatNumber: true },
|
||||
{ key: "cancel_qty_sum", label: "수주취소", width: "w-[115px]", align: "right", formatNumber: true },
|
||||
{ key: "paid_type_name", label: "유/무상", width: "w-[100px]", align: "center" },
|
||||
{ key: "contract_result_name", label: "수주상태", width: "w-[115px]", align: "center" },
|
||||
{ key: "order_supply_price_sum", label: "공급가액", width: "w-[130px]", formatMoney: true },
|
||||
{ key: "order_vat_sum", label: "부가세", width: "w-[115px]", formatMoney: true },
|
||||
{ key: "order_total_amount_sum", label: "총액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "order_total_amount_krw", label: "원화총액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", renderType: "clip" },
|
||||
{ key: "has_order_data", label: "주문서", width: "w-[80px]", align: "center", renderType: "folder" },
|
||||
{ key: "order_total_amount_krw", label: "원화총액", width: "w-[130px]", formatMoney: true },
|
||||
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[115px]", align: "center", renderType: "clip" },
|
||||
{ key: "has_order_data", label: "주문서", width: "w-[100px]", align: "center", renderType: "folder" },
|
||||
{ key: "customer_request", label: "고객사요청사항", width: "w-[180px]" },
|
||||
{ key: "order_appr_status", label: "결재상태", width: "w-[90px]", align: "center" },
|
||||
{ key: "contract_currency_name", label: "환종", width: "w-[70px]", align: "center" },
|
||||
{ key: "exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true },
|
||||
{ key: "order_appr_status", label: "결재상태", width: "w-[115px]", align: "center" },
|
||||
{ key: "contract_currency_name", label: "환종", width: "w-[95px]", align: "center" },
|
||||
{ key: "exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true },
|
||||
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
|
||||
{ key: "part_no", label: "품번", width: "w-[120px]" },
|
||||
{ key: "writer_name", label: "작성자", width: "w-[110px]" },
|
||||
{ key: "writer_name", label: "작성자", width: "w-[115px]" },
|
||||
/* wace orderMgmtList.jsp 429~434 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
|
||||
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
||||
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
||||
@@ -209,6 +210,29 @@ export default function SalesOrderPage() {
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
// ─── 하단 통계 ──────────────────────────────────────────────
|
||||
// 수주 건수 / 수주수량·취소 합계 / 공급가액·부가세·총액·원화총액 합계
|
||||
const orderSummary = useMemo(() => {
|
||||
const count = rows.length;
|
||||
const qtySum = rows.reduce((acc, r) => acc + Number(r.order_quantity || 0), 0);
|
||||
const cancelSum = rows.reduce((acc, r) => acc + Number(r.cancel_qty_sum || 0), 0);
|
||||
const supplySum = rows.reduce((acc, r) => acc + Number(r.order_supply_price_sum || 0), 0);
|
||||
const vatSum = rows.reduce((acc, r) => acc + Number(r.order_vat_sum || 0), 0);
|
||||
const totalSum = rows.reduce((acc, r) => acc + Number(r.order_total_amount_sum || 0), 0);
|
||||
const krwSum = rows.reduce((acc, r) => acc + Number(r.order_total_amount_krw || 0), 0);
|
||||
const money = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const intFmt = (n: number) => n.toLocaleString();
|
||||
return [
|
||||
{ label: "수주 건수", value: intFmt(count), suffix: "건" },
|
||||
{ label: "수주수량 합계", value: intFmt(qtySum) },
|
||||
{ label: "수주취소 합계", value: intFmt(cancelSum) },
|
||||
{ label: "공급가액 합계", value: money(supplySum) },
|
||||
{ label: "부가세 합계", value: money(vatSum) },
|
||||
{ label: "총액 합계", value: money(totalSum) },
|
||||
{ label: "원화총액 합계", value: money(krwSum) },
|
||||
];
|
||||
}, [rows]);
|
||||
|
||||
const openCreate = async () => {
|
||||
setDialogMode("create");
|
||||
const contractNo = await salesOrderMgmtApi.generateNumber().catch(() => "");
|
||||
@@ -619,6 +643,7 @@ export default function SalesOrderPage() {
|
||||
</CompactFilterBar>
|
||||
|
||||
<DataGrid
|
||||
gridId="sales-order"
|
||||
columns={gridColumns}
|
||||
data={rows}
|
||||
showCheckbox
|
||||
@@ -634,6 +659,22 @@ export default function SalesOrderPage() {
|
||||
onRowDoubleClick={(row) => { setSelected(row); openEdit(); }}
|
||||
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
|
||||
loading={loading}
|
||||
showColumnSettings
|
||||
paginationStyle="range"
|
||||
pageSizeOptions={[10, 15, 20, 50, 100]}
|
||||
summaryStats={orderSummary}
|
||||
systemColumnKeys={["writer_name"]}
|
||||
onRefresh={fetchList}
|
||||
onDownload={() => {
|
||||
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
||||
const exportRows = rows.map((r) => {
|
||||
const out: Record<string, any> = {};
|
||||
GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "주문관리.xlsx", "주문관리");
|
||||
}}
|
||||
showChart
|
||||
/>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { PageHeader } from "@/components/common/PageHeader";
|
||||
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
|
||||
import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog";
|
||||
import { salesSaleApi, RevenueListRow, DeadlineInfoBody } from "@/lib/api/salesSale";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
// RevenueListRow → 정규화된 ProjectInfoData (wace 운영판 다이얼로그 1:1)
|
||||
const toProjectInfo = (r: RevenueListRow): ProjectInfoData => ({
|
||||
@@ -45,31 +46,31 @@ const toProjectInfo = (r: RevenueListRow): ProjectInfoData => ({
|
||||
// wace_plm revenueMgmtList.jsp 컬럼 순서/라벨에 맞춤
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[170px]", frozen: true },
|
||||
{ key: "order_type_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "sales_deadline_date", label: "매출마감", width: "w-[110px]", align: "center" },
|
||||
{ key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" },
|
||||
{ key: "sales_deadline_date", label: "매출마감", width: "w-[115px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[115px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[140px]" },
|
||||
{ key: "customer", label: "고객사", width: "w-[160px]" },
|
||||
{ key: "product_type_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
||||
{ key: "product_type_name", label: "제품구분", width: "w-[115px]", align: "center" },
|
||||
{ key: "product_name", label: "품명", width: "w-[180px]" },
|
||||
{ key: "sales_quantity", label: "수량", width: "w-[80px]", align: "right", formatNumber: true },
|
||||
{ key: "sales_unit_price", label: "단가", width: "w-[110px]", formatMoney: true },
|
||||
{ key: "sales_supply_price", label: "공급가액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "sales_vat", label: "부가세", width: "w-[100px]", formatMoney: true },
|
||||
{ key: "sales_quantity", label: "수량", width: "w-[95px]", align: "right", formatNumber: true },
|
||||
{ key: "sales_unit_price", label: "단가", width: "w-[115px]", formatMoney: true },
|
||||
{ key: "sales_supply_price", label: "공급가액", width: "w-[130px]", formatMoney: true },
|
||||
{ key: "sales_vat", label: "부가세", width: "w-[115px]", formatMoney: true },
|
||||
{ key: "sales_total_amount", label: "총액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "sales_total_amount_krw", label: "원화총액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "sales_total_amount_krw", label: "원화총액", width: "w-[130px]", formatMoney: true },
|
||||
{ key: "shipping_date", label: "출하일", width: "w-[115px]", align: "center" },
|
||||
{ key: "nation_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
||||
{ key: "sales_currency_name", label: "환종", width: "w-[70px]", align: "center" },
|
||||
{ key: "sales_exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true },
|
||||
{ key: "nation_name", label: "국내/해외", width: "w-[115px]", align: "center" },
|
||||
{ key: "sales_currency_name", label: "환종", width: "w-[95px]", align: "center" },
|
||||
{ key: "sales_exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true },
|
||||
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
|
||||
{ key: "split_serial_no", label: "분할S/N", width: "w-[140px]" },
|
||||
{ key: "product_no", label: "품번", width: "w-[120px]" },
|
||||
{ key: "tax_type", label: "과세구분", width: "w-[100px]", align: "center" },
|
||||
{ key: "tax_invoice_date", label: "세금계산서발행일", width: "w-[140px]", align: "center" },
|
||||
{ key: "export_decl_no", label: "수출신고필증신고번호", width: "w-[160px]" },
|
||||
{ key: "loading_date", label: "선적일자", width: "w-[100px]", align: "center" },
|
||||
{ key: "has_transaction_statement", label: "거래명세서", width: "w-[100px]", align: "center" },
|
||||
{ key: "tax_type", label: "과세구분", width: "w-[115px]", align: "center" },
|
||||
{ key: "tax_invoice_date", label: "세금계산서발행일", width: "w-[160px]", align: "center" },
|
||||
{ key: "export_decl_no", label: "수출신고필증신고번호", width: "w-[180px]" },
|
||||
{ key: "loading_date", label: "선적일자", width: "w-[115px]", align: "center" },
|
||||
{ key: "has_transaction_statement", label: "거래명세서", width: "w-[115px]", align: "center" },
|
||||
/* wace revenueMgmtList.jsp 615~632 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
|
||||
{ key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" },
|
||||
{ key: "payment_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
|
||||
@@ -134,6 +135,27 @@ export default function SalesRevenuePage() {
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
// ─── 하단 통계 ──────────────────────────────────────────────
|
||||
// 매출 이력 건수 / 수량 합계 / 공급가액·부가세·총액·원화총액 합계
|
||||
const revenueSummary = useMemo(() => {
|
||||
const count = rows.length;
|
||||
const qtySum = rows.reduce((acc, r) => acc + Number(r.sales_quantity || 0), 0);
|
||||
const supplySum = rows.reduce((acc, r) => acc + Number(r.sales_supply_price || 0), 0);
|
||||
const vatSum = rows.reduce((acc, r) => acc + Number(r.sales_vat || 0), 0);
|
||||
const totalSum = rows.reduce((acc, r) => acc + Number(r.sales_total_amount || 0), 0);
|
||||
const krwSum = rows.reduce((acc, r) => acc + Number(r.sales_total_amount_krw || 0), 0);
|
||||
const money = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const intFmt = (n: number) => n.toLocaleString();
|
||||
return [
|
||||
{ label: "매출 이력", value: intFmt(count), suffix: "건" },
|
||||
{ label: "수량 합계", value: intFmt(qtySum) },
|
||||
{ label: "공급가액 합계", value: money(supplySum) },
|
||||
{ label: "부가세 합계", value: money(vatSum) },
|
||||
{ label: "총액 합계", value: money(totalSum) },
|
||||
{ label: "원화총액 합계", value: money(krwSum) },
|
||||
];
|
||||
}, [rows]);
|
||||
|
||||
const openDeadline = () => {
|
||||
if (!selected) { toast.warning("마감정보를 입력할 행을 선택하세요."); return; }
|
||||
setForm({
|
||||
@@ -273,6 +295,7 @@ export default function SalesRevenuePage() {
|
||||
</CompactFilterBar>
|
||||
|
||||
<DataGrid
|
||||
gridId="sales-revenue"
|
||||
columns={columns}
|
||||
data={rows}
|
||||
selectedId={selected ? String(selected.log_id) : null}
|
||||
@@ -283,6 +306,21 @@ export default function SalesRevenuePage() {
|
||||
onRowDoubleClick={(row) => { setSelected(row); openDeadline(); }}
|
||||
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
|
||||
loading={loading}
|
||||
showColumnSettings
|
||||
paginationStyle="range"
|
||||
pageSizeOptions={[10, 15, 20, 50, 100]}
|
||||
summaryStats={revenueSummary}
|
||||
onRefresh={fetchList}
|
||||
onDownload={() => {
|
||||
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
||||
const exportRows = rows.map((r) => {
|
||||
const out: Record<string, any> = {};
|
||||
GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "매출관리.xlsx", "매출관리");
|
||||
}}
|
||||
showChart
|
||||
/>
|
||||
|
||||
<ProjectInfoDialog open={infoOpen} onOpenChange={setInfoOpen} data={infoData} />
|
||||
|
||||
@@ -22,6 +22,7 @@ import { PageHeader } from "@/components/common/PageHeader";
|
||||
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
|
||||
import { ProjectInfoDialog, ProjectInfoData } from "@/components/project/ProjectInfoDialog";
|
||||
import { salesSaleApi, SaleListRow, SaleRegisterBody } from "@/lib/api/salesSale";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
|
||||
// SaleListRow → 정규화된 ProjectInfoData (wace 운영판 다이얼로그 1:1)
|
||||
const toProjectInfo = (r: SaleListRow): ProjectInfoData => ({
|
||||
@@ -44,29 +45,29 @@ const toProjectInfo = (r: SaleListRow): ProjectInfoData => ({
|
||||
// wace_plm salesMgmtList.jsp 컬럼 순서/라벨에 맞춤
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[170px]", frozen: true },
|
||||
{ key: "order_type_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "order_type_name", label: "주문유형", width: "w-[115px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[115px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[140px]" },
|
||||
{ key: "request_date", label: "요청납기", width: "w-[115px]", align: "center" },
|
||||
{ key: "shipping_date", label: "출하일", width: "w-[115px]", align: "center" },
|
||||
{ key: "customer", label: "고객사", width: "w-[160px]" },
|
||||
{ key: "product_name", label: "품명", width: "w-[180px]" },
|
||||
{ key: "order_quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "sales_quantity", label: "판매수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
{ key: "remaining_quantity", label: "잔량", width: "w-[80px]", align: "right", formatNumber: true },
|
||||
{ key: "sales_unit_price", label: "판매단가", width: "w-[110px]", formatMoney: true },
|
||||
{ key: "sales_supply_price", label: "판매공급가액", width: "w-[130px]", formatMoney: true },
|
||||
{ key: "sales_vat", label: "부가세", width: "w-[100px]", formatMoney: true },
|
||||
{ key: "sales_total_amount", label: "판매총액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "sales_total_amount_krw", label: "판매원화총액", width: "w-[130px]", formatMoney: true },
|
||||
{ key: "remaining_amount_krw", label: "잔량원화총액", width: "w-[130px]", formatMoney: true },
|
||||
{ key: "order_status_name", label: "수주상태", width: "w-[90px]", align: "center" },
|
||||
{ key: "sales_status", label: "판매상태", width: "w-[100px]", align: "center" },
|
||||
{ key: "production_status", label: "생산상태", width: "w-[100px]", align: "center" },
|
||||
{ key: "shipping_order_status", label: "출하지시상태", width: "w-[110px]", align: "center" },
|
||||
{ key: "payment_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
|
||||
{ key: "sales_currency_name", label: "환종", width: "w-[70px]", align: "center" },
|
||||
{ key: "sales_exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true },
|
||||
{ key: "order_quantity", label: "수주수량", width: "w-[115px]", align: "right", formatNumber: true },
|
||||
{ key: "sales_quantity", label: "판매수량", width: "w-[115px]", align: "right", formatNumber: true },
|
||||
{ key: "remaining_quantity", label: "잔량", width: "w-[95px]", align: "right", formatNumber: true },
|
||||
{ key: "sales_unit_price", label: "판매단가", width: "w-[115px]", formatMoney: true },
|
||||
{ key: "sales_supply_price", label: "판매공급가액", width: "w-[140px]", formatMoney: true },
|
||||
{ key: "sales_vat", label: "부가세", width: "w-[115px]", formatMoney: true },
|
||||
{ key: "sales_total_amount", label: "판매총액", width: "w-[130px]", formatMoney: true },
|
||||
{ key: "sales_total_amount_krw", label: "판매원화총액", width: "w-[140px]", formatMoney: true },
|
||||
{ key: "remaining_amount_krw", label: "잔량원화총액", width: "w-[140px]", formatMoney: true },
|
||||
{ key: "order_status_name", label: "수주상태", width: "w-[115px]", align: "center" },
|
||||
{ key: "sales_status", label: "판매상태", width: "w-[115px]", align: "center" },
|
||||
{ key: "production_status", label: "생산상태", width: "w-[115px]", align: "center" },
|
||||
{ key: "shipping_order_status", label: "출하지시상태", width: "w-[135px]", align: "center" },
|
||||
{ key: "payment_type_name", label: "유/무상", width: "w-[100px]", align: "center" },
|
||||
{ key: "sales_currency_name", label: "환종", width: "w-[95px]", align: "center" },
|
||||
{ key: "sales_exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true },
|
||||
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
|
||||
{ key: "split_serial_no", label: "분할S/N", width: "w-[140px]" },
|
||||
{ key: "product_no", label: "품번", width: "w-[120px]" },
|
||||
@@ -80,7 +81,7 @@ const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "manager_name", label: "담당자", width: "w-[100px]", align: "center" },
|
||||
{ key: "incoterms", label: "인도조건", width: "w-[90px]", align: "center" },
|
||||
*/
|
||||
{ key: "has_transaction_statement", label: "거래명세서", width: "w-[100px]", align: "center" },
|
||||
{ key: "has_transaction_statement", label: "거래명세서", width: "w-[115px]", align: "center" },
|
||||
];
|
||||
|
||||
export default function SalesSalePage() {
|
||||
@@ -130,6 +131,29 @@ export default function SalesSalePage() {
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
// ─── 하단 통계 ──────────────────────────────────────────────
|
||||
// 판매 라인 수 / 수주·판매·잔량 수량 합계 / 판매공급가액·판매원화총액·잔량원화총액 합계
|
||||
const saleSummary = useMemo(() => {
|
||||
const count = rows.length;
|
||||
const ordQty = rows.reduce((acc, r) => acc + Number(r.order_quantity || 0), 0);
|
||||
const salQty = rows.reduce((acc, r) => acc + Number(r.sales_quantity || 0), 0);
|
||||
const remQty = rows.reduce((acc, r) => acc + Number(r.remaining_quantity || 0), 0);
|
||||
const supplySum = rows.reduce((acc, r) => acc + Number(r.sales_supply_price || 0), 0);
|
||||
const totalKrw = rows.reduce((acc, r) => acc + Number(r.sales_total_amount_krw || 0), 0);
|
||||
const remKrw = rows.reduce((acc, r) => acc + Number(r.remaining_amount_krw || 0), 0);
|
||||
const money = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const intFmt = (n: number) => n.toLocaleString();
|
||||
return [
|
||||
{ label: "판매 라인", value: intFmt(count), suffix: "건" },
|
||||
{ label: "수주수량 합계", value: intFmt(ordQty) },
|
||||
{ label: "판매수량 합계", value: intFmt(salQty) },
|
||||
{ label: "잔량 합계", value: intFmt(remQty) },
|
||||
{ label: "판매공급가액 합계", value: money(supplySum) },
|
||||
{ label: "판매원화총액 합계", value: money(totalKrw) },
|
||||
{ label: "잔량원화총액 합계", value: money(remKrw) },
|
||||
];
|
||||
}, [rows]);
|
||||
|
||||
const openRegister = () => {
|
||||
if (!selected) { toast.warning("판매등록할 행을 선택하세요."); return; }
|
||||
setForm({
|
||||
@@ -257,6 +281,7 @@ export default function SalesSalePage() {
|
||||
</CompactFilterBar>
|
||||
|
||||
<DataGrid
|
||||
gridId="sales-sale"
|
||||
columns={columns}
|
||||
data={rows}
|
||||
selectedId={selected ? `${selected.project_no}-${selected.contract_item_objid}-0` : null}
|
||||
@@ -264,6 +289,21 @@ export default function SalesSalePage() {
|
||||
onRowDoubleClick={(row) => { setSelected(row); openRegister(); }}
|
||||
emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"}
|
||||
loading={loading}
|
||||
showColumnSettings
|
||||
paginationStyle="range"
|
||||
pageSizeOptions={[10, 15, 20, 50, 100]}
|
||||
summaryStats={saleSummary}
|
||||
onRefresh={fetchList}
|
||||
onDownload={() => {
|
||||
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
|
||||
const exportRows = rows.map((r) => {
|
||||
const out: Record<string, any> = {};
|
||||
GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; });
|
||||
return out;
|
||||
});
|
||||
exportToExcel(exportRows, "판매관리.xlsx", "판매관리");
|
||||
}}
|
||||
showChart
|
||||
/>
|
||||
|
||||
<ProjectInfoDialog open={infoOpen} onOpenChange={setInfoOpen} data={infoData} />
|
||||
|
||||
@@ -22,7 +22,9 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Filter, Check, Search, ImageIcon, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Folder, Paperclip } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Filter, Check, Search, ImageIcon, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Folder, Paperclip, Settings, GripVertical, RotateCw, Download as DownloadIcon, BarChart3 } from "lucide-react";
|
||||
import { DataGridChartPanel } from "./DataGridChartPanel";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -76,6 +78,34 @@ export interface DataGridProps {
|
||||
showPagination?: boolean;
|
||||
/** 초기 페이지 크기 (기본: 50) */
|
||||
defaultPageSize?: number;
|
||||
/** 페이지네이션 라벨 포맷 — 'classic'(기본): "전체 N건", 'range': "1-N / 총 X건" */
|
||||
paginationStyle?: "classic" | "range";
|
||||
/** 페이지 크기 드롭다운 옵션. 지정 시 Input 대신 Select 렌더. 예: [10,15,20,50,100] */
|
||||
pageSizeOptions?: number[];
|
||||
/** 컬럼 표시/숨김 설정 드롭다운 활성화 (gear 아이콘) */
|
||||
showColumnSettings?: boolean;
|
||||
/** 시스템 컬럼으로 분리 표시할 key 배열. 표시 메뉴에서 '시스템 컬럼' 그룹으로 묶임 */
|
||||
systemColumnKeys?: string[];
|
||||
/** 하단 통계 행 — 평균/합계 등. footer에 좌측 배치 */
|
||||
summaryStats?: Array<{ label: string; value: string | number; suffix?: string }>;
|
||||
/** 재조회 콜백. 지정 시 그리드 상단 toolbar에 ⟳ 아이콘 표시 */
|
||||
onRefresh?: () => void;
|
||||
/** 다운로드 콜백 (Excel/CSV 등). 지정 시 그리드 상단 toolbar에 ⬇ 아이콘 표시 */
|
||||
onDownload?: () => void;
|
||||
/** 차트 분석 패널 활성화. 지정 시 toolbar에 📊 토글 + 그리드 하단에 패널 노출 */
|
||||
showChart?: boolean;
|
||||
/** 서버 페이지네이션 모드. true면 page/pageSize/totalItems 를 props로 받고 변경 시 콜백 호출 */
|
||||
serverPaging?: boolean;
|
||||
/** 서버 페이지네이션 현재 페이지 (1-based, serverPaging=true일 때) */
|
||||
serverPage?: number;
|
||||
/** 서버 페이지네이션 페이지 크기 (serverPaging=true일 때) */
|
||||
serverPageSize?: number;
|
||||
/** 서버 페이지네이션 총 건수 (serverPaging=true일 때) */
|
||||
serverTotalItems?: number;
|
||||
/** 페이지 변경 콜백 — 서버 페이지네이션 시 API 재호출용 */
|
||||
onPageChange?: (page: number) => void;
|
||||
/** 페이지 크기 변경 콜백 — 서버 페이지네이션 시 API 재호출용 */
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
}
|
||||
|
||||
const fmtNum = (val: any) => {
|
||||
@@ -140,15 +170,19 @@ function SortableHeaderCell({
|
||||
style={style}
|
||||
className={cn(
|
||||
widthPx == null && col.width, widthPx == null && col.minWidth,
|
||||
"select-none relative",
|
||||
"select-none relative group/th",
|
||||
col.frozen && cn("sticky z-20 bg-background", frozenLeftClass),
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div
|
||||
<div className="inline-flex items-center gap-1 w-full">
|
||||
<GripVertical
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex items-center gap-0.5 cursor-pointer min-w-0"
|
||||
className="h-3 w-3 text-muted-foreground/50 shrink-0 cursor-grab active:cursor-grabbing"
|
||||
aria-label="컬럼 드래그"
|
||||
/>
|
||||
<div
|
||||
className="flex items-center gap-0.5 cursor-pointer min-w-0 flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (col.sortable !== false) onSort(col.key);
|
||||
@@ -273,10 +307,70 @@ export function DataGrid({
|
||||
gridId,
|
||||
showPagination = true,
|
||||
defaultPageSize = 50,
|
||||
paginationStyle = "classic",
|
||||
pageSizeOptions,
|
||||
showColumnSettings = false,
|
||||
systemColumnKeys,
|
||||
summaryStats,
|
||||
onRefresh,
|
||||
onDownload,
|
||||
showChart = false,
|
||||
serverPaging = false,
|
||||
serverPage,
|
||||
serverPageSize,
|
||||
serverTotalItems,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: DataGridProps) {
|
||||
const [columns, setColumns] = useState(initialColumns);
|
||||
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
|
||||
|
||||
// 차트 패널 열림 상태 (gridId 있으면 localStorage 영속)
|
||||
const [chartPanelOpen, setChartPanelOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!gridId) return;
|
||||
const saved = localStorage.getItem(`datagrid_chart_open_${gridId}`);
|
||||
if (saved === "1") setChartPanelOpen(true);
|
||||
}, [gridId]);
|
||||
const toggleChartPanel = useCallback(() => {
|
||||
setChartPanelOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (gridId) {
|
||||
try { localStorage.setItem(`datagrid_chart_open_${gridId}`, next ? "1" : "0"); } catch { /* skip */ }
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [gridId]);
|
||||
|
||||
// 컬럼 visibility — 숨겨진 컬럼 key Set. localStorage 영속(gridId 있을 때).
|
||||
const [hiddenColumns, setHiddenColumns] = useState<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!gridId) return;
|
||||
const saved = localStorage.getItem(`datagrid_col_hidden_${gridId}`);
|
||||
if (saved) {
|
||||
try { setHiddenColumns(new Set(JSON.parse(saved) as string[])); } catch { /* skip */ }
|
||||
}
|
||||
}, [gridId]);
|
||||
const toggleColumnVisibility = useCallback((key: string) => {
|
||||
setHiddenColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key); else next.add(key);
|
||||
if (gridId) {
|
||||
try { localStorage.setItem(`datagrid_col_hidden_${gridId}`, JSON.stringify(Array.from(next))); } catch { /* skip */ }
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [gridId]);
|
||||
const resetVisibility = useCallback(() => {
|
||||
setHiddenColumns(new Set());
|
||||
if (gridId) try { localStorage.removeItem(`datagrid_col_hidden_${gridId}`); } catch { /* skip */ }
|
||||
}, [gridId]);
|
||||
const resetOrder = useCallback(() => {
|
||||
setColumns(initialColumns);
|
||||
if (gridId) try { localStorage.removeItem(`datagrid_col_order_${gridId}`); } catch { /* skip */ }
|
||||
}, [gridId, initialColumns]);
|
||||
// resetWidthsAll은 setColumnWidths 선언 이후에 정의 (아래에서)
|
||||
|
||||
// 정렬
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
||||
@@ -284,10 +378,22 @@ export function DataGrid({
|
||||
// 헤더 필터 (컬럼별 선택된 값 Set)
|
||||
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
||||
|
||||
// 페이지네이션
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||
// 페이지네이션 — 서버 모드면 props 사용, 아니면 로컬 state
|
||||
const [localCurrentPage, setLocalCurrentPage] = useState(1);
|
||||
const [localPageSize, setLocalPageSize] = useState(defaultPageSize);
|
||||
const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize));
|
||||
const currentPage = serverPaging ? (serverPage ?? 1) : localCurrentPage;
|
||||
const pageSize = serverPaging ? (serverPageSize ?? defaultPageSize) : localPageSize;
|
||||
const setCurrentPage = useCallback((p: number | ((prev: number) => number)) => {
|
||||
const next = typeof p === "function" ? (p as (n: number) => number)(currentPage) : p;
|
||||
if (serverPaging) { onPageChange?.(next); } else { setLocalCurrentPage(next); }
|
||||
}, [serverPaging, currentPage, onPageChange]);
|
||||
const setPageSize = useCallback((n: number) => {
|
||||
if (serverPaging) { onPageSizeChange?.(n); } else { setLocalPageSize(n); }
|
||||
}, [serverPaging, onPageSizeChange]);
|
||||
useEffect(() => {
|
||||
if (serverPaging && serverPageSize) setPageSizeInput(String(serverPageSize));
|
||||
}, [serverPaging, serverPageSize]);
|
||||
|
||||
// 인라인 편집
|
||||
const [editingCell, setEditingCell] = useState<{ rowIdx: number; colKey: string } | null>(null);
|
||||
@@ -331,6 +437,16 @@ export function DataGrid({
|
||||
try { localStorage.setItem(`datagrid_col_widths_${gridId}`, JSON.stringify(next)); } catch { /* skip */ }
|
||||
}
|
||||
}, [gridId]);
|
||||
const resetWidthsAll = useCallback(() => {
|
||||
setColumnWidths({});
|
||||
if (gridId) try { localStorage.removeItem(`datagrid_col_widths_${gridId}`); } catch { /* skip */ }
|
||||
}, [gridId]);
|
||||
|
||||
// 가시 컬럼 — hiddenColumns 적용. 헤더/바디 렌더에 사용. DnD/순서 영속은 전체 columns 기준 유지.
|
||||
const visibleColumns = useMemo(
|
||||
() => columns.filter((c) => !hiddenColumns.has(c.key)),
|
||||
[columns, hiddenColumns],
|
||||
);
|
||||
|
||||
// 리사이즈 드래그 핸들러 — 헤더 우측 핸들에서 mousedown 발생 시 호출.
|
||||
const startResize = useCallback((e: React.MouseEvent, colKey: string, currentWidthPx: number) => {
|
||||
@@ -447,31 +563,34 @@ export function DataGrid({
|
||||
return result;
|
||||
}, [data, headerFilters, sortKey, sortDir]);
|
||||
|
||||
// 필터/데이터 변경 시 1페이지로 리셋
|
||||
// 필터/데이터 변경 시 1페이지로 리셋 — 서버 페이지네이션에서는 부모가 직접 page 관리하므로 스킵
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [data, headerFilters]);
|
||||
if (serverPaging) return;
|
||||
setLocalCurrentPage(1);
|
||||
}, [data, headerFilters, serverPaging]);
|
||||
|
||||
// 페이지네이션 계산
|
||||
const totalItems = processedData.length;
|
||||
// 서버 모드: totalItems 는 prop, paginatedData 는 data 그대로(서버가 잘라준 한 페이지)
|
||||
// 클라이언트 모드: totalItems = processedData.length, paginatedData = client slice
|
||||
const totalItems = serverPaging ? (serverTotalItems ?? processedData.length) : processedData.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
const safePage = Math.min(currentPage, totalPages);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||||
}, [currentPage, totalPages]);
|
||||
if (!serverPaging && currentPage > totalPages) setCurrentPage(totalPages);
|
||||
}, [serverPaging, currentPage, totalPages, setCurrentPage]);
|
||||
|
||||
const pageOffset = (safePage - 1) * pageSize;
|
||||
const paginatedData = showPagination
|
||||
? processedData.slice(pageOffset, pageOffset + pageSize)
|
||||
: processedData;
|
||||
const paginatedData = serverPaging
|
||||
? processedData
|
||||
: (showPagination ? processedData.slice(pageOffset, pageOffset + pageSize) : processedData);
|
||||
|
||||
// 페이지 크기 입력 적용
|
||||
const applyPageSize = () => {
|
||||
const n = parseInt(pageSizeInput, 10);
|
||||
if (!isNaN(n) && n >= 1) {
|
||||
setPageSize(n);
|
||||
setCurrentPage(1);
|
||||
if (!serverPaging) setLocalCurrentPage(1);
|
||||
setPageSizeInput(String(n));
|
||||
} else {
|
||||
setPageSizeInput(String(pageSize));
|
||||
@@ -629,7 +748,7 @@ export function DataGrid({
|
||||
const isRightAlign = col.align === "right" || isNumericCol;
|
||||
|
||||
return (
|
||||
<span className={cn("text-xs", truncateClass, isRightAlign && "text-right w-full inline-block")}
|
||||
<span className={cn("text-xs leading-tight", truncateClass, isRightAlign && "text-right w-full inline-block")}
|
||||
title={String(val ?? "")}>
|
||||
{display}
|
||||
</span>
|
||||
@@ -637,19 +756,132 @@ export function DataGrid({
|
||||
};
|
||||
|
||||
// 좌측 고정 보조: NO/체크박스 컬럼은 40px(=left-10), 첫 frozen 컬럼은 그 다음 위치
|
||||
const hasFrozen = columns.some((c) => c.frozen);
|
||||
const hasFrozen = visibleColumns.some((c) => c.frozen);
|
||||
const hasFirstCol = showCheckbox || showRowNumber;
|
||||
const stickyFirstColClass = "sticky left-0 z-20 bg-background";
|
||||
const stickyFirstColBodyClass = "sticky left-0 z-[6]";
|
||||
const frozenLeftClass = hasFirstCol ? "left-10" : "left-0";
|
||||
|
||||
// 컬럼 settings dropdown — 데이터/시스템 그룹 분리. 시스템 키는 systemColumnKeys로 지정.
|
||||
const systemKeySet = useMemo(() => new Set(systemColumnKeys ?? []), [systemColumnKeys]);
|
||||
const dataCols = useMemo(() => columns.filter((c) => !systemKeySet.has(c.key)), [columns, systemKeySet]);
|
||||
const systemCols = useMemo(() => columns.filter((c) => systemKeySet.has(c.key)), [columns, systemKeySet]);
|
||||
|
||||
const hasToolbar = showColumnSettings || onRefresh || onDownload || showChart;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full flex-1 min-h-0">
|
||||
{hasToolbar && (
|
||||
<div className="flex items-center justify-end gap-0.5 border-b bg-muted/20 px-2 py-1 shrink-0">
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted text-muted-foreground"
|
||||
title="재조회"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{showChart && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleChartPanel}
|
||||
className={cn(
|
||||
"h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted",
|
||||
chartPanelOpen ? "text-primary bg-primary/10" : "text-muted-foreground",
|
||||
)}
|
||||
title="차트 분석"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onDownload && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownload}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted text-muted-foreground"
|
||||
title="Excel 다운로드"
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{showColumnSettings && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted text-muted-foreground"
|
||||
title="컬럼 표시 설정"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="end">
|
||||
<div className="px-3 py-2 border-b">
|
||||
<span className="text-sm font-semibold">표시할 컬럼 선택</span>
|
||||
</div>
|
||||
<div className="max-h-[60vh] overflow-y-auto px-2 py-2 space-y-3">
|
||||
{dataCols.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-muted-foreground px-1 mb-1">데이터 컬럼</div>
|
||||
<div className="space-y-0.5">
|
||||
{dataCols.map((col) => {
|
||||
const checked = !hiddenColumns.has(col.key);
|
||||
return (
|
||||
<label
|
||||
key={col.key}
|
||||
className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-muted cursor-pointer text-sm"
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={() => toggleColumnVisibility(col.key)} />
|
||||
<span className="truncate">{col.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{systemCols.length > 0 && (
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-muted-foreground px-1 mb-1">시스템 컬럼</div>
|
||||
<div className="space-y-0.5">
|
||||
{systemCols.map((col) => {
|
||||
const checked = !hiddenColumns.has(col.key);
|
||||
return (
|
||||
<label
|
||||
key={col.key}
|
||||
className="flex items-center gap-2 px-1.5 py-1 rounded hover:bg-muted cursor-pointer text-sm"
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={() => toggleColumnVisibility(col.key)} />
|
||||
<span className="truncate">{col.label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t px-2 py-1.5 flex flex-col items-start gap-0.5">
|
||||
<button onClick={resetVisibility} className="text-primary text-xs hover:underline px-1.5 py-0.5">
|
||||
표시/숨김 초기화
|
||||
</button>
|
||||
<button onClick={resetOrder} className="text-primary text-xs hover:underline px-1.5 py-0.5">
|
||||
순서 초기화
|
||||
</button>
|
||||
<button onClick={resetWidthsAll} className="text-primary text-xs hover:underline px-1.5 py-0.5">
|
||||
컬럼 너비 초기화
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table noWrapper className="table-fixed">
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableHeader className="sticky top-0 bg-muted z-10 shadow-[0_1px_0_0_hsl(var(--border))] [&_th]:h-9 [&_th]:bg-muted">
|
||||
<SortableContext items={visibleColumns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow>
|
||||
{showCheckbox && (
|
||||
<TableHead className={cn("w-[40px] text-center ", hasFrozen && stickyFirstColClass)}>
|
||||
@@ -664,7 +896,7 @@ export function DataGrid({
|
||||
{showRowNumber && !showCheckbox && (
|
||||
<TableHead className={cn("w-[40px] text-center text-xs ", hasFrozen && stickyFirstColClass)}>No</TableHead>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
{visibleColumns.map((col) => (
|
||||
<SortableHeaderCell
|
||||
key={col.key}
|
||||
col={col}
|
||||
@@ -683,27 +915,31 @@ export function DataGrid({
|
||||
</TableRow>
|
||||
</SortableContext>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableBody className="[&_tr]:!h-7 [&_td]:!py-0 [&_td]:!h-7 [&_td]:!leading-none [&_td_*]:!leading-none [&_td_svg]:!h-3.5 [&_td_svg]:!w-3.5">
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={visibleColumns.length + 1} className="text-center py-8 text-muted-foreground">
|
||||
로딩 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paginatedData.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={visibleColumns.length + 1} className="text-center py-8 text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paginatedData.map((row, rowIdx) => {
|
||||
const isSelected = selectedId === row.id || (showCheckbox && checkedIds.includes(row.id));
|
||||
// selectedId 또는 row.id 가 nullish 면 비교 결과를 무조건 false 로 — 둘 다 undefined 일 때 `undefined === undefined` 가 true 가 되어 모든 행이 selected 로 잡히는 함정 차단
|
||||
const isSelected =
|
||||
(selectedId != null && row.id != null && selectedId === row.id) ||
|
||||
(showCheckbox && row.id != null && checkedIds.includes(row.id));
|
||||
// sticky 셀에 alpha 없는 단색 배경 사용 (반투명이면 뒤 셀이 비침).
|
||||
// selected → bg-accent / hover(non-selected) → group-hover로 muted 적용 / 기본 → bg-background
|
||||
const stickyBgClass = isSelected ? "bg-accent" : "bg-background group-hover:bg-muted";
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id || rowIdx}
|
||||
style={{ height: 28 }}
|
||||
className={cn("cursor-pointer group",
|
||||
isSelected && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
@@ -749,7 +985,7 @@ export function DataGrid({
|
||||
{pageOffset + rowIdx + 1}
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.map((col) => {
|
||||
{visibleColumns.map((col) => {
|
||||
const w = columnWidths[col.key];
|
||||
const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined;
|
||||
// 일반 텍스트 셀에 컬럼 단위 onClick 지원 (clip/folder 외)
|
||||
@@ -759,7 +995,7 @@ export function DataGrid({
|
||||
key={col.key}
|
||||
style={inlineStyle}
|
||||
className={cn(
|
||||
w == null && col.width, w == null && col.minWidth, "py-2.5",
|
||||
w == null && col.width, w == null && col.minWidth, "py-1",
|
||||
col.editable && "cursor-text",
|
||||
cellClickable && "cursor-pointer hover:underline text-primary",
|
||||
isSelected && "bg-accent",
|
||||
@@ -784,17 +1020,68 @@ export function DataGrid({
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* 하단 통계 행 — summaryStats 지정 시. 페이지네이션 footer 위에 별도 띠로 노출. */}
|
||||
{summaryStats && summaryStats.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 border-t bg-muted/30 px-3 py-1.5 text-xs shrink-0">
|
||||
{summaryStats.map((s, i) => (
|
||||
<div key={`${s.label}-${i}`} className="inline-flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">{s.label}</span>
|
||||
<span className="font-semibold text-foreground tabular-nums">{s.value}{s.suffix ?? ""}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 footer */}
|
||||
{showPagination && (
|
||||
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground shrink-0">
|
||||
{/* 좌측: 데이터 수량 + 페이지 크기 입력 */}
|
||||
{/* 좌측: 데이터 수량 + 페이지 크기 입력/Select */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{paginationStyle === "range" ? (
|
||||
<>
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{totalItems === 0 ? 0 : (pageOffset + 1).toLocaleString()}-{(pageOffset + paginatedData.length).toLocaleString()}
|
||||
</span>
|
||||
<span>/ 총</span>
|
||||
<span className="font-medium text-foreground tabular-nums">{totalItems.toLocaleString()}</span>
|
||||
<span>건</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>전체</span>
|
||||
<span className="font-medium text-foreground">{totalItems.toLocaleString()}</span>
|
||||
<span>건</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{pageSizeOptions && pageSizeOptions.length > 0 ? (
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(v) => {
|
||||
const n = Number(v);
|
||||
if (!isNaN(n) && n >= 1) {
|
||||
setPageSize(n);
|
||||
setPageSizeInput(String(n));
|
||||
// 서버 모드는 부모가 page 리셋(또는 페이지 유지)을 직접 결정
|
||||
if (!serverPaging) setLocalCurrentPage(1);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[88px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((n) => (
|
||||
<SelectItem key={n} value={String(n)} className="text-xs">
|
||||
{n} / 페이지
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -805,6 +1092,8 @@ export function DataGrid({
|
||||
className="h-7 w-16 text-center text-xs"
|
||||
/>
|
||||
<span>건씩 보기</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -854,6 +1143,11 @@ export function DataGrid({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 차트 분석 패널 — showChart + chartPanelOpen 시 노출 */}
|
||||
{showChart && chartPanelOpen && (
|
||||
<DataGridChartPanel gridId={gridId} columns={columns} data={processedData} />
|
||||
)}
|
||||
|
||||
{/* 이미지 확대 모달 */}
|
||||
{previewImage && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/70"
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DataGridChartPanel — DataGrid 하단 차트 분석 패널.
|
||||
*
|
||||
* 기능:
|
||||
* - 여러 차트 동시 관리 (추가/삭제/제목 편집/드래그 순서 변경)
|
||||
* - 차트 타입: Bar / Line / Pie
|
||||
* - X축 컬럼 선택 (모든 컬럼)
|
||||
* - Y축 컬럼 선택 (숫자 컬럼)
|
||||
* - 집계: count / sum / avg / min / max
|
||||
* - localStorage 영속 (gridId 기준)
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import {
|
||||
DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, useSortable, arrayMove, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Plus, X, Pencil, ChevronDown, ChevronUp, GripVertical } from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DataGridColumn } from "./DataGrid";
|
||||
|
||||
type AggType = "count" | "sum" | "avg" | "min" | "max";
|
||||
type ChartType = "bar" | "line" | "pie";
|
||||
|
||||
interface ChartConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
type: ChartType;
|
||||
xKey: string;
|
||||
yKey: string;
|
||||
agg: AggType;
|
||||
}
|
||||
|
||||
const AGG_LABEL: Record<AggType, string> = {
|
||||
count: "건수", sum: "합계", avg: "평균", min: "최소", max: "최대",
|
||||
};
|
||||
const CHART_TYPE_LABEL: Record<ChartType, string> = {
|
||||
bar: "막대", line: "선", pie: "원형",
|
||||
};
|
||||
const PIE_COLORS = ["#3b82f6", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
|
||||
|
||||
function newChartConfig(columns: DataGridColumn[]): ChartConfig {
|
||||
const numericCols = columns.filter((c) => c.formatNumber || c.formatMoney || c.inputType === "number");
|
||||
const firstCat = columns.find((c) => !numericCols.includes(c));
|
||||
const firstNum = numericCols[0];
|
||||
return {
|
||||
id: `chart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: "제목 없는 차트",
|
||||
type: "bar",
|
||||
xKey: firstCat?.key ?? columns[0]?.key ?? "",
|
||||
yKey: firstNum?.key ?? "",
|
||||
agg: firstNum ? "sum" : "count",
|
||||
};
|
||||
}
|
||||
|
||||
function aggregate(
|
||||
rows: Record<string, any>[],
|
||||
xKey: string,
|
||||
yKey: string,
|
||||
agg: AggType,
|
||||
): Array<{ name: string; value: number }> {
|
||||
const groups: Record<string, number[]> = {};
|
||||
for (const row of rows) {
|
||||
const xVal = row[xKey] == null || row[xKey] === "" ? "(빈 값)" : String(row[xKey]);
|
||||
if (!groups[xVal]) groups[xVal] = [];
|
||||
if (agg === "count") {
|
||||
groups[xVal].push(1);
|
||||
} else {
|
||||
const raw = row[yKey];
|
||||
const n = typeof raw === "number" ? raw : Number(String(raw ?? "").replace(/,/g, ""));
|
||||
if (!isNaN(n)) groups[xVal].push(n);
|
||||
}
|
||||
}
|
||||
return Object.entries(groups).map(([name, vals]) => {
|
||||
let v: number;
|
||||
if (agg === "count") v = vals.length;
|
||||
else if (agg === "sum") v = vals.reduce((a, b) => a + b, 0);
|
||||
else if (agg === "avg") v = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
|
||||
else if (agg === "min") v = vals.length > 0 ? Math.min(...vals) : 0;
|
||||
else v = vals.length > 0 ? Math.max(...vals) : 0;
|
||||
return { name, value: Number(v.toFixed(2)) };
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 단일 차트 카드 ─────────────────────────────────────────────
|
||||
|
||||
interface ChartCardProps {
|
||||
cfg: ChartConfig;
|
||||
columns: DataGridColumn[];
|
||||
data: Record<string, any>[];
|
||||
onChange: (next: ChartConfig) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function SortableChartCard({ cfg, columns, data, onChange, onDelete }: ChartCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: cfg.id });
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(cfg.title);
|
||||
|
||||
const numericCols = useMemo(
|
||||
() => columns.filter((c) => c.formatNumber || c.formatMoney || c.inputType === "number"),
|
||||
[columns],
|
||||
);
|
||||
|
||||
const chartData = useMemo(
|
||||
() => aggregate(data, cfg.xKey, cfg.yKey, cfg.agg),
|
||||
[data, cfg.xKey, cfg.yKey, cfg.agg],
|
||||
);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="rounded-md border bg-card text-card-foreground shadow-sm"
|
||||
>
|
||||
{/* 카드 헤더: 드래그 핸들 + 제목 + 삭제 */}
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground"
|
||||
title="드래그하여 순서 변경"
|
||||
type="button"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
{editingTitle ? (
|
||||
<Input
|
||||
value={titleDraft}
|
||||
autoFocus
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={() => { onChange({ ...cfg, title: titleDraft.trim() || "제목 없는 차트" }); setEditingTitle(false); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") { onChange({ ...cfg, title: titleDraft.trim() || "제목 없는 차트" }); setEditingTitle(false); }
|
||||
if (e.key === "Escape") { setTitleDraft(cfg.title); setEditingTitle(false); }
|
||||
}}
|
||||
className="h-7 text-sm flex-1"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setTitleDraft(cfg.title); setEditingTitle(true); }}
|
||||
className="text-sm font-medium hover:underline flex-1 text-left truncate"
|
||||
title="클릭하여 제목 편집"
|
||||
>
|
||||
{cfg.title}
|
||||
<Pencil className="inline ml-1.5 h-3 w-3 text-muted-foreground/60" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="h-7 w-7 inline-flex items-center justify-center rounded hover:bg-muted text-muted-foreground"
|
||||
title="차트 삭제"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컨트롤 행 */}
|
||||
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b bg-muted/20 text-xs">
|
||||
<span className="text-muted-foreground">유형</span>
|
||||
<Select value={cfg.type} onValueChange={(v) => onChange({ ...cfg, type: v as ChartType })}>
|
||||
<SelectTrigger className="h-7 w-[80px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["bar", "line", "pie"] as ChartType[]).map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-xs">{CHART_TYPE_LABEL[t]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<span className="text-muted-foreground ml-2">X축</span>
|
||||
<Select value={cfg.xKey} onValueChange={(v) => onChange({ ...cfg, xKey: v })}>
|
||||
<SelectTrigger className="h-7 w-[140px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((c) => (
|
||||
<SelectItem key={c.key} value={c.key} className="text-xs">{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<span className="text-muted-foreground ml-2">집계</span>
|
||||
<Select value={cfg.agg} onValueChange={(v) => onChange({ ...cfg, agg: v as AggType })}>
|
||||
<SelectTrigger className="h-7 w-[80px] text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["count", "sum", "avg", "min", "max"] as AggType[]).map((a) => (
|
||||
<SelectItem key={a} value={a} className="text-xs">{AGG_LABEL[a]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{cfg.agg !== "count" && (
|
||||
<>
|
||||
<span className="text-muted-foreground ml-2">Y축</span>
|
||||
<Select value={cfg.yKey} onValueChange={(v) => onChange({ ...cfg, yKey: v })}>
|
||||
<SelectTrigger className="h-7 w-[140px] text-xs"><SelectValue placeholder="숫자 컬럼 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{numericCols.length === 0 ? (
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">숫자 컬럼 없음</div>
|
||||
) : numericCols.map((c) => (
|
||||
<SelectItem key={c.key} value={c.key} className="text-xs">{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 차트 본체 */}
|
||||
<div className="p-3">
|
||||
{chartData.length === 0 ? (
|
||||
<div className="h-[240px] flex items-center justify-center text-muted-foreground text-xs">
|
||||
데이터 없음
|
||||
</div>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
{cfg.type === "bar" ? (
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill="#3b82f6" name={`${columns.find((c) => c.key === cfg.yKey)?.label ?? ""} ${AGG_LABEL[cfg.agg]}`} />
|
||||
</BarChart>
|
||||
) : cfg.type === "line" ? (
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
|
||||
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Line type="monotone" dataKey="value" stroke="#3b82f6" strokeWidth={2} name={`${columns.find((c) => c.key === cfg.yKey)?.label ?? ""} ${AGG_LABEL[cfg.agg]}`} />
|
||||
</LineChart>
|
||||
) : (
|
||||
<PieChart>
|
||||
<Pie data={chartData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label={{ fontSize: 11 }}>
|
||||
{chartData.map((_, idx) => <Cell key={idx} fill={PIE_COLORS[idx % PIE_COLORS.length]} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
</PieChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 패널 본체 ──────────────────────────────────────────────────
|
||||
|
||||
interface DataGridChartPanelProps {
|
||||
/** localStorage 키 prefix용 grid ID. 미지정 시 영속 비활성 */
|
||||
gridId?: string;
|
||||
columns: DataGridColumn[];
|
||||
data: Record<string, any>[];
|
||||
}
|
||||
|
||||
export function DataGridChartPanel({ gridId, columns, data }: DataGridChartPanelProps) {
|
||||
const [charts, setCharts] = useState<ChartConfig[]>([]);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const storageKey = gridId ? `datagrid_charts_${gridId}` : null;
|
||||
|
||||
// 영속 로드
|
||||
useEffect(() => {
|
||||
if (!storageKey) return;
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved) as ChartConfig[];
|
||||
if (Array.isArray(parsed)) setCharts(parsed);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}, [storageKey]);
|
||||
|
||||
const persist = useCallback((next: ChartConfig[]) => {
|
||||
setCharts(next);
|
||||
if (storageKey) {
|
||||
try { localStorage.setItem(storageKey, JSON.stringify(next)); } catch { /* skip */ }
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
const addChart = () => persist([...charts, newChartConfig(columns)]);
|
||||
const updateChart = (id: string, next: ChartConfig) => persist(charts.map((c) => c.id === id ? next : c));
|
||||
const deleteChart = (id: string) => persist(charts.filter((c) => c.id !== id));
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));
|
||||
const handleDragEnd = (e: DragEndEvent) => {
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIdx = charts.findIndex((c) => c.id === active.id);
|
||||
const newIdx = charts.findIndex((c) => c.id === over.id);
|
||||
if (oldIdx < 0 || newIdx < 0) return;
|
||||
persist(arrayMove(charts, oldIdx, newIdx));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("border-t bg-muted/10 flex flex-col shrink-0", collapsed ? "" : "h-[360px]")}>
|
||||
{/* 패널 헤더 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((p) => !p)}
|
||||
className="h-6 w-6 inline-flex items-center justify-center rounded hover:bg-muted text-muted-foreground"
|
||||
title={collapsed ? "펼치기" : "접기"}
|
||||
>
|
||||
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
|
||||
</button>
|
||||
<span className="text-sm font-semibold">차트 분석 ({charts.length}개)</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addChart}
|
||||
className="h-7 inline-flex items-center gap-1 px-2 rounded hover:bg-muted text-muted-foreground text-xs"
|
||||
title="차트 추가"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" /> 차트 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 차트 카드들 — 내부 스크롤 */}
|
||||
{!collapsed && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3">
|
||||
{charts.length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground text-xs">
|
||||
+ 차트 추가 버튼을 눌러 분석 차트를 만들어보세요.
|
||||
</div>
|
||||
) : (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={charts.map((c) => c.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className={cn("grid gap-3", charts.length >= 2 ? "grid-cols-1 lg:grid-cols-2" : "grid-cols-1")}>
|
||||
{charts.map((cfg) => (
|
||||
<SortableChartCard
|
||||
key={cfg.id}
|
||||
cfg={cfg}
|
||||
columns={columns}
|
||||
data={data}
|
||||
onChange={(next) => updateChart(cfg.id, next)}
|
||||
onDelete={() => deleteChart(cfg.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,37 @@
|
||||
"use client";
|
||||
|
||||
// 생산관리 > M-BOM 관리 — 단건 상세 + read-only 트리 다이얼로그.
|
||||
// 생산관리 > M-BOM 관리 — 단건 상세 + 트리 (read-only / 편집).
|
||||
//
|
||||
// 운영판 통합:
|
||||
// wace mBomHeaderPopup.jsp (헤더 메타)
|
||||
// + wace mBomPopupLeft.jsp (read-only 트리 — 4분기 자동)
|
||||
// + wace mBomPopupLeft.jsp (좌측 트리 — read-only/편집)
|
||||
//
|
||||
// 4분기 (운영판 mBomPopupLeft.do):
|
||||
// SAVED 저장된 mbom_header.status='Y' 의 트리 (생산정보 포함)
|
||||
// ASSIGNED_EBOM source_bom_type='EBOM' + source_ebom_objid → bom_part_qty 트리
|
||||
// SAVED 저장된 mbom_header.status='Y' 의 트리 (생산정보 포함, 편집 시 UPDATE)
|
||||
// ASSIGNED_EBOM source_bom_type='EBOM' + source_ebom_objid → bom_part_qty 트리 (편집 시 신규 CREATE)
|
||||
// ASSIGNED_MBOM source_bom_type='MBOM' + source_mbom_objid → mbom_detail 구조만
|
||||
// TEMPLATE Machine 이외 + 동일 part_no 의 mbom_header 템플릿
|
||||
// NONE 빈 트리
|
||||
//
|
||||
// 본 편집 / BOM 복사 / 구매리스트 생성 / 변경이력 — PR-B 분리.
|
||||
// PR-B1 — 셀 인라인 편집 + 저장 (운영 saveMbom.do 1:1).
|
||||
// 행 추가/삭제(mBomCenterBtnPopup) / BOM 복사 / 구매리스트 / 변경이력 — PR-B2~ 분리.
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Folder } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Loader2, Folder, Pencil, Save, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow } from "@/lib/api/mbom";
|
||||
import { mbomApi, MbomDetail, MbomTreeResponse, MbomBomDataType, MbomTreeRow, MbomSaveRow } from "@/lib/api/mbom";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectObjid: string | null;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
const BOM_DATA_TYPE_LABEL: Record<MbomBomDataType, { text: string; color: string }> = {
|
||||
@@ -39,38 +42,55 @@ const BOM_DATA_TYPE_LABEL: Record<MbomBomDataType, { text: string; color: string
|
||||
NONE: { text: "BOM 없음", color: "bg-slate-500" },
|
||||
};
|
||||
|
||||
export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) {
|
||||
// 편집 모드에서 사용자가 직접 수정 가능한 필드 (운영판 wace fieldMapping 기반)
|
||||
type EditableField =
|
||||
| "qty" | "supply_type" | "make_or_buy"
|
||||
| "raw_material_no" | "raw_material" | "raw_material_size"
|
||||
| "required_qty" | "order_qty" | "production_qty"
|
||||
| "processing_vendor" | "processing_deadline" | "grinding_deadline"
|
||||
| "remark";
|
||||
|
||||
export function MbomDetailDialog({ open, onOpenChange, projectObjid, onSaved }: Props) {
|
||||
const [detail, setDetail] = useState<MbomDetail | null>(null);
|
||||
const [tree, setTree] = useState<MbomTreeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editableRows, setEditableRows] = useState<MbomTreeRow[]>([]);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !projectObjid) {
|
||||
setDetail(null); setTree(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
const loadTree = (objid: string) => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
mbomApi.getDetail(projectObjid),
|
||||
mbomApi.getTree(projectObjid),
|
||||
return Promise.all([
|
||||
mbomApi.getDetail(objid),
|
||||
mbomApi.getTree(objid),
|
||||
])
|
||||
.then(([d, t]) => {
|
||||
if (!alive) return;
|
||||
setDetail(d);
|
||||
setTree(t);
|
||||
setEditableRows((t?.rows ?? []).map(r => ({ ...r })));
|
||||
setDirty(false);
|
||||
})
|
||||
.catch((e: any) => {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "M-BOM 조회 실패");
|
||||
})
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !projectObjid) {
|
||||
setDetail(null); setTree(null); setEditableRows([]);
|
||||
setEditMode(false); setDirty(false);
|
||||
return;
|
||||
}
|
||||
void loadTree(projectObjid);
|
||||
}, [open, projectObjid]);
|
||||
|
||||
const maxLevel = Math.max(1, tree?.max_level ?? 1);
|
||||
const rows: MbomTreeRow[] = tree?.rows ?? [];
|
||||
const rows = editMode ? editableRows : (tree?.rows ?? []);
|
||||
const bomDataType: MbomBomDataType = tree?.bom_data_type ?? "NONE";
|
||||
const meta = BOM_DATA_TYPE_LABEL[bomDataType];
|
||||
const canEdit = bomDataType !== "NONE";
|
||||
|
||||
const levelHeaders = useMemo(() => {
|
||||
const h: number[] = [];
|
||||
@@ -78,13 +98,91 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) {
|
||||
return h;
|
||||
}, [maxLevel]);
|
||||
|
||||
const updateRow = (idx: number, field: EditableField, value: any) => {
|
||||
setEditableRows(prev => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], [field]: value };
|
||||
return next;
|
||||
});
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const handleEditToggle = () => {
|
||||
if (editMode && dirty) {
|
||||
if (!window.confirm("편집 중인 변경사항이 사라집니다. 취소하시겠습니까?")) return;
|
||||
setEditableRows((tree?.rows ?? []).map(r => ({ ...r })));
|
||||
setDirty(false);
|
||||
}
|
||||
setEditMode(!editMode);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!projectObjid) return;
|
||||
if (rows.length === 0) {
|
||||
toast.error("저장할 트리가 비어있습니다");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const isUpdate = bomDataType === "SAVED";
|
||||
const payload = {
|
||||
project_obj_id: projectObjid,
|
||||
is_update: isUpdate,
|
||||
rows: editableRows.map<MbomSaveRow>(r => ({
|
||||
objid: r.objid,
|
||||
parent_objid: r.parent_objid,
|
||||
child_objid: r.child_objid,
|
||||
seq: r.seq,
|
||||
level: r.level,
|
||||
part_objid: r.part_objid,
|
||||
part_no: r.part_no,
|
||||
part_name: r.part_name,
|
||||
qty: r.qty,
|
||||
item_qty: r.item_qty,
|
||||
unit: r.unit,
|
||||
supply_type: r.supply_type,
|
||||
make_or_buy: r.make_or_buy,
|
||||
raw_material_no: r.raw_material_no,
|
||||
raw_material_spec: r.raw_material_spec,
|
||||
raw_material: r.raw_material,
|
||||
raw_material_size: (r as any).raw_material_size ?? r.size,
|
||||
processing_vendor: r.processing_vendor,
|
||||
processing_deadline: r.processing_deadline,
|
||||
grinding_deadline: r.grinding_deadline,
|
||||
required_qty: r.required_qty,
|
||||
order_qty: r.order_qty,
|
||||
production_qty: r.production_qty,
|
||||
remark: r.remark,
|
||||
})),
|
||||
};
|
||||
const result = await mbomApi.save(payload);
|
||||
toast.success(`M-BOM ${result.mode === "CREATE" ? "생성" : "수정"} 완료 (${result.mbom_no})`);
|
||||
setEditMode(false);
|
||||
setDirty(false);
|
||||
await loadTree(projectObjid);
|
||||
onSaved?.();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={(v) => {
|
||||
if (!v && editMode && dirty) {
|
||||
if (!window.confirm("저장하지 않은 변경사항이 있습니다. 닫으시겠습니까?")) return;
|
||||
}
|
||||
onOpenChange(v);
|
||||
}}>
|
||||
<DialogContent className="max-w-[1600px] w-[97vw] max-h-[92vh] flex flex-col p-0 overflow-hidden">
|
||||
<DialogHeader className="bg-blue-600 px-4 py-3">
|
||||
<DialogTitle className="text-white flex items-center gap-3">
|
||||
<span>M-BOM 관리 — 단건 상세</span>
|
||||
<span>M-BOM 관리 — {editMode ? "본 편집" : "단건 상세"}</span>
|
||||
<span className={cn("rounded px-2 py-0.5 text-xs font-semibold", meta.color)}>{meta.text}</span>
|
||||
{editMode && dirty && (
|
||||
<span className="rounded bg-orange-500 px-2 py-0.5 text-xs font-semibold">변경됨</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -115,6 +213,24 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) {
|
||||
<span className="ml-2 text-muted-foreground/70">BOM_OBJID = {tree.bom_report_objid}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canEdit && !editMode && (
|
||||
<Button size="sm" variant="outline" onClick={handleEditToggle} disabled={loading}>
|
||||
<Pencil className="w-3 h-3 mr-1" /> 본 편집
|
||||
</Button>
|
||||
)}
|
||||
{editMode && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" onClick={handleEditToggle} disabled={saving}>
|
||||
<X className="w-3 h-3 mr-1" /> 취소
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!dirty || saving}>
|
||||
{saving ? <Loader2 className="w-3 h-3 mr-1 animate-spin" /> : <Save className="w-3 h-3 mr-1" />}
|
||||
저장 ({bomDataType === "SAVED" ? "수정" : "신규"})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
@@ -170,24 +286,36 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) {
|
||||
))}
|
||||
<td className="border px-2 py-0.5 whitespace-nowrap">{r.part_no}</td>
|
||||
<td className="border px-2 py-0.5">{r.part_name}</td>
|
||||
<td className="border px-2 py-0.5 text-right">{fmtNum(r.qty)}</td>
|
||||
<EditableNumCell editable={editMode} value={r.qty} onChange={(v) => updateRow(idx, "qty", v)} />
|
||||
<td className="border px-2 py-0.5 text-right">{fmtNum(r.item_qty)}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{r.unit_title ?? r.unit ?? ""}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{r.supply_type ?? ""}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{r.make_or_buy ?? ""}</td>
|
||||
<td className="border px-2 py-0.5">{r.raw_material_no ?? ""}</td>
|
||||
<td className="border px-2 py-0.5">{r.raw_material ?? ""}</td>
|
||||
<td className="border px-2 py-0.5">{r.size ?? ""}</td>
|
||||
<td className="border px-2 py-0.5 text-right">{fmtNum(r.required_qty)}</td>
|
||||
<td className="border px-2 py-0.5 text-right">{fmtNum(r.order_qty)}</td>
|
||||
<td className="border px-2 py-0.5 text-right">{fmtNum(r.production_qty)}</td>
|
||||
<td className="border px-2 py-0.5">{r.processing_vendor_name ?? r.processing_vendor ?? ""}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{r.processing_deadline ?? ""}</td>
|
||||
<td className="border px-2 py-0.5 text-center">{r.grinding_deadline ?? ""}</td>
|
||||
<EditableSelectCell editable={editMode} value={r.supply_type} options={SUPPLY_TYPE_OPTIONS}
|
||||
onChange={(v) => updateRow(idx, "supply_type", v)} />
|
||||
<EditableSelectCell editable={editMode} value={r.make_or_buy} options={MAKE_OR_BUY_OPTIONS}
|
||||
onChange={(v) => updateRow(idx, "make_or_buy", v)} />
|
||||
<EditableTextCell editable={editMode} value={r.raw_material_no}
|
||||
onChange={(v) => updateRow(idx, "raw_material_no", v)} />
|
||||
<EditableTextCell editable={editMode} value={r.raw_material}
|
||||
onChange={(v) => updateRow(idx, "raw_material", v)} />
|
||||
<EditableTextCell editable={editMode} value={(r as any).raw_material_size ?? r.size}
|
||||
onChange={(v) => updateRow(idx, "raw_material_size", v)} />
|
||||
<EditableNumCell editable={editMode} value={r.required_qty}
|
||||
onChange={(v) => updateRow(idx, "required_qty", v)} />
|
||||
<EditableNumCell editable={editMode} value={r.order_qty}
|
||||
onChange={(v) => updateRow(idx, "order_qty", v)} />
|
||||
<EditableNumCell editable={editMode} value={r.production_qty}
|
||||
onChange={(v) => updateRow(idx, "production_qty", v)} />
|
||||
<EditableTextCell editable={editMode} value={r.processing_vendor_name ?? r.processing_vendor}
|
||||
onChange={(v) => updateRow(idx, "processing_vendor", v)} />
|
||||
<EditableDateCell editable={editMode} value={r.processing_deadline}
|
||||
onChange={(v) => updateRow(idx, "processing_deadline", v)} />
|
||||
<EditableDateCell editable={editMode} value={r.grinding_deadline}
|
||||
onChange={(v) => updateRow(idx, "grinding_deadline", v)} />
|
||||
<td className="border px-2 py-0.5 text-center"><FolderCell n={r.cu01_cnt} /></td>
|
||||
<td className="border px-2 py-0.5 text-center"><FolderCell n={r.cu02_cnt} /></td>
|
||||
<td className="border px-2 py-0.5 text-center"><FolderCell n={r.cu03_cnt} /></td>
|
||||
<td className="border px-2 py-0.5">{r.remark ?? ""}</td>
|
||||
<EditableTextCell editable={editMode} value={r.remark}
|
||||
onChange={(v) => updateRow(idx, "remark", v)} />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -204,6 +332,74 @@ export function MbomDetailDialog({ open, onOpenChange, projectObjid }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const SUPPLY_TYPE_OPTIONS = [
|
||||
{ value: "", label: "—" },
|
||||
{ value: "자급", label: "자급" },
|
||||
{ value: "사급", label: "사급" },
|
||||
];
|
||||
const MAKE_OR_BUY_OPTIONS = [
|
||||
{ value: "", label: "—" },
|
||||
{ value: "Make", label: "Make" },
|
||||
{ value: "Buy", label: "Buy" },
|
||||
];
|
||||
|
||||
function EditableNumCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) {
|
||||
if (!editable) return <td className="border px-2 py-0.5 text-right">{fmtNum(value)}</td>;
|
||||
return (
|
||||
<td className="border px-1 py-0.5 text-right">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
className="h-6 px-1 py-0 text-xs text-right border-blue-300 focus-visible:ring-1"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value === "" ? null : e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
function EditableTextCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) {
|
||||
if (!editable) return <td className="border px-2 py-0.5">{value ?? ""}</td>;
|
||||
return (
|
||||
<td className="border px-1 py-0.5">
|
||||
<Input
|
||||
type="text"
|
||||
className="h-6 px-1 py-0 text-xs border-blue-300 focus-visible:ring-1"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
function EditableDateCell({ editable, value, onChange }: { editable: boolean; value: any; onChange: (v: any) => void }) {
|
||||
if (!editable) return <td className="border px-2 py-0.5 text-center">{value ?? ""}</td>;
|
||||
return (
|
||||
<td className="border px-1 py-0.5">
|
||||
<Input
|
||||
type="date"
|
||||
className="h-6 px-1 py-0 text-xs border-blue-300 focus-visible:ring-1"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
function EditableSelectCell({
|
||||
editable, value, options, onChange,
|
||||
}: { editable: boolean; value: any; options: { value: string; label: string }[]; onChange: (v: any) => void }) {
|
||||
if (!editable) return <td className="border px-2 py-0.5 text-center">{value ?? ""}</td>;
|
||||
return (
|
||||
<td className="border px-1 py-0.5">
|
||||
<select
|
||||
className="h-6 px-1 py-0 text-xs w-full border border-blue-300 rounded"
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value || null)}
|
||||
>
|
||||
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderCell({ n }: { n: any }) {
|
||||
const has = Number(n ?? 0) > 0;
|
||||
return (
|
||||
@@ -229,7 +425,6 @@ function fmtNum(v: any): string {
|
||||
if (v == null || v === "") return "";
|
||||
const n = Number(v);
|
||||
if (!isFinite(n)) return String(v);
|
||||
// 정수면 천 단위, 소수가 있으면 그대로 (4자리 까지 표시)
|
||||
return Number.isInteger(n)
|
||||
? n.toLocaleString()
|
||||
: n.toLocaleString(undefined, { maximumFractionDigits: 4 });
|
||||
|
||||
@@ -269,6 +269,8 @@ export const getDirectFileUrl = (filePath: string): string => {
|
||||
|
||||
// 로컬 개발환경
|
||||
if (currentHost === "localhost" || currentHost === "127.0.0.1") {
|
||||
const envOrigin = process.env.NEXT_PUBLIC_API_URL?.replace(/\/api$/, "");
|
||||
if (envOrigin) return `${envOrigin}${filePath}`;
|
||||
return `http://localhost:8080${filePath}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,55 @@ export interface MbomTreeResponse {
|
||||
rows: MbomTreeRow[];
|
||||
}
|
||||
|
||||
// ─── 저장 (PR-B1) ───────────────────────────────────────────
|
||||
// 운영판 saveMbom.do 1:1 — 신규/수정 통합 엔드포인트.
|
||||
// is_update=false → 새 mbom_header + child_objid 재매핑 후 detail 일괄 insert
|
||||
// is_update=true → 기존 mbom_header.objid 조회 → UPSERT + 누락 행 delete
|
||||
|
||||
export interface MbomSaveRow {
|
||||
objid?: string | null;
|
||||
parent_objid?: string | null;
|
||||
child_objid?: string | null;
|
||||
seq?: number | string | null;
|
||||
level?: number | string | null;
|
||||
part_objid?: string | number | null;
|
||||
part_no?: string | null;
|
||||
part_name?: string | null;
|
||||
qty?: number | string | null;
|
||||
item_qty?: number | string | null;
|
||||
unit?: string | null;
|
||||
supply_type?: string | null;
|
||||
make_or_buy?: string | null;
|
||||
raw_material_no?: string | null;
|
||||
raw_material_spec?: string | null;
|
||||
raw_material?: string | null;
|
||||
size?: string | null;
|
||||
raw_material_size?: string | null;
|
||||
processing_vendor?: string | null;
|
||||
processing_deadline?: string | null;
|
||||
grinding_deadline?: string | null;
|
||||
required_qty?: number | string | null;
|
||||
order_qty?: number | string | null;
|
||||
production_qty?: number | string | null;
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
export interface MbomSavePayload {
|
||||
project_obj_id: string;
|
||||
is_update: boolean;
|
||||
mbom_part_no?: string | null;
|
||||
rows: MbomSaveRow[];
|
||||
}
|
||||
|
||||
export interface MbomSaveResult {
|
||||
mode: "CREATE" | "UPDATE";
|
||||
mbom_header_objid: string;
|
||||
mbom_no: string;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
}
|
||||
|
||||
export const mbomApi = {
|
||||
async list(filter: MbomListFilter = {}): Promise<MbomListResponse> {
|
||||
const res = await apiClient.get("/production/mbom/list", { params: filter });
|
||||
@@ -179,4 +228,8 @@ export const mbomApi = {
|
||||
const res = await apiClient.get(`/production/mbom/tree/${encodeURIComponent(objid)}`);
|
||||
return res.data?.data as MbomTreeResponse;
|
||||
},
|
||||
async save(payload: MbomSavePayload): Promise<MbomSaveResult> {
|
||||
const res = await apiClient.post("/production/mbom/save", payload);
|
||||
return res.data?.data as MbomSaveResult;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user