Merge pull request 'jskim-node' (#49) from jskim-node into main
Some checks failed
Build and Push Images / build-and-push (push) Failing after 45s
Some checks failed
Build and Push Images / build-and-push (push) Failing after 45s
Reviewed-on: jskim/vexplor_dev#49
This commit is contained in:
@@ -143,6 +143,73 @@ export async function initializeBomVersion(req: Request, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BOM 복사 (TASK:ERP-028) ─────────────────────────
|
||||
|
||||
/**
|
||||
* POST /bom/:bomId/copy-to-items
|
||||
* 기준 BOM의 트리(편집본)를 대상 품목 N개에 복제
|
||||
* - conflictStrategy = "skip": 대상 품목에 BOM 있으면 skipped[]
|
||||
* - conflictStrategy = "new_version": 대상 품목 BOM에 새 draft 버전 추가 (없으면 새 BOM 생성)
|
||||
*/
|
||||
export async function copyBomToItems(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId } = req.params;
|
||||
const companyCode = (req as any).user?.companyCode || "*";
|
||||
const userId = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||
|
||||
const { targetItemIds, conflictStrategy, editedTree } = req.body || {};
|
||||
|
||||
// ─── 페이로드 검증 ─────────────────────
|
||||
if (!Array.isArray(targetItemIds) || targetItemIds.length === 0) {
|
||||
res.status(400).json({ success: false, message: "targetItemIds는 1개 이상의 배열이어야 합니다" });
|
||||
return;
|
||||
}
|
||||
if (conflictStrategy !== "skip" && conflictStrategy !== "new_version") {
|
||||
res.status(400).json({ success: false, message: "conflictStrategy는 'skip' 또는 'new_version'이어야 합니다" });
|
||||
return;
|
||||
}
|
||||
if (!Array.isArray(editedTree) || editedTree.length === 0) {
|
||||
res.status(400).json({ success: false, message: "editedTree는 1개 이상의 노드 배열이어야 합니다" });
|
||||
return;
|
||||
}
|
||||
|
||||
// 트리 노드 필수 필드 검증
|
||||
for (const n of editedTree) {
|
||||
if (!n || typeof n !== "object") {
|
||||
res.status(400).json({ success: false, message: "editedTree 노드는 객체여야 합니다" });
|
||||
return;
|
||||
}
|
||||
if (!n.tempId) {
|
||||
res.status(400).json({ success: false, message: "editedTree 각 노드에는 tempId가 필요합니다" });
|
||||
return;
|
||||
}
|
||||
if (!n.childItemId) {
|
||||
res.status(400).json({ success: false, message: `노드 ${n.tempId}: childItemId가 필요합니다` });
|
||||
return;
|
||||
}
|
||||
const qty = Number(n.quantity);
|
||||
if (!Number.isFinite(qty) || qty <= 0) {
|
||||
res.status(400).json({ success: false, message: `노드 ${n.tempId}: quantity는 0보다 커야 합니다` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const data = await bomService.copyBomToItems({
|
||||
sourceBomId: bomId,
|
||||
companyCode,
|
||||
userId,
|
||||
targetItemIds,
|
||||
conflictStrategy,
|
||||
editedTree,
|
||||
});
|
||||
|
||||
res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 복사 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── BOM 엑셀 업로드/다운로드 ─────────────────────────
|
||||
|
||||
export async function createBomFromExcel(req: Request, res: Response) {
|
||||
|
||||
@@ -22,6 +22,9 @@ router.post("/excel-upload", bomController.createBomFromExcel);
|
||||
router.post("/:bomId/excel-upload-version", bomController.createBomVersionFromExcel);
|
||||
router.get("/:bomId/excel-download", bomController.downloadBomExcelData);
|
||||
|
||||
// BOM 복사 — 다른 품목으로 전체 트리 복제 (TASK:ERP-028)
|
||||
router.post("/:bomId/copy-to-items", bomController.copyBomToItems);
|
||||
|
||||
// 버전
|
||||
router.get("/:bomId/versions", bomController.getBomVersions);
|
||||
router.post("/:bomId/versions", bomController.createBomVersion);
|
||||
|
||||
@@ -959,3 +959,408 @@ export async function deleteBomDetailSubstitute(id: string) {
|
||||
const sql = `DELETE FROM bom_detail_substitute WHERE id = $1 RETURNING id`;
|
||||
return queryOne(sql, [id]);
|
||||
}
|
||||
|
||||
// ─── BOM 복사 (TASK:ERP-028) ─────────────────────────────
|
||||
|
||||
export interface BomTreeNodeInput {
|
||||
tempId: string;
|
||||
parentTempId: string | null;
|
||||
childItemId: string;
|
||||
quantity: number | string;
|
||||
unit?: string | null;
|
||||
processType?: string | null;
|
||||
lossRate?: number | string | null;
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
export interface CopyBomToItemsParams {
|
||||
sourceBomId: string;
|
||||
companyCode: string;
|
||||
userId: string;
|
||||
targetItemIds: string[]; // item_info.id (UUID)
|
||||
conflictStrategy: "skip" | "new_version";
|
||||
editedTree: BomTreeNodeInput[];
|
||||
}
|
||||
|
||||
export interface CopyBomToItemsResult {
|
||||
success_new: string[];
|
||||
success_versioned: { itemId: string; newVersionName: string }[];
|
||||
skipped: { itemId: string; reason: string }[];
|
||||
failed: { itemId: string; error: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리 위상 정렬 + 무결성 검증
|
||||
* - parentTempId가 가리키는 tempId가 트리 안에 존재해야 함
|
||||
* - 사이클 차단
|
||||
* - 루트 노드(parentTempId=null)는 1개 이상 존재해야 함
|
||||
* 반환: 부모→자식 순서로 정렬된 노드 배열
|
||||
*/
|
||||
export function topoSortAndValidateTree(nodes: BomTreeNodeInput[]): BomTreeNodeInput[] {
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) {
|
||||
throw new Error("트리 노드가 없습니다");
|
||||
}
|
||||
const tempIdSet = new Set<string>();
|
||||
for (const n of nodes) {
|
||||
if (!n.tempId) throw new Error("tempId가 비어있는 노드가 있습니다");
|
||||
if (tempIdSet.has(n.tempId)) throw new Error(`중복 tempId: ${n.tempId}`);
|
||||
tempIdSet.add(n.tempId);
|
||||
}
|
||||
// parentTempId 참조 무결성
|
||||
for (const n of nodes) {
|
||||
if (n.parentTempId && !tempIdSet.has(n.parentTempId)) {
|
||||
throw new Error(`노드 ${n.tempId}의 parentTempId(${n.parentTempId})가 트리에 없습니다`);
|
||||
}
|
||||
}
|
||||
|
||||
// 위상 정렬 (Kahn's algorithm)
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
const indegree = new Map<string, number>();
|
||||
for (const n of nodes) {
|
||||
indegree.set(n.tempId, 0);
|
||||
}
|
||||
for (const n of nodes) {
|
||||
if (n.parentTempId) {
|
||||
indegree.set(n.tempId, (indegree.get(n.tempId) || 0) + 1);
|
||||
const arr = childrenOf.get(n.parentTempId) || [];
|
||||
arr.push(n.tempId);
|
||||
childrenOf.set(n.parentTempId, arr);
|
||||
}
|
||||
}
|
||||
const queue: string[] = [];
|
||||
for (const [id, deg] of indegree.entries()) {
|
||||
if (deg === 0) queue.push(id);
|
||||
}
|
||||
if (queue.length === 0) throw new Error("루트 노드가 없습니다 (사이클 의심)");
|
||||
|
||||
const byId = new Map<string, BomTreeNodeInput>();
|
||||
for (const n of nodes) byId.set(n.tempId, n);
|
||||
|
||||
const sorted: BomTreeNodeInput[] = [];
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()!;
|
||||
sorted.push(byId.get(cur)!);
|
||||
const children = childrenOf.get(cur) || [];
|
||||
for (const c of children) {
|
||||
indegree.set(c, (indegree.get(c) || 0) - 1);
|
||||
if (indegree.get(c) === 0) queue.push(c);
|
||||
}
|
||||
}
|
||||
if (sorted.length !== nodes.length) {
|
||||
throw new Error("트리에 사이클이 있습니다");
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대상 bom_id의 다음 version_name 채번 (충돌 시 +0.1)
|
||||
* - 기존 version_name이 숫자 형식이면 max+0.1
|
||||
* - 아니면 "1.0" 부터 시도
|
||||
*/
|
||||
export async function nextVersionName(client: any, bomId: string): Promise<string> {
|
||||
const res = await client.query(
|
||||
`SELECT version_name FROM bom_version WHERE bom_id = $1`,
|
||||
[bomId],
|
||||
);
|
||||
const names: string[] = res.rows.map((r: any) => String(r.version_name || ""));
|
||||
if (names.length === 0) return "1.0";
|
||||
|
||||
let maxVal = 0;
|
||||
let hasNumeric = false;
|
||||
for (const n of names) {
|
||||
const v = parseFloat(n);
|
||||
if (!Number.isNaN(v)) {
|
||||
hasNumeric = true;
|
||||
if (v > maxVal) maxVal = v;
|
||||
}
|
||||
}
|
||||
if (!hasNumeric) {
|
||||
// 숫자 버전이 하나도 없으면 "1.0" 시도 (existing string 버전들과 충돌 시 별도 처리)
|
||||
if (!names.includes("1.0")) return "1.0";
|
||||
// 1.0도 있으면 1.1, 1.2...
|
||||
let candidate = 1.0;
|
||||
while (names.includes(candidate.toFixed(1))) candidate += 0.1;
|
||||
return candidate.toFixed(1);
|
||||
}
|
||||
const next = (maxVal + 0.1).toFixed(1);
|
||||
// 안전망: next가 이미 있으면 +0.1 반복
|
||||
let nextNum = parseFloat(next);
|
||||
let candidate = nextNum.toFixed(1);
|
||||
while (names.includes(candidate)) {
|
||||
nextNum += 0.1;
|
||||
candidate = nextNum.toFixed(1);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 버전 레코드 + bom_detail 트리 INSERT
|
||||
* - sortedTree는 topoSortAndValidateTree()가 부모→자식 순서로 보장
|
||||
*/
|
||||
export async function insertVersionAndDetails(
|
||||
client: any,
|
||||
params: {
|
||||
bomId: string;
|
||||
versionName: string;
|
||||
revision: number;
|
||||
status: string; // "draft" 등
|
||||
createdBy: string;
|
||||
companyCode: string;
|
||||
sortedTree: BomTreeNodeInput[];
|
||||
},
|
||||
): Promise<{ versionId: string; insertedCount: number }> {
|
||||
const versionRes = await client.query(
|
||||
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
[
|
||||
params.bomId,
|
||||
params.versionName,
|
||||
params.revision,
|
||||
params.status,
|
||||
params.createdBy,
|
||||
params.companyCode,
|
||||
],
|
||||
);
|
||||
const newVersionId = versionRes.rows[0].id;
|
||||
|
||||
// tempId → 새 detail id 매핑 (parent 재배선용)
|
||||
const tempToNewId: Record<string, string> = {};
|
||||
let seq = 1;
|
||||
for (const node of params.sortedTree) {
|
||||
const parentDetailId = node.parentTempId ? (tempToNewId[node.parentTempId] || null) : null;
|
||||
// level 계산: parent가 없으면 1, 있으면 parent level +1
|
||||
// (간단 처리: 정렬 순서대로 매기는 BFS이므로 트리 깊이별 누적이 들어맞음. 정확한 level은 클라이언트가 안 보낸 경우 1로 기본)
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO bom_detail
|
||||
(bom_id, version_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, seq_no, writer, company_code)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING id`,
|
||||
[
|
||||
params.bomId,
|
||||
newVersionId,
|
||||
parentDetailId,
|
||||
node.childItemId,
|
||||
node.quantity != null ? String(node.quantity) : null,
|
||||
node.unit || null,
|
||||
node.processType || null,
|
||||
node.lossRate != null ? String(node.lossRate) : null,
|
||||
node.remark || null,
|
||||
null,
|
||||
null,
|
||||
String(params.revision),
|
||||
String(seq++),
|
||||
params.createdBy,
|
||||
params.companyCode,
|
||||
],
|
||||
);
|
||||
tempToNewId[node.tempId] = insertRes.rows[0].id;
|
||||
}
|
||||
|
||||
return { versionId: newVersionId, insertedCount: params.sortedTree.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* BOM 복사 메인 진입
|
||||
* - 품목 1개당 1트랜잭션 (일부 실패해도 다음 진행)
|
||||
* - 자기 자신(기준 BOM의 item) 차단은 failed[]에 누적
|
||||
*/
|
||||
export async function copyBomToItems(params: CopyBomToItemsParams): Promise<CopyBomToItemsResult> {
|
||||
const { sourceBomId, companyCode, userId, targetItemIds, conflictStrategy, editedTree } = params;
|
||||
|
||||
// 1) 기준 BOM 헤더 조회
|
||||
const sourceBom = await queryOne<Record<string, any>>(
|
||||
`SELECT id, item_id, item_code, item_name, item_type, base_qty, unit, remark, company_code
|
||||
FROM bom WHERE id = $1`,
|
||||
[sourceBomId],
|
||||
);
|
||||
if (!sourceBom) {
|
||||
throw new Error("기준 BOM을 찾을 수 없습니다");
|
||||
}
|
||||
|
||||
// 2) 트리 검증 + 위상정렬 (요청 전체에 1회만 수행 — 동일 트리를 N개 품목에 적용)
|
||||
const sortedTree = topoSortAndValidateTree(editedTree);
|
||||
|
||||
const result: CopyBomToItemsResult = {
|
||||
success_new: [],
|
||||
success_versioned: [],
|
||||
skipped: [],
|
||||
failed: [],
|
||||
};
|
||||
|
||||
const sourceItemId = sourceBom.item_id;
|
||||
const todayStr = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
|
||||
// 3) 대상 품목별 루프 — 품목 1개당 1트랜잭션
|
||||
for (const targetItemId of targetItemIds) {
|
||||
// 자기 자신 차단
|
||||
if (targetItemId === sourceItemId) {
|
||||
result.failed.push({
|
||||
itemId: targetItemId,
|
||||
error: "기준 BOM의 품목 자체로는 복사할 수 없습니다",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 대상 품목 조회 (item_code/item_name 갱신용)
|
||||
const targetItem = await queryOne<Record<string, any>>(
|
||||
`SELECT id, item_number, item_name, unit FROM item_info WHERE id = $1`,
|
||||
[targetItemId],
|
||||
);
|
||||
if (!targetItem) {
|
||||
result.failed.push({ itemId: targetItemId, error: "대상 품목을 찾을 수 없습니다" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 충돌 처리: 대상 품목에 이미 BOM이 있는지 확인
|
||||
const existingBom = await queryOne<{ id: string }>(
|
||||
`SELECT id FROM bom WHERE item_id = $1 ${
|
||||
companyCode !== "*" ? "AND company_code = $2" : ""
|
||||
} LIMIT 1`,
|
||||
companyCode !== "*" ? [targetItemId, companyCode] : [targetItemId],
|
||||
);
|
||||
|
||||
if (conflictStrategy === "skip") {
|
||||
if (existingBom) {
|
||||
result.skipped.push({ itemId: targetItemId, reason: "기존 BOM이 존재하여 스킵" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 신규 BOM 생성 (스킵 모드 — 없을 때만 생성)
|
||||
await transaction(async (client) => {
|
||||
await createNewBomWithTree(client, {
|
||||
companyCode,
|
||||
createdBy: userId,
|
||||
targetItemId,
|
||||
targetItemCode: targetItem.item_number || "",
|
||||
targetItemName: targetItem.item_name || "",
|
||||
sourceBom,
|
||||
sortedTree,
|
||||
todayStr,
|
||||
});
|
||||
});
|
||||
result.success_new.push(targetItemId);
|
||||
} else {
|
||||
// new_version 모드
|
||||
if (existingBom) {
|
||||
// 기존 bom_id에 새 draft 버전 append (race condition 대비 1회 재시도)
|
||||
let attempted = false;
|
||||
let newVersionName = "";
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
await transaction(async (client) => {
|
||||
newVersionName = await nextVersionName(client, existingBom.id);
|
||||
await insertVersionAndDetails(client, {
|
||||
bomId: existingBom.id,
|
||||
versionName: newVersionName,
|
||||
revision: 0,
|
||||
status: "draft",
|
||||
createdBy: userId,
|
||||
companyCode,
|
||||
sortedTree,
|
||||
});
|
||||
// bom.current_version_id는 변경하지 않음 (draft만 추가)
|
||||
});
|
||||
break;
|
||||
} catch (e: any) {
|
||||
if (!attempted && /duplicate|unique|version_name/i.test(e.message || "")) {
|
||||
attempted = true;
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
result.success_versioned.push({ itemId: targetItemId, newVersionName });
|
||||
} else {
|
||||
// 기존 BOM 없음 → 신규 생성
|
||||
await transaction(async (client) => {
|
||||
await createNewBomWithTree(client, {
|
||||
companyCode,
|
||||
createdBy: userId,
|
||||
targetItemId,
|
||||
targetItemCode: targetItem.item_number || "",
|
||||
targetItemName: targetItem.item_name || "",
|
||||
sourceBom,
|
||||
sortedTree,
|
||||
todayStr,
|
||||
});
|
||||
});
|
||||
result.success_new.push(targetItemId);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error("BOM 복사 실패 (품목)", {
|
||||
targetItemId,
|
||||
error: err.message,
|
||||
});
|
||||
result.failed.push({ itemId: targetItemId, error: err.message || "알 수 없는 오류" });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규 BOM 마스터 + 초기 버전 + 트리 INSERT (한 트랜잭션 내부에서 호출)
|
||||
*/
|
||||
async function createNewBomWithTree(
|
||||
client: any,
|
||||
args: {
|
||||
companyCode: string;
|
||||
createdBy: string;
|
||||
targetItemId: string;
|
||||
targetItemCode: string;
|
||||
targetItemName: string;
|
||||
sourceBom: Record<string, any>;
|
||||
sortedTree: BomTreeNodeInput[];
|
||||
todayStr: string;
|
||||
},
|
||||
) {
|
||||
// bom_number 자동 채번: BOM-{YYYYMMDD}-{랜덤4}
|
||||
const rand = Math.floor(1000 + Math.random() * 9000);
|
||||
const bomNumber = `BOM-${args.todayStr.replace(/-/g, "")}-${rand}`;
|
||||
|
||||
const newBomRes = await client.query(
|
||||
`INSERT INTO bom
|
||||
(id, company_code, bom_number, item_id, item_code, item_name, item_type, base_qty, unit, version, revision, status, effective_date, expired_date, remark, writer, created_date, updated_date)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL, $13, $14, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[
|
||||
args.companyCode,
|
||||
bomNumber,
|
||||
args.targetItemId,
|
||||
args.targetItemCode,
|
||||
args.targetItemName,
|
||||
args.sourceBom.item_type || null,
|
||||
args.sourceBom.base_qty || null,
|
||||
args.sourceBom.unit || null,
|
||||
"1.0",
|
||||
"0",
|
||||
"draft",
|
||||
args.todayStr,
|
||||
args.sourceBom.remark || null,
|
||||
args.createdBy,
|
||||
],
|
||||
);
|
||||
const newBomId = newBomRes.rows[0].id;
|
||||
|
||||
// 초기 버전 + 트리 INSERT (version_name="1.0" — 신규 BOM이므로 충돌 없음)
|
||||
const { versionId } = await insertVersionAndDetails(client, {
|
||||
bomId: newBomId,
|
||||
versionName: "1.0",
|
||||
revision: 0,
|
||||
status: "draft",
|
||||
createdBy: args.createdBy,
|
||||
companyCode: args.companyCode,
|
||||
sortedTree: args.sortedTree,
|
||||
});
|
||||
|
||||
// 신규 BOM은 초기 버전이 곧 current_version
|
||||
await client.query(
|
||||
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
|
||||
[versionId, newBomId],
|
||||
);
|
||||
|
||||
return { newBomId, versionId };
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ import {
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@@ -202,6 +205,22 @@ interface TreeNode extends BomDetail {
|
||||
_isVirtualRoot?: boolean; // 가상 루트(BOM 마스터) 노드
|
||||
}
|
||||
|
||||
// 복사 모달 트리 편집기용 행 (평탄화 + 부모 임시 ID로 연결)
|
||||
interface CopyTreeRow {
|
||||
tempId: string;
|
||||
parentTempId: string | null;
|
||||
level: number;
|
||||
childItemId: string;
|
||||
childItemNumber: string;
|
||||
childItemName: string;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
processType: string;
|
||||
lossRate: string;
|
||||
remark: string;
|
||||
_invalid?: boolean; // 수량 0 이하 차단 하이라이트
|
||||
}
|
||||
|
||||
// ─── 트리 구성 헬퍼 ─────────────────────────────
|
||||
function buildTree(details: BomDetail[]): TreeNode[] {
|
||||
const nodeMap = new Map<string, TreeNode>();
|
||||
@@ -380,7 +399,34 @@ export default function BomManagementPage() {
|
||||
const [draggedRowId, setDraggedRowId] = useState<string | null>(null);
|
||||
const [dragOverRowId, setDragOverRowId] = useState<string | null>(null);
|
||||
|
||||
// ─── BOM 복사 모달 (TASK:ERP-028) ───────────────
|
||||
const [copyModalOpen, setCopyModalOpen] = useState(false);
|
||||
// 좌측: 대상 품목 검색/체크
|
||||
const [copySearchKeyword, setCopySearchKeyword] = useState("");
|
||||
const [copyFilteredItems, setCopyFilteredItems] = useState<any[]>([]);
|
||||
const [copySearchLoading, setCopySearchLoading] = useState(false);
|
||||
const [copyPage, setCopyPage] = useState(1);
|
||||
const [copyTotal, setCopyTotal] = useState(0);
|
||||
const [copyCheckedIds, setCopyCheckedIds] = useState<string[]>([]);
|
||||
const [existingBomItemIds, setExistingBomItemIds] = useState<Set<string>>(new Set());
|
||||
// 중앙: 옵션 + 진행률
|
||||
const [copyConflictStrategy, setCopyConflictStrategy] = useState<"skip" | "new_version">("skip");
|
||||
const [copying, setCopying] = useState(false);
|
||||
const [copyProgress, setCopyProgress] = useState({ current: 0, total: 0 });
|
||||
const [copyResult, setCopyResult] = useState({ new: 0, versioned: 0, skipped: 0, failed: 0 });
|
||||
// 우측: 편집 트리 (평탄화된 행 목록)
|
||||
const [copyTreeRows, setCopyTreeRows] = useState<CopyTreeRow[]>([]);
|
||||
// 자식 추가용 품목 검색 모달
|
||||
const [copyChildSearchOpen, setCopyChildSearchOpen] = useState(false);
|
||||
const [copyChildSearchKw, setCopyChildSearchKw] = useState("");
|
||||
const [copyChildSearchResults, setCopyChildSearchResults] = useState<any[]>([]);
|
||||
const [copyChildSearchLoading, setCopyChildSearchLoading] = useState(false);
|
||||
const [copyChildAddTarget, setCopyChildAddTarget] = useState<{ mode: "sibling" | "child" | "root"; rowTempId: string | null }>({ mode: "root", rowTempId: null });
|
||||
const copyPageSize = 20;
|
||||
const copyTotalPages = Math.max(1, Math.ceil(copyTotal / copyPageSize));
|
||||
|
||||
// ─── 데이터 로드 ──────────────────────────────
|
||||
// (디바운스 useEffect는 핸들러 정의 이후에 위치)
|
||||
const fetchBomList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -1087,6 +1133,348 @@ export default function BomManagementPage() {
|
||||
};
|
||||
|
||||
// ─── BOM 삭제 ────────────────────────────────
|
||||
// ─── BOM 복사 모달 핸들러 (TASK:ERP-028) ───────────
|
||||
// 트리(TreeNode[]) → 평탄화된 CopyTreeRow[]
|
||||
const flattenTreeForCopy = useCallback((nodes: TreeNode[], parentTempId: string | null = null, level = 0): CopyTreeRow[] => {
|
||||
const out: CopyTreeRow[] = [];
|
||||
for (const n of nodes) {
|
||||
const tempId = crypto.randomUUID();
|
||||
out.push({
|
||||
tempId,
|
||||
parentTempId,
|
||||
level,
|
||||
childItemId: n.child_item_id || "",
|
||||
childItemNumber: n.item_number || "",
|
||||
childItemName: n.item_name || "",
|
||||
quantity: n.quantity || "1",
|
||||
unit: n.unit || "",
|
||||
processType: n.process_type || "",
|
||||
lossRate: n.loss_rate || "0",
|
||||
remark: n.remark || "",
|
||||
});
|
||||
if (n.children && n.children.length > 0) {
|
||||
out.push(...flattenTreeForCopy(n.children, tempId, level + 1));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, []);
|
||||
|
||||
// 대상 품목별 "기존 BOM 있음" 판정용 set 로드
|
||||
const fetchExistingBomItemIds = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/${BOM_TABLE}/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
const ids = new Set<string>(rows.map((r: any) => r.item_id).filter(Boolean));
|
||||
setExistingBomItemIds(ids);
|
||||
} catch {
|
||||
setExistingBomItemIds(new Set());
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 대상 품목 검색 (좌측 목록)
|
||||
const searchCopyTargets = useCallback(async (page?: number, kw?: string) => {
|
||||
const p = page ?? copyPage;
|
||||
const keyword = (kw ?? copySearchKeyword).trim();
|
||||
setCopySearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (keyword) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: keyword });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
let resData = res.data?.data;
|
||||
let rows = resData?.data || resData?.rows || [];
|
||||
|
||||
// 품명으로 못 찾으면 품목코드로 재시도
|
||||
if (keyword && rows.length === 0) {
|
||||
const res2 = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: p, size: copyPageSize,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: keyword }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
resData = res2.data?.data;
|
||||
rows = resData?.data || resData?.rows || [];
|
||||
}
|
||||
|
||||
// 기준 BOM의 item_id는 자기 자신이므로 제외
|
||||
const sourceItemId = bomHeader?.item_id || "";
|
||||
const filtered = rows.filter((r: any) => r.id !== sourceItemId);
|
||||
setCopyFilteredItems(filtered);
|
||||
setCopyTotal(resData?.total || resData?.totalCount || filtered.length);
|
||||
} catch {
|
||||
setCopyFilteredItems([]);
|
||||
setCopyTotal(0);
|
||||
} finally {
|
||||
setCopySearchLoading(false);
|
||||
}
|
||||
}, [copyPage, copySearchKeyword, bomHeader]);
|
||||
|
||||
const openCopyModal = async () => {
|
||||
if (!selectedBomId || !bomHeader) {
|
||||
toast.error("복사 기준 BOM을 먼저 선택해주세요");
|
||||
return;
|
||||
}
|
||||
// 트리 초기값: 현재 화면의 트리(편집본 우선, 없으면 원본)
|
||||
const baseTree = editingTree.length > 0 ? editingTree : treeNodes;
|
||||
const flat = flattenTreeForCopy(baseTree, null, 0);
|
||||
setCopyTreeRows(flat);
|
||||
setCopySearchKeyword("");
|
||||
setCopyPage(1);
|
||||
setCopyCheckedIds([]);
|
||||
setCopyConflictStrategy("skip");
|
||||
setCopyProgress({ current: 0, total: 0 });
|
||||
setCopyResult({ new: 0, versioned: 0, skipped: 0, failed: 0 });
|
||||
setCopyModalOpen(true);
|
||||
await fetchExistingBomItemIds();
|
||||
searchCopyTargets(1, "");
|
||||
};
|
||||
|
||||
const handleCopySearch = () => {
|
||||
setCopyPage(1);
|
||||
searchCopyTargets(1);
|
||||
};
|
||||
|
||||
const toggleCopyChecked = (itemId: string) => {
|
||||
setCopyCheckedIds((prev) => prev.includes(itemId) ? prev.filter((c) => c !== itemId) : [...prev, itemId]);
|
||||
};
|
||||
|
||||
// 트리 행 수정
|
||||
const updateCopyTreeRow = (tempId: string, patch: Partial<CopyTreeRow>) => {
|
||||
setCopyTreeRows((prev) => prev.map((r) => r.tempId === tempId ? { ...r, ...patch, _invalid: false } : r));
|
||||
};
|
||||
|
||||
// 트리 행 삭제 (자손 포함)
|
||||
const deleteCopyTreeRow = (tempId: string) => {
|
||||
setCopyTreeRows((prev) => {
|
||||
const toRemove = new Set<string>([tempId]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const r of prev) {
|
||||
if (r.parentTempId && toRemove.has(r.parentTempId) && !toRemove.has(r.tempId)) {
|
||||
toRemove.add(r.tempId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return prev.filter((r) => !toRemove.has(r.tempId));
|
||||
});
|
||||
};
|
||||
|
||||
// 자식/형제/루트 행 추가 — 품목 검색 모달 오픈
|
||||
const openCopyChildSearch = (mode: "sibling" | "child" | "root", rowTempId: string | null) => {
|
||||
setCopyChildAddTarget({ mode, rowTempId });
|
||||
setCopyChildSearchKw("");
|
||||
setCopyChildSearchResults([]);
|
||||
setCopyChildSearchOpen(true);
|
||||
// 빈 검색 즉시 1페이지 로드
|
||||
void searchCopyChildItems("");
|
||||
};
|
||||
|
||||
const searchCopyChildItems = async (kw: string) => {
|
||||
setCopyChildSearchLoading(true);
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
const keyword = kw.trim();
|
||||
if (keyword) {
|
||||
filters.push({ columnName: "item_name", operator: "contains", value: keyword });
|
||||
}
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 30,
|
||||
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
||||
autoFilter: true,
|
||||
});
|
||||
let rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
if (keyword && rows.length === 0) {
|
||||
const res2 = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 30,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "contains", value: keyword }] },
|
||||
autoFilter: true,
|
||||
});
|
||||
rows = res2.data?.data?.data || res2.data?.data?.rows || [];
|
||||
}
|
||||
// 기준 BOM의 item_id는 자식 후보에서 제외 (재귀 차단)
|
||||
const sourceItemId = bomHeader?.item_id || "";
|
||||
setCopyChildSearchResults(rows.filter((r: any) => r.id !== sourceItemId));
|
||||
} catch {
|
||||
setCopyChildSearchResults([]);
|
||||
} finally {
|
||||
setCopyChildSearchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 검색 결과에서 품목 선택 → 트리에 행 추가
|
||||
const selectCopyChildItem = (item: any) => {
|
||||
const tempId = crypto.randomUUID();
|
||||
const target = copyChildAddTarget;
|
||||
const newRow: CopyTreeRow = {
|
||||
tempId,
|
||||
parentTempId: null,
|
||||
level: 0,
|
||||
childItemId: item.id,
|
||||
childItemNumber: item.item_number || "",
|
||||
childItemName: item.item_name || "",
|
||||
quantity: "1",
|
||||
unit: item.inventory_unit || "",
|
||||
processType: "",
|
||||
lossRate: "0",
|
||||
remark: "",
|
||||
};
|
||||
|
||||
setCopyTreeRows((prev) => {
|
||||
if (target.mode === "root" || !target.rowTempId) {
|
||||
newRow.parentTempId = null;
|
||||
newRow.level = 0;
|
||||
return [...prev, newRow];
|
||||
}
|
||||
const baseRow = prev.find((r) => r.tempId === target.rowTempId);
|
||||
if (!baseRow) return prev;
|
||||
if (target.mode === "child") {
|
||||
newRow.parentTempId = baseRow.tempId;
|
||||
newRow.level = baseRow.level + 1;
|
||||
// baseRow 바로 아래(같은 부모 마지막)에 삽입
|
||||
const idx = prev.findIndex((r) => r.tempId === baseRow.tempId);
|
||||
// baseRow의 자손 마지막 위치 찾기
|
||||
let insertIdx = idx + 1;
|
||||
const descendants = new Set<string>([baseRow.tempId]);
|
||||
for (let i = idx + 1; i < prev.length; i++) {
|
||||
if (prev[i].parentTempId && descendants.has(prev[i].parentTempId!)) {
|
||||
descendants.add(prev[i].tempId);
|
||||
insertIdx = i + 1;
|
||||
} else if (prev[i].level <= baseRow.level) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [...prev.slice(0, insertIdx), newRow, ...prev.slice(insertIdx)];
|
||||
}
|
||||
// sibling
|
||||
newRow.parentTempId = baseRow.parentTempId;
|
||||
newRow.level = baseRow.level;
|
||||
const idx = prev.findIndex((r) => r.tempId === baseRow.tempId);
|
||||
// baseRow의 자손 끝나는 위치
|
||||
let insertIdx = idx + 1;
|
||||
const descendants = new Set<string>([baseRow.tempId]);
|
||||
for (let i = idx + 1; i < prev.length; i++) {
|
||||
if (prev[i].parentTempId && descendants.has(prev[i].parentTempId!)) {
|
||||
descendants.add(prev[i].tempId);
|
||||
insertIdx = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [...prev.slice(0, insertIdx), newRow, ...prev.slice(insertIdx)];
|
||||
});
|
||||
setCopyChildSearchOpen(false);
|
||||
};
|
||||
|
||||
// 복사 실행
|
||||
const handleCopyExecute = async () => {
|
||||
if (!selectedBomId || !bomHeader) {
|
||||
toast.error("기준 BOM이 선택되어 있지 않아요");
|
||||
return;
|
||||
}
|
||||
if (copyCheckedIds.length === 0) {
|
||||
toast.error("복사할 대상 품목을 선택해주세요");
|
||||
return;
|
||||
}
|
||||
if (copyTreeRows.length === 0) {
|
||||
toast.error("최소 1개 이상의 자품목이 필요합니다");
|
||||
return;
|
||||
}
|
||||
// 수량 검증 — quantity <= 0 또는 빈 값 차단
|
||||
const invalidRows = copyTreeRows.filter((r) => {
|
||||
const q = Number(r.quantity);
|
||||
return !Number.isFinite(q) || q <= 0;
|
||||
});
|
||||
if (invalidRows.length > 0) {
|
||||
const ids = new Set(invalidRows.map((r) => r.tempId));
|
||||
setCopyTreeRows((prev) => prev.map((r) => ({ ...r, _invalid: ids.has(r.tempId) })));
|
||||
toast.error("수량은 1 이상이어야 합니다 (해당 행 강조 표시)");
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = await confirm(
|
||||
`선택한 ${copyCheckedIds.length}개 품목에 편집된 BOM 트리(${copyTreeRows.length}개 행)를 복사할까요?`,
|
||||
{
|
||||
description: copyConflictStrategy === "skip"
|
||||
? "이미 BOM 있는 품목은 건너뜁니다."
|
||||
: "이미 BOM 있는 품목은 새 draft 버전으로 추가됩니다.",
|
||||
variant: "info",
|
||||
confirmText: "복사",
|
||||
}
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
setCopying(true);
|
||||
setCopyProgress({ current: 0, total: copyCheckedIds.length });
|
||||
setCopyResult({ new: 0, versioned: 0, skipped: 0, failed: 0 });
|
||||
try {
|
||||
// 백엔드는 N개 품목을 한 번에 처리 — 진행률은 즉시 100%로 마감
|
||||
const body = {
|
||||
targetItemIds: copyCheckedIds,
|
||||
conflictStrategy: copyConflictStrategy,
|
||||
editedTree: copyTreeRows.map((r) => ({
|
||||
tempId: r.tempId,
|
||||
parentTempId: r.parentTempId,
|
||||
childItemId: r.childItemId,
|
||||
quantity: Number(r.quantity),
|
||||
unit: r.unit || "",
|
||||
processType: r.processType || null,
|
||||
lossRate: r.lossRate ? Number(r.lossRate) : null,
|
||||
remark: r.remark || null,
|
||||
})),
|
||||
};
|
||||
const res = await apiClient.post(`/bom/${selectedBomId}/copy-to-items`, body);
|
||||
const data = res.data?.data || {};
|
||||
const newCount = (data.success_new || []).length;
|
||||
const verCount = (data.success_versioned || []).length;
|
||||
const skipCount = (data.skipped || []).length;
|
||||
const failCount = (data.failed || []).length;
|
||||
setCopyResult({ new: newCount, versioned: verCount, skipped: skipCount, failed: failCount });
|
||||
setCopyProgress({ current: copyCheckedIds.length, total: copyCheckedIds.length });
|
||||
|
||||
toast.success(`신규 ${newCount}건 / 새 버전 ${verCount}건 / 스킵 ${skipCount}건 / 실패 ${failCount}건`);
|
||||
if (failCount === 0 && (newCount + verCount) > 0) {
|
||||
// 완전 성공 시 모달 닫고 목록 새로고침
|
||||
setTimeout(() => setCopyModalOpen(false), 400);
|
||||
fetchBomList();
|
||||
} else {
|
||||
fetchBomList();
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message || "BOM 복사에 실패했어요");
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 복사 모달 — 좌측 검색 디바운스 (200ms)
|
||||
useEffect(() => {
|
||||
if (!copyModalOpen) return;
|
||||
const handle = setTimeout(() => {
|
||||
setCopyPage(1);
|
||||
searchCopyTargets(1, copySearchKeyword);
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [copySearchKeyword, copyModalOpen]);
|
||||
|
||||
// 복사 모달 — 자식 추가 검색 디바운스 (200ms)
|
||||
useEffect(() => {
|
||||
if (!copyChildSearchOpen) return;
|
||||
const handle = setTimeout(() => {
|
||||
void searchCopyChildItems(copyChildSearchKw);
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [copyChildSearchKw, copyChildSearchOpen]);
|
||||
|
||||
const handleDeleteBom = async () => {
|
||||
if (checkedIds.length === 0) {
|
||||
toast.error("삭제할 BOM을 선택해주세요");
|
||||
@@ -1739,6 +2127,16 @@ export default function BomManagementPage() {
|
||||
<Pencil className="w-3.5 h-3.5 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={openCopyModal}
|
||||
disabled={!selectedBomId || !bomHeader}
|
||||
title="기준 BOM을 다른 품목들에 복사"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5 mr-1" />
|
||||
복사
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-border mx-0.5" />
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -2982,6 +3380,307 @@ export default function BomManagementPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ─── BOM 복사 모달 (TASK:ERP-028) ─── */}
|
||||
<Dialog open={copyModalOpen} onOpenChange={(o) => { if (!copying) setCopyModalOpen(o); }}>
|
||||
<DialogContent className="max-w-[1280px] w-[95vw] max-h-[90vh] p-0 flex flex-col gap-0">
|
||||
<DialogHeader className="px-5 py-3 border-b">
|
||||
<DialogTitle className="text-base">BOM 복사 — 기준 BOM을 다른 품목들로 복제</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
좌측에서 대상 품목을 선택하고, 중앙에서 충돌 처리 방식을 고른 뒤, 우측 트리를 편집하여 복사합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
{/* 좌측: 대상 품목 선택 */}
|
||||
<div className="w-[360px] shrink-0 border-r flex flex-col min-h-0">
|
||||
<div className="px-3 py-2 border-b bg-muted/40 shrink-0">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<Label className="text-xs font-semibold">대상 품목 (체크 다중)</Label>
|
||||
<span className="text-[11px] text-muted-foreground">선택 {copyCheckedIds.length}건</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/60" />
|
||||
<Input
|
||||
className="h-8 text-xs pl-7"
|
||||
placeholder="품명/품목코드로 검색"
|
||||
value={copySearchKeyword}
|
||||
onChange={(e) => setCopySearchKeyword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{copySearchLoading && copyFilteredItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-10 text-muted-foreground text-xs gap-1.5">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> 불러오는 중...
|
||||
</div>
|
||||
) : copyFilteredItems.length === 0 ? (
|
||||
<div className="text-center py-10 text-muted-foreground text-xs">검색 결과가 없어요</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{copyFilteredItems.map((it: any) => {
|
||||
const checked = copyCheckedIds.includes(it.id);
|
||||
const hasExisting = existingBomItemIds.has(it.id);
|
||||
return (
|
||||
<div
|
||||
key={it.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 text-xs cursor-pointer hover:bg-accent/40",
|
||||
checked && "bg-primary/5"
|
||||
)}
|
||||
onClick={() => toggleCopyChecked(it.id)}
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={() => toggleCopyChecked(it.id)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{it.item_number || "-"}</span>
|
||||
{hasExisting && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 bg-warning/15 text-warning">기존 BOM</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate font-medium">{it.item_name || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-t bg-muted/20 shrink-0 text-[11px] text-muted-foreground">
|
||||
<span>총 {copyTotal}건</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" className="h-6 px-2 text-[11px]" disabled={copyPage <= 1 || copySearchLoading}
|
||||
onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }}>
|
||||
이전
|
||||
</Button>
|
||||
<span className="font-mono">{copyPage} / {copyTotalPages}</span>
|
||||
<Button size="sm" variant="ghost" className="h-6 px-2 text-[11px]" disabled={copyPage >= copyTotalPages || copySearchLoading}
|
||||
onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }}>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 복사 옵션 */}
|
||||
<div className="w-[280px] shrink-0 border-r flex flex-col min-h-0">
|
||||
<div className="px-3 py-2 border-b bg-muted/40 shrink-0">
|
||||
<Label className="text-xs font-semibold">복사 옵션</Label>
|
||||
</div>
|
||||
<div className="p-3 space-y-4 overflow-auto">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">충돌 처리</Label>
|
||||
<RadioGroup
|
||||
value={copyConflictStrategy}
|
||||
onValueChange={(v) => setCopyConflictStrategy(v as "skip" | "new_version")}
|
||||
className="space-y-1.5"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<RadioGroupItem value="skip" id="copy-skip" className="mt-0.5" />
|
||||
<Label htmlFor="copy-skip" className="text-xs cursor-pointer leading-tight">
|
||||
스킵 <span className="text-muted-foreground block text-[10px]">기존 BOM 있으면 건너뜀</span>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<RadioGroupItem value="new_version" id="copy-newver" className="mt-0.5" />
|
||||
<Label htmlFor="copy-newver" className="text-xs cursor-pointer leading-tight">
|
||||
새 버전으로 추가 <span className="text-muted-foreground block text-[10px]">기존 BOM에 draft 버전 append</span>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/30 p-2.5 space-y-1.5">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">신규 헤더 (자동 적용)</Label>
|
||||
<div className="text-[11px] grid grid-cols-2 gap-x-2 gap-y-0.5">
|
||||
<span className="text-muted-foreground">새 버전</span><span className="font-mono">1.0</span>
|
||||
<span className="text-muted-foreground">상태</span><span className="font-mono">draft</span>
|
||||
<span className="text-muted-foreground">적용일</span><span className="font-mono">TODAY</span>
|
||||
<span className="text-muted-foreground">만료일</span><span className="font-mono">없음</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행률 + 결과 */}
|
||||
{(copying || copyProgress.total > 0) && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">진행률</Label>
|
||||
<Progress value={copyProgress.total > 0 ? (copyProgress.current / copyProgress.total) * 100 : 0} className="h-1.5" />
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{copyProgress.current} / {copyProgress.total}건 {copying && "처리 중..."}
|
||||
</div>
|
||||
{(copyResult.new + copyResult.versioned + copyResult.skipped + copyResult.failed) > 0 && (
|
||||
<div className="grid grid-cols-2 gap-1 text-[11px] mt-1">
|
||||
<span className="text-emerald-600">신규 {copyResult.new}건</span>
|
||||
<span className="text-blue-600">새 버전 {copyResult.versioned}건</span>
|
||||
<span className="text-amber-600">스킵 {copyResult.skipped}건</span>
|
||||
<span className="text-destructive">실패 {copyResult.failed}건</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 트리 편집기 */}
|
||||
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||
<div className="px-3 py-2 border-b bg-muted/40 shrink-0 flex items-center justify-between">
|
||||
<Label className="text-xs font-semibold">기준 BOM 트리 (편집) — 대상 N개에 일괄 적용</Label>
|
||||
<Button size="sm" variant="outline" className="h-7 text-[11px]" onClick={() => openCopyChildSearch("root", null)}>
|
||||
<Plus className="w-3 h-3 mr-1" /> 루트 행 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 기준 BOM 정보 readonly 카드 */}
|
||||
<div className="px-3 py-2 border-b bg-background shrink-0 text-[11px] grid grid-cols-4 gap-2">
|
||||
<div><span className="text-muted-foreground">품목코드</span><div className="font-mono truncate">{bomHeader?.item_code || "-"}</div></div>
|
||||
<div><span className="text-muted-foreground">품명</span><div className="truncate">{bomHeader?.item_name || "-"}</div></div>
|
||||
<div><span className="text-muted-foreground">BOM 유형</span><div>{BOM_TYPE_OPTIONS.find((o) => o.code === bomHeader?.bom_type)?.label || bomHeader?.bom_type || "-"}</div></div>
|
||||
<div><span className="text-muted-foreground">기준수량/단위</span><div className="font-mono">{bomHeader?.base_qty || "-"} {bomHeader?.unit || ""}</div></div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{copyTreeRows.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground gap-2 text-xs">
|
||||
<Inbox className="w-8 h-8 text-muted-foreground/40" />
|
||||
트리 행이 비어 있어요. 우상단 [루트 행 추가]로 시작하세요.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted z-10">
|
||||
<TableRow>
|
||||
<TableHead className="text-[11px] w-[260px]">품목</TableHead>
|
||||
<TableHead className="text-[11px] w-[80px]">수량</TableHead>
|
||||
<TableHead className="text-[11px] w-[80px]">단위</TableHead>
|
||||
<TableHead className="text-[11px] w-[120px]">공정</TableHead>
|
||||
<TableHead className="text-[11px] w-[80px]">손실율(%)</TableHead>
|
||||
<TableHead className="text-[11px]">비고</TableHead>
|
||||
<TableHead className="text-[11px] w-[120px]">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{copyTreeRows.map((r) => (
|
||||
<TableRow key={r.tempId} className={cn(r._invalid && "bg-destructive/10")}>
|
||||
<TableCell className="text-[12px]" style={{ paddingLeft: `${12 + r.level * 16}px` }}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{r.childItemNumber || "-"}</span>
|
||||
<span className="truncate">{r.childItemName || r.childItemId}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input className="h-7 text-xs" type="number" min="0.0001" step="0.01" value={r.quantity}
|
||||
onChange={(e) => updateCopyTreeRow(r.tempId, { quantity: e.target.value })} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input className="h-7 text-xs" value={r.unit}
|
||||
onChange={(e) => updateCopyTreeRow(r.tempId, { unit: e.target.value })} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SmartSelect
|
||||
value={r.processType || ""}
|
||||
onValueChange={(v) => updateCopyTreeRow(r.tempId, { processType: v })}
|
||||
options={processOptions}
|
||||
placeholder="공정"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input className="h-7 text-xs" type="number" min="0" step="0.1" value={r.lossRate}
|
||||
onChange={(e) => updateCopyTreeRow(r.tempId, { lossRate: e.target.value })} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input className="h-7 text-xs" value={r.remark}
|
||||
onChange={(e) => updateCopyTreeRow(r.tempId, { remark: e.target.value })} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" title="자식 행 추가"
|
||||
onClick={() => openCopyChildSearch("child", r.tempId)}>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0" title="형제 행 추가"
|
||||
onClick={() => openCopyChildSearch("sibling", r.tempId)}>
|
||||
<GitBranch className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="h-6 w-6 p-0 text-destructive hover:text-destructive" title="삭제"
|
||||
onClick={() => deleteCopyTreeRow(r.tempId)}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="px-5 py-3 border-t shrink-0">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
대상 {copyCheckedIds.length}건 · 트리 {copyTreeRows.length}행 · 충돌 처리 {copyConflictStrategy === "skip" ? "스킵" : "새 버전"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}>취소</Button>
|
||||
<Button onClick={handleCopyExecute} disabled={copying}>
|
||||
{copying ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : <Copy className="w-3.5 h-3.5 mr-1" />}
|
||||
복사 실행
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 복사 모달 — 자식/형제/루트 행 추가용 품목 검색 */}
|
||||
<Dialog open={copyChildSearchOpen} onOpenChange={setCopyChildSearchOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base">자품목 검색 — 클릭해서 추가</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{copyChildAddTarget.mode === "root" && "루트(레벨 0)에 새 자품목을 추가합니다"}
|
||||
{copyChildAddTarget.mode === "child" && "선택한 행의 하위 자식으로 추가합니다"}
|
||||
{copyChildAddTarget.mode === "sibling" && "선택한 행과 같은 레벨의 형제로 추가합니다"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground/60" />
|
||||
<Input className="h-8 text-xs pl-7" placeholder="품명/품목코드 검색"
|
||||
value={copyChildSearchKw} onChange={(e) => setCopyChildSearchKw(e.target.value)} />
|
||||
</div>
|
||||
<div className="border rounded-md max-h-80 overflow-auto">
|
||||
{copyChildSearchLoading ? (
|
||||
<div className="flex items-center justify-center py-6 text-muted-foreground text-xs gap-1.5">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> 불러오는 중...
|
||||
</div>
|
||||
) : copyChildSearchResults.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground text-xs">검색 결과가 없어요</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr><th className="px-2 py-1.5 text-left w-32">품번</th><th className="px-2 py-1.5 text-left">품명</th><th className="px-2 py-1.5 w-10"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{copyChildSearchResults.map((it: any) => (
|
||||
<tr key={it.id} className="border-t cursor-pointer hover:bg-primary/5" onClick={() => selectCopyChildItem(it)}>
|
||||
<td className="px-2 py-1.5 font-mono text-[11px]">{it.item_number || "-"}</td>
|
||||
<td className="px-2 py-1.5">{it.item_name || "-"}</td>
|
||||
<td className="px-2 py-1.5 text-right"><Plus className="w-3 h-3 inline text-primary" /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCopyChildSearchOpen(false)}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<TableSettingsModal
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
"use client";
|
||||
import { EquipmentHome } from "@/components/pop/hardcoded/equipment/EquipmentHome";
|
||||
|
||||
export default function EquipmentPage() {
|
||||
return <EquipmentHome />;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { InboundCartPage } from "@/components/pop/hardcoded/inbound/InboundCartPage";
|
||||
|
||||
export default function InboundCartRoute() {
|
||||
return (
|
||||
<PopShell showBanner={false} title="입고 장바구니">
|
||||
<InboundCartPage />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { PurchaseInbound } from "@/components/pop/hardcoded/inbound";
|
||||
import { useCartSync } from "@/components/pop/hardcoded/common/useCartSync";
|
||||
|
||||
export default function PurchaseInboundPage() {
|
||||
const router = useRouter();
|
||||
const cart = useCartSync("pop-purchase-inbound", "purchase_detail");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleCartClick = async () => {
|
||||
if (cart.isDirty) {
|
||||
setSaving(true);
|
||||
const ok = await cart.saveToDb();
|
||||
setSaving(false);
|
||||
if (!ok) return; // save failed, don't navigate
|
||||
}
|
||||
router.push("/pop/inbound/cart");
|
||||
};
|
||||
|
||||
return (
|
||||
<PopShell
|
||||
showBanner={false}
|
||||
title="구매입고"
|
||||
headerRight={
|
||||
<button
|
||||
onClick={handleCartClick}
|
||||
disabled={saving}
|
||||
className="relative w-11 h-11 rounded-xl bg-white/10 flex items-center justify-center text-white hover:bg-white/20 active:scale-95 transition-all"
|
||||
>
|
||||
{saving ? (
|
||||
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
|
||||
</svg>
|
||||
)}
|
||||
{cart.cartCount > 0 && (
|
||||
<span className={`absolute -top-1 -right-1 min-w-[20px] h-5 rounded-full text-[10px] font-bold text-white flex items-center justify-center
|
||||
${cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"}
|
||||
`}>
|
||||
{cart.cartCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<PurchaseInbound cart={cart} />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { OutboundCartPage } from "@/components/pop/hardcoded/outbound/OutboundCartPage";
|
||||
|
||||
export default function OutboundCartRoute() {
|
||||
return (
|
||||
<PopShell showBanner={false} title="출고 장바구니">
|
||||
<OutboundCartPage />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PopShell } from "@/components/pop/hardcoded";
|
||||
import { SalesOutbound } from "@/components/pop/hardcoded/outbound";
|
||||
import { useCartSync } from "@/components/pop/hardcoded/common/useCartSync";
|
||||
|
||||
export default function SalesOutboundPage() {
|
||||
const router = useRouter();
|
||||
const cart = useCartSync("pop-sales-outbound", "shipment_instruction_detail");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleCartClick = async () => {
|
||||
if (cart.isDirty) {
|
||||
setSaving(true);
|
||||
const ok = await cart.saveToDb();
|
||||
setSaving(false);
|
||||
if (!ok) return;
|
||||
}
|
||||
router.push("/pop/outbound/cart");
|
||||
};
|
||||
|
||||
return (
|
||||
<PopShell
|
||||
showBanner={false}
|
||||
title="판매출고"
|
||||
headerRight={
|
||||
<button
|
||||
onClick={handleCartClick}
|
||||
disabled={saving}
|
||||
className="relative w-11 h-11 rounded-xl bg-white/10 flex items-center justify-center text-white hover:bg-white/20 active:scale-95 transition-all"
|
||||
>
|
||||
{saving ? (
|
||||
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 00-3 3h15.75m-12.75-3h11.218c1.121 0 2.002-.881 2.002-2.003V6.75m-14.22 0h14.22" />
|
||||
</svg>
|
||||
)}
|
||||
{cart.cartCount > 0 && (
|
||||
<span className={`absolute -top-1 -right-1 min-w-[20px] h-5 rounded-full text-[10px] font-bold text-white flex items-center justify-center
|
||||
${cart.isDirty ? "bg-orange-500 animate-pulse" : "bg-red-500"}
|
||||
`}>
|
||||
{cart.cartCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<SalesOutbound cart={cart} />
|
||||
</PopShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user