Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
kjs
2026-04-06 18:09:00 +09:00
parent ddfbac69ef
commit 5e9544f31d

View File

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