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

Reviewed-on: jskim/vexplor_dev#49
This commit is contained in:
2026-05-11 04:28:19 +00:00
9 changed files with 1174 additions and 144 deletions

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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 };
}

View File

@@ -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

View File

@@ -1,6 +0,0 @@
"use client";
import { EquipmentHome } from "@/components/pop/hardcoded/equipment/EquipmentHome";
export default function EquipmentPage() {
return <EquipmentHome />;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}