Merge branch 'feature/v2-renewal' into jskim-node

Made-with: Cursor
This commit is contained in:
kmh 2026-02-26 09:45:41 +09:00
commit 5cff85d260
5 changed files with 232 additions and 58 deletions

View File

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

View File

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

View File

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

View File

@ -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"> &gt; </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"));
}}
/>
)}

View File

@ -10,7 +10,7 @@ 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";
@ -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>