Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
@@ -209,6 +209,17 @@ function flattenTree(nodes: TreeNode[], level = 0): (TreeNode & { _level: number
|
||||
}
|
||||
|
||||
// ─── 타입 뱃지 색상 ────────────────────────────
|
||||
// BOM 이력 변경 유형 색상
|
||||
const CHANGE_TYPE_STYLE: Record<string, string> = {
|
||||
"등록": "bg-primary/10 text-primary ring-primary/20",
|
||||
"수정": "bg-amber-50 text-amber-600 ring-amber-200",
|
||||
"추가": "bg-emerald-50 text-emerald-600 ring-emerald-200",
|
||||
"삭제": "bg-destructive/10 text-destructive ring-red-200",
|
||||
"버전변경": "bg-blue-50 text-blue-600 ring-blue-200",
|
||||
"excel_upload": "bg-blue-50 text-blue-600 ring-blue-200",
|
||||
};
|
||||
const CHANGE_TYPE_OPTIONS = ["등록", "수정", "추가", "삭제", "버전변경", "기타"];
|
||||
|
||||
function getItemTypeBadge(type?: string) {
|
||||
switch (type) {
|
||||
case "원자재":
|
||||
@@ -762,6 +773,37 @@ export default function BomManagementPage() {
|
||||
|
||||
// 트리 품목 선택 완료 (트리에 추가)
|
||||
const handleTreeItemSelect = (item: any) => {
|
||||
// 1. 자기 자신 추가 방지 (BOM 마스터 품목과 동일한 품목)
|
||||
if (bomHeader && (item.id === bomHeader.item_id || item.item_number === bomHeader.item_code)) {
|
||||
toast.error("자기 자신을 BOM에 추가할 수 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 같은 경로에 동일 품목 중복 방지 (조상 노드에 같은 품목이 있으면 순환)
|
||||
const getAncestorItemIds = (tree: TreeNode[], targetId: string | null): Set<string> => {
|
||||
const ids = new Set<string>();
|
||||
if (!targetId) return ids;
|
||||
const collectPath = (nodes: TreeNode[], path: TreeNode[]): boolean => {
|
||||
for (const n of nodes) {
|
||||
const nk = n.id || n._tempId || "";
|
||||
if (nk === targetId) {
|
||||
path.forEach(p => { if (p.child_item_id) ids.add(p.child_item_id); });
|
||||
if (n.child_item_id) ids.add(n.child_item_id);
|
||||
return true;
|
||||
}
|
||||
if (n.children.length > 0 && collectPath(n.children, [...path, n])) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
collectPath(tree, []);
|
||||
return ids;
|
||||
};
|
||||
const ancestorIds = getAncestorItemIds(editingTree, addTargetParentId);
|
||||
if (ancestorIds.has(item.id)) {
|
||||
toast.error("상위 경로에 이미 동일 품목이 있어 순환 참조가 발생합니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const tempId = `temp_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
const parentNode = addTargetParentId ? findNodeById(editingTree, addTargetParentId) : null;
|
||||
const newLevel = parentNode ? ((parentNode._level ?? parentNode.level ?? 0) as number) + 1 : 0;
|
||||
@@ -892,6 +934,8 @@ export default function BomManagementPage() {
|
||||
await apiClient.put(`/table-management/tables/bom_detail/edit`, {
|
||||
originalData: { id: node.id },
|
||||
updatedData: {
|
||||
bom_id: selectedBomId,
|
||||
version_id: versionId,
|
||||
quantity: node.quantity,
|
||||
unit: node.unit,
|
||||
process_type: node.process_type,
|
||||
@@ -904,6 +948,36 @@ export default function BomManagementPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// 이력 자동 기록 (변경 내용 상세)
|
||||
const historyParts: string[] = [];
|
||||
// 추가된 품목
|
||||
for (const n of toInsert) {
|
||||
historyParts.push(`${n.item_number || "품목"} 추가 (소요량: ${n.quantity || 1})`);
|
||||
}
|
||||
// 수정된 품목 (변경 전후 비교)
|
||||
for (const n of toUpdate) {
|
||||
const orig = bomDetails.find((d: any) => d.id === n.id);
|
||||
if (orig) {
|
||||
const changes: string[] = [];
|
||||
if (String(orig.quantity || "") !== String(n.quantity || "")) changes.push(`소요량 ${orig.quantity || "-"} → ${n.quantity || "-"}`);
|
||||
if (String(orig.process_type || "") !== String(n.process_type || "")) changes.push(`공정 변경`);
|
||||
if (String(orig.loss_rate || "") !== String(n.loss_rate || "")) changes.push(`손실율 ${orig.loss_rate || "0"} → ${n.loss_rate || "0"}`);
|
||||
if (String(orig.remark || "") !== String(n.remark || "")) changes.push(`비고 변경`);
|
||||
if (changes.length > 0) historyParts.push(`${n.item_number || "품목"}: ${changes.join(", ")}`);
|
||||
}
|
||||
}
|
||||
// 삭제된 품목
|
||||
for (const id of toDelete) {
|
||||
const orig = bomDetails.find((d: any) => d.id === id);
|
||||
historyParts.push(`${orig?.item_number || "품목"} 삭제`);
|
||||
}
|
||||
if (historyParts.length > 0) {
|
||||
apiClient.post(`/bom/${selectedBomId}/history`, {
|
||||
change_type: historyParts.some(p => p.includes("추가")) ? "추가" : historyParts.some(p => p.includes("삭제")) ? "삭제" : "수정",
|
||||
change_description: historyParts.join("; "),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
toast.success("BOM 트리가 저장되었어요");
|
||||
setTreeHasChanges(false);
|
||||
fetchBomDetail(selectedBomId);
|
||||
@@ -1165,6 +1239,7 @@ export default function BomManagementPage() {
|
||||
await apiClient.post(`/bom/${selectedBomId}/versions`, {
|
||||
versionName: newVersionName.trim() || undefined,
|
||||
});
|
||||
apiClient.post(`/bom/${selectedBomId}/history`, { change_type: "버전변경", change_description: `새 버전 생성: ${newVersionName.trim() || "자동"}` }).catch(() => {});
|
||||
toast.success("새 버전이 생성되었어요");
|
||||
setShowNewVersionDialog(false);
|
||||
setNewVersionName("");
|
||||
@@ -1195,6 +1270,7 @@ export default function BomManagementPage() {
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiClient.post(`/bom/${selectedBomId}/versions/${versionId}/activate`);
|
||||
apiClient.post(`/bom/${selectedBomId}/history`, { change_type: "버전변경", change_description: `버전 사용 확정` }).catch(() => {});
|
||||
toast.success("버전이 사용 확정되었어요");
|
||||
fetchVersions(selectedBomId);
|
||||
fetchBomDetail(selectedBomId);
|
||||
@@ -1491,48 +1567,6 @@ export default function BomManagementPage() {
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 트리뷰 토글 (트리뷰 탭에서만) */}
|
||||
{rightTab === "tree" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-0.5 bg-card border rounded-md p-0.5">
|
||||
<button
|
||||
className={cn("px-2.5 py-1 text-[11px] font-medium rounded transition-all",
|
||||
treeDirection === "forward" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTreeDirection("forward")}
|
||||
>
|
||||
정전개
|
||||
</button>
|
||||
<button
|
||||
className={cn("px-2.5 py-1 text-[11px] font-medium rounded transition-all",
|
||||
treeDirection === "reverse" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTreeDirection("reverse")}
|
||||
>
|
||||
역전개
|
||||
</button>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-border" />
|
||||
<div className="flex items-center gap-0.5 bg-card border rounded-md p-0.5">
|
||||
<button
|
||||
className={cn("px-2.5 py-1 text-[11px] font-medium rounded transition-all",
|
||||
treeBasis === "material" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTreeBasis("material")}
|
||||
>
|
||||
자재기준
|
||||
</button>
|
||||
<button
|
||||
className={cn("px-2.5 py-1 text-[11px] font-medium rounded transition-all",
|
||||
treeBasis === "process" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
)}
|
||||
onClick={() => setTreeBasis("process")}
|
||||
>
|
||||
공정기준
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 트리뷰 탭 */}
|
||||
@@ -1865,10 +1899,6 @@ export default function BomManagementPage() {
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{historyList.length}건 이력
|
||||
</span>
|
||||
<Button size="sm" onClick={() => setShowHistoryDialog(true)}>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
이력 추가
|
||||
</Button>
|
||||
</div>
|
||||
{historyLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
@@ -1881,24 +1911,34 @@ export default function BomManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{historyList.map((entry) => (
|
||||
<div key={entry.id} className="p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{entry.change_type}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{entry.changed_date ? new Date(entry.changed_date).toLocaleDateString("ko-KR") : "-"}
|
||||
</span>
|
||||
{historyList.map((entry) => {
|
||||
const typeStyle = CHANGE_TYPE_STYLE[entry.change_type] || "bg-muted text-muted-foreground ring-border";
|
||||
return (
|
||||
<div key={entry.id} className="p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset", typeStyle)}>
|
||||
{entry.change_type}
|
||||
</span>
|
||||
{(entry.revision || entry.version) && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{entry.revision && `차수 ${entry.revision}`}{entry.revision && entry.version && " / "}{entry.version && `v${entry.version}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{entry.changed_date ? new Date(entry.changed_date).toLocaleString("ko-KR", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-1">{entry.change_description || "-"}</p>
|
||||
{entry.changed_by && (
|
||||
<span className="text-[10px] text-muted-foreground/60 mt-1 block">
|
||||
{entry.changed_by}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{entry.change_description || "-"}</p>
|
||||
{entry.changed_by && (
|
||||
<span className="text-[10px] text-muted-foreground/60 mt-1 block">
|
||||
작성자: {entry.changed_by}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2263,10 +2303,15 @@ export default function BomManagementPage() {
|
||||
<Button onClick={() => {
|
||||
if (treeEditTarget) {
|
||||
const nodeKey = treeEditTarget.id || treeEditTarget._tempId || "";
|
||||
handleTreeFieldChange(nodeKey, "quantity", treeEditForm.quantity || "1");
|
||||
handleTreeFieldChange(nodeKey, "process_type", treeEditForm.process_type || "");
|
||||
handleTreeFieldChange(nodeKey, "loss_rate", treeEditForm.loss_rate || "0");
|
||||
handleTreeFieldChange(nodeKey, "remark", treeEditForm.remark || "");
|
||||
setEditingTree(prev => updateTreeNode(prev, nodeKey, node => ({
|
||||
...node,
|
||||
quantity: treeEditForm.quantity || "1",
|
||||
process_type: treeEditForm.process_type || "",
|
||||
loss_rate: treeEditForm.loss_rate || "0",
|
||||
remark: treeEditForm.remark || "",
|
||||
_isModified: !node._isNew ? true : node._isModified,
|
||||
})));
|
||||
setTreeHasChanges(true);
|
||||
}
|
||||
setTreeEditModalOpen(false);
|
||||
}}>저장</Button>
|
||||
@@ -2300,7 +2345,27 @@ export default function BomManagementPage() {
|
||||
<p className="text-xs">검색어를 입력해주세요</p>
|
||||
</div>
|
||||
) : (
|
||||
itemSearchResults.map((item) => {
|
||||
itemSearchResults.filter((item) => {
|
||||
// 자기 자신 제외
|
||||
if (bomHeader && (item.id === bomHeader.item_id || item.item_number === bomHeader.item_code)) return false;
|
||||
// 조상 경로 순환 참조 제외
|
||||
if (addTargetParentId) {
|
||||
const collectAncestorIds = (nodes: TreeNode[], targetId: string, path: string[]): string[] | null => {
|
||||
for (const n of nodes) {
|
||||
const nk = n.id || n._tempId || "";
|
||||
if (nk === targetId) return [...path, n.child_item_id || ""];
|
||||
if (n.children.length > 0) {
|
||||
const r = collectAncestorIds(n.children, targetId, [...path, n.child_item_id || ""]);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const ancestors = collectAncestorIds(editingTree, addTargetParentId, []);
|
||||
if (ancestors && ancestors.includes(item.id)) return false;
|
||||
}
|
||||
return true;
|
||||
}).map((item) => {
|
||||
const badge = getItemTypeBadge(item.division);
|
||||
return (
|
||||
<div
|
||||
@@ -2365,12 +2430,9 @@ export default function BomManagementPage() {
|
||||
<Select value={newHistory.change_type} onValueChange={(v) => setNewHistory((prev) => ({ ...prev, change_type: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="변경 유형 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="design_change">설계 변경</SelectItem>
|
||||
<SelectItem value="version_update">버전 업데이트</SelectItem>
|
||||
<SelectItem value="material_change">자재 변경</SelectItem>
|
||||
<SelectItem value="qty_change">수량 변경</SelectItem>
|
||||
<SelectItem value="process_change">공정 변경</SelectItem>
|
||||
<SelectItem value="etc">기타</SelectItem>
|
||||
{CHANGE_TYPE_OPTIONS.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user