feat: Add BOM version activation feature and enhance BOM management
- Implemented the `activateBomVersion` function in the BOM controller to allow activation of specific BOM versions. - Updated BOM routes to include the new activation endpoint for BOM versions. - Enhanced the BOM service to handle version activation logic, including status updates and BOM header version changes. - Improved the BOM version modal to support version activation with user confirmation and feedback. - Added checks to prevent deletion of active BOM versions, ensuring data integrity.
This commit is contained in:
parent
18cf5e3269
commit
0f3ec495a5
|
|
@ -93,6 +93,19 @@ export async function loadBomVersion(req: Request, res: Response) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function activateBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId, versionId } = req.params;
|
||||
const { tableName } = req.body || {};
|
||||
|
||||
const result = await bomService.activateBomVersion(bomId, versionId, tableName);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 버전 사용 확정 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteBomVersion(req: Request, res: Response) {
|
||||
try {
|
||||
const { bomId, versionId } = req.params;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ router.post("/:bomId/history", bomController.addBomHistory);
|
|||
router.get("/:bomId/versions", bomController.getBomVersions);
|
||||
router.post("/:bomId/versions", bomController.createBomVersion);
|
||||
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
|
||||
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
|
||||
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -112,6 +112,9 @@ export async function createBomVersion(
|
|||
companyCode,
|
||||
]);
|
||||
|
||||
// BOM 헤더의 version 필드도 업데이트
|
||||
await client.query(`UPDATE bom SET version = $1 WHERE id = $2`, [versionName, bomId]);
|
||||
|
||||
logger.info("BOM 버전 생성", { bomId, versionName, companyCode, vTable, dTable });
|
||||
return result.rows[0];
|
||||
});
|
||||
|
|
@ -140,6 +143,7 @@ export async function loadBomVersion(
|
|||
await client.query(`DELETE FROM ${snapshotDetailTable} WHERE bom_id = $1`, [bomId]);
|
||||
|
||||
const b = snapshot.bom;
|
||||
const loadedVersionName = verRow.rows[0].version_name;
|
||||
await client.query(
|
||||
`UPDATE bom SET base_qty = $1, unit = $2, revision = $3, remark = $4 WHERE id = $5`,
|
||||
[b.base_qty || null, b.unit || null, b.revision || null, b.remark || null, bomId],
|
||||
|
|
@ -169,12 +173,50 @@ export async function loadBomVersion(
|
|||
}
|
||||
|
||||
logger.info("BOM 버전 불러오기 완료", { bomId, versionId, vTable, snapshotDetailTable });
|
||||
return { restored: true, versionName: verRow.rows[0].version_name };
|
||||
return { restored: true, versionName: loadedVersionName };
|
||||
});
|
||||
}
|
||||
|
||||
export async function activateBomVersion(bomId: string, versionId: string, tableName?: string) {
|
||||
const table = safeTableName(tableName || "", "bom_version");
|
||||
|
||||
return transaction(async (client) => {
|
||||
const verRow = await client.query(
|
||||
`SELECT version_name FROM ${table} WHERE id = $1 AND bom_id = $2`,
|
||||
[versionId, bomId],
|
||||
);
|
||||
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
|
||||
|
||||
// 기존 active -> inactive
|
||||
await client.query(
|
||||
`UPDATE ${table} SET status = 'inactive' WHERE bom_id = $1 AND status = 'active'`,
|
||||
[bomId],
|
||||
);
|
||||
// 선택한 버전 -> active
|
||||
await client.query(
|
||||
`UPDATE ${table} SET status = 'active' WHERE id = $1`,
|
||||
[versionId],
|
||||
);
|
||||
// BOM 헤더 version도 갱신
|
||||
const versionName = verRow.rows[0].version_name;
|
||||
await client.query(
|
||||
`UPDATE bom SET version = $1 WHERE id = $2`,
|
||||
[versionName, bomId],
|
||||
);
|
||||
|
||||
logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName });
|
||||
return { activated: true, versionName };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteBomVersion(bomId: string, versionId: string, tableName?: string) {
|
||||
const table = safeTableName(tableName || "", "bom_version");
|
||||
// active 상태 버전은 삭제 불가
|
||||
const checkSql = `SELECT status FROM ${table} WHERE id = $1 AND bom_id = $2`;
|
||||
const checkResult = await query(checkSql, [versionId, bomId]);
|
||||
if (checkResult.length > 0 && checkResult[0].status === "active") {
|
||||
throw new Error("사용중인 버전은 삭제할 수 없습니다");
|
||||
}
|
||||
const sql = `DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`;
|
||||
const result = await query(sql, [versionId, bomId]);
|
||||
return result.length > 0;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { Input } from "@/components/ui/input";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import apiClient from "@/lib/api/client";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface BomDetailEditModalProps {
|
||||
open: boolean;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import apiClient from "@/lib/api/client";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface BomHistoryItem {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -357,19 +357,92 @@ export function BomTreeComponent({
|
|||
if (isDesignMode) {
|
||||
const configuredColumns = (config.columns || []).filter((c: TreeColumnDef) => !c.hidden);
|
||||
|
||||
const previewSampleValue = (col: TreeColumnDef, rowIdx: number): React.ReactNode => {
|
||||
if (col.key === "level") return rowIdx === 0 ? "0" : "1";
|
||||
if (col.key.includes("type") || col.key.includes("division")) {
|
||||
const badge = rowIdx === 0
|
||||
? { bg: "bg-blue-50 text-blue-500 ring-blue-200", label: "제품" }
|
||||
: { bg: "bg-amber-50 text-amber-500 ring-amber-200", label: "반제품" };
|
||||
return (
|
||||
<span className={cn("rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset", badge.bg)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (col.key.includes("quantity") || col.key.includes("qty")) return rowIdx === 0 ? "30" : "3";
|
||||
if (col.key.includes("unit")) return "EA";
|
||||
if (col.key.includes("process")) {
|
||||
const badge = rowIdx === 0
|
||||
? { bg: "bg-emerald-50 text-emerald-600 ring-emerald-200", label: "제조" }
|
||||
: { bg: "bg-purple-50 text-purple-600 ring-purple-200", label: "외주" };
|
||||
return (
|
||||
<span className={cn("rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset", badge.bg)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return `예시${rowIdx + 1}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b bg-gray-50/80 px-4 py-2.5">
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-semibold">BOM 트리 뷰</span>
|
||||
<span className="rounded-md bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-500">{detailTable}</span>
|
||||
{config.dataSource?.sourceTable && (
|
||||
<span className="rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-500">
|
||||
{config.dataSource.sourceTable}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
{/* 헤더 (실제 화면과 동일 구조) */}
|
||||
<div className="border-b px-5 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50">
|
||||
<Package className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-gray-400">BOM 상세정보</h3>
|
||||
<span className="inline-flex items-center rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-500 ring-1 ring-inset ring-blue-200">
|
||||
제품
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex gap-3 text-[10px] text-gray-300">
|
||||
<span>품목코드 <b className="text-gray-400">SAMPLE-001</b></span>
|
||||
<span>기준수량 <b className="text-gray-400">1</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 툴바 (실제 화면과 동일 구조) */}
|
||||
<div className="flex items-center border-b bg-gray-50/50 px-5 py-1.5">
|
||||
<span className="text-xs font-medium text-gray-500">BOM 구성</span>
|
||||
<span className="ml-2 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">2</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{showHistory && (
|
||||
<Button variant="outline" size="sm" disabled className="h-6 gap-1 px-2 text-[10px]">
|
||||
<History className="h-3 w-3" />
|
||||
이력
|
||||
</Button>
|
||||
)}
|
||||
{showVersion && (
|
||||
<Button variant="outline" size="sm" disabled className="h-6 gap-1 px-2 text-[10px]">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
버전
|
||||
</Button>
|
||||
)}
|
||||
<div className="mx-1 h-4 w-px bg-gray-200" />
|
||||
<div className="flex overflow-hidden rounded-md border">
|
||||
<span className="h-6 bg-primary px-2 text-[10px] font-medium leading-6 text-primary-foreground">트리</span>
|
||||
<span className="h-6 border-l bg-white px-2 text-[10px] font-medium leading-6 text-gray-500">레벨</span>
|
||||
</div>
|
||||
{features.showExpandAll !== false && (
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" disabled className="h-6 gap-1 px-2 text-[10px] text-gray-400">
|
||||
<Expand className="h-3 w-3" /> 정전개
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" disabled className="h-6 gap-1 px-2 text-[10px] text-gray-400">
|
||||
<Shrink className="h-3 w-3" /> 역전개
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
{configuredColumns.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6">
|
||||
<AlertCircle className="h-8 w-8 text-gray-200" />
|
||||
|
|
@ -377,46 +450,58 @@ export function BomTreeComponent({
|
|||
<p className="text-[11px] text-gray-300">설정 패널 > 컬럼 탭에서 표시할 컬럼을 선택하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b bg-gray-50">
|
||||
<th className="w-10 border-r border-gray-100 px-2 py-2"></th>
|
||||
{configuredColumns.map((col: TreeColumnDef) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="border-r border-gray-100 px-3 py-2 text-left text-[11px] font-semibold text-gray-600"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
{col.title || col.key}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-2 py-2.5 text-center" style={{ width: "52px" }}></th>
|
||||
{configuredColumns.map((col: TreeColumnDef) => {
|
||||
const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no", "unit", "level"].includes(col.key)
|
||||
|| col.key.includes("qty") || col.key.includes("quantity");
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"px-3 py-2.5 text-[11px] font-semibold text-gray-500",
|
||||
centered ? "text-center" : "text-left",
|
||||
)}
|
||||
>
|
||||
{col.title || col.key}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-gray-400">
|
||||
<tr className="border-b bg-white">
|
||||
<td className="border-r border-gray-50 px-2 py-2">
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-300" />
|
||||
<tbody>
|
||||
{/* 0레벨 루트 */}
|
||||
<tr className="border-b hover:bg-gray-50/50">
|
||||
<td className="px-2 py-2 text-center">
|
||||
<ChevronDown className="inline h-3.5 w-3.5 text-gray-400" />
|
||||
</td>
|
||||
{configuredColumns.map((col: TreeColumnDef, i: number) => (
|
||||
<td key={col.key} className="border-r border-gray-50 px-3 py-2">
|
||||
{col.key === "level" ? "0" : col.key.includes("type") ? (
|
||||
<span className="rounded-md bg-blue-50 px-1.5 py-0.5 text-[10px] text-blue-500 ring-1 ring-inset ring-blue-200">제품</span>
|
||||
) : col.key.includes("quantity") || col.key.includes("qty") ? "30" : `예시${i + 1}`}
|
||||
</td>
|
||||
))}
|
||||
{configuredColumns.map((col: TreeColumnDef) => {
|
||||
const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no", "unit", "level"].includes(col.key)
|
||||
|| col.key.includes("qty") || col.key.includes("quantity");
|
||||
return (
|
||||
<td key={col.key} className={cn("px-3 py-2 text-gray-600", centered && "text-center")}>
|
||||
{previewSampleValue(col, 0)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
<tr className="border-b bg-gray-50/30">
|
||||
<td className="border-r border-gray-50 px-2 py-2 pl-7">
|
||||
<span className="inline-block h-1 w-1 rounded-full bg-gray-300" />
|
||||
{/* 1레벨 자식 */}
|
||||
<tr className="border-b hover:bg-gray-50/50">
|
||||
<td className="px-2 py-2 text-center" style={{ paddingLeft: `${INDENT_PX + 8}px` }}>
|
||||
<Layers className="inline h-3.5 w-3.5 text-gray-300" />
|
||||
</td>
|
||||
{configuredColumns.map((col: TreeColumnDef, i: number) => (
|
||||
<td key={col.key} className="border-r border-gray-50 px-3 py-2 text-gray-300">
|
||||
{col.key === "level" ? "1" : col.key.includes("type") ? (
|
||||
<span className="rounded-md bg-amber-50 px-1.5 py-0.5 text-[10px] text-amber-500 ring-1 ring-inset ring-amber-200">반제품</span>
|
||||
) : col.key.includes("quantity") || col.key.includes("qty") ? "3" : `예시${i + 1}`}
|
||||
</td>
|
||||
))}
|
||||
{configuredColumns.map((col: TreeColumnDef) => {
|
||||
const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no", "unit", "level"].includes(col.key)
|
||||
|| col.key.includes("qty") || col.key.includes("quantity");
|
||||
return (
|
||||
<td key={col.key} className={cn("px-3 py-2 text-gray-400", centered && "text-center")}>
|
||||
{previewSampleValue(col, 1)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -839,6 +924,7 @@ export function BomTreeComponent({
|
|||
detailTable={detailTable}
|
||||
onVersionLoaded={() => {
|
||||
if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData);
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import {
|
|||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Plus, Trash2, Download } from "lucide-react";
|
||||
import { Loader2, Plus, Trash2, Download, ShieldCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import apiClient from "@/lib/api/client";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface BomVersion {
|
||||
id: string;
|
||||
|
|
@ -81,7 +81,7 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
|
|||
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/load`, { tableName, detailTable });
|
||||
if (res.data?.success) {
|
||||
onVersionLoaded?.();
|
||||
onOpenChange(false);
|
||||
loadVersions();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BomVersion] 불러오기 실패:", error);
|
||||
|
|
@ -90,6 +90,22 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
|
|||
}
|
||||
};
|
||||
|
||||
const handleActivateVersion = async (versionId: string) => {
|
||||
if (!bomId || !confirm("이 버전을 사용 확정하시겠습니까?\n기존 사용중 버전은 사용중지로 변경됩니다.")) return;
|
||||
setActionId(versionId);
|
||||
try {
|
||||
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/activate`, { tableName });
|
||||
if (res.data?.success) {
|
||||
loadVersions();
|
||||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BomVersion] 사용 확정 실패:", error);
|
||||
} finally {
|
||||
setActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteVersion = async (versionId: string) => {
|
||||
if (!bomId || !confirm("이 버전을 삭제하시겠습니까?")) return;
|
||||
setActionId(versionId);
|
||||
|
|
@ -179,17 +195,33 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
|
|||
{isActing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
||||
불러오기
|
||||
</Button>
|
||||
{ver.status !== "active" && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteVersion(ver.id)}
|
||||
disabled={isActing}
|
||||
className="h-7 gap-1 px-2 text-[10px]"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
삭제
|
||||
</Button>
|
||||
{ver.status === "active" ? (
|
||||
<span className="flex h-7 items-center rounded-md bg-emerald-50 px-2 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-200">
|
||||
사용중
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleActivateVersion(ver.id)}
|
||||
disabled={isActing}
|
||||
className="h-7 gap-1 px-2 text-[10px] border-emerald-300 text-emerald-600 hover:bg-emerald-50"
|
||||
>
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
사용 확정
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteVersion(ver.id)}
|
||||
disabled={isActing}
|
||||
className="h-7 gap-1 px-2 text-[10px]"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue