jskim-node #394
|
|
@ -105,6 +105,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou
|
||||||
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성
|
||||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||||
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||||
|
import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리
|
||||||
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리
|
||||||
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리
|
||||||
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리
|
||||||
|
|
@ -288,6 +289,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여
|
||||||
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성
|
||||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||||
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||||
|
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
|
||||||
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
|
||||||
app.use("/api/departments", departmentRoutes); // 부서 관리
|
app.use("/api/departments", departmentRoutes); // 부서 관리
|
||||||
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* BOM 이력/버전 관리 컨트롤러
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import * as bomService from "../services/bomService";
|
||||||
|
|
||||||
|
// ─── 이력 (History) ─────────────────────────────
|
||||||
|
|
||||||
|
export async function getBomHistory(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { bomId } = req.params;
|
||||||
|
const companyCode = (req as any).user?.companyCode || "*";
|
||||||
|
const tableName = (req.query.tableName as string) || undefined;
|
||||||
|
|
||||||
|
const data = await bomService.getBomHistory(bomId, companyCode, tableName);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("BOM 이력 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addBomHistory(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { bomId } = req.params;
|
||||||
|
const companyCode = (req as any).user?.companyCode || "*";
|
||||||
|
const changedBy = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||||
|
|
||||||
|
const { change_type, change_description, revision, version, tableName } = req.body;
|
||||||
|
if (!change_type) {
|
||||||
|
res.status(400).json({ success: false, message: "change_type은 필수입니다" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await bomService.addBomHistory(bomId, companyCode, {
|
||||||
|
change_type,
|
||||||
|
change_description,
|
||||||
|
revision,
|
||||||
|
version,
|
||||||
|
changed_by: changedBy,
|
||||||
|
}, 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 버전 (Version) ─────────────────────────────
|
||||||
|
|
||||||
|
export async function getBomVersions(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { bomId } = req.params;
|
||||||
|
const companyCode = (req as any).user?.companyCode || "*";
|
||||||
|
const tableName = (req.query.tableName as string) || undefined;
|
||||||
|
|
||||||
|
const data = await bomService.getBomVersions(bomId, companyCode, tableName);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("BOM 버전 목록 조회 실패", { error: error.message });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBomVersion(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { bomId } = req.params;
|
||||||
|
const companyCode = (req as any).user?.companyCode || "*";
|
||||||
|
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||||
|
const { tableName, detailTable } = req.body || {};
|
||||||
|
|
||||||
|
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable);
|
||||||
|
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 loadBomVersion(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { bomId, versionId } = req.params;
|
||||||
|
const companyCode = (req as any).user?.companyCode || "*";
|
||||||
|
const { tableName, detailTable } = req.body || {};
|
||||||
|
|
||||||
|
const result = await bomService.loadBomVersion(bomId, versionId, companyCode, tableName, detailTable);
|
||||||
|
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;
|
||||||
|
const tableName = (req.query.tableName as string) || undefined;
|
||||||
|
|
||||||
|
const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName);
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("BOM 버전 삭제 실패", { error: error.message });
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/**
|
||||||
|
* BOM 이력/버전 관리 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import * as bomController from "../controllers/bomController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 이력
|
||||||
|
router.get("/:bomId/history", bomController.getBomHistory);
|
||||||
|
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.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
/**
|
||||||
|
* BOM 이력 및 버전 관리 서비스
|
||||||
|
* 설정 패널에서 지정한 테이블명을 동적으로 사용
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { query, queryOne, transaction } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
// SQL 인젝션 방지: 테이블명은 알파벳, 숫자, 언더스코어만 허용
|
||||||
|
function safeTableName(name: string, fallback: string): string {
|
||||||
|
if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return fallback;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 이력 (History) ─────────────────────────────
|
||||||
|
|
||||||
|
export async function getBomHistory(bomId: string, companyCode: string, tableName?: string) {
|
||||||
|
const table = safeTableName(tableName || "", "bom_history");
|
||||||
|
const sql = companyCode === "*"
|
||||||
|
? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY changed_date DESC`
|
||||||
|
: `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY changed_date DESC`;
|
||||||
|
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
|
||||||
|
return query(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addBomHistory(
|
||||||
|
bomId: string,
|
||||||
|
companyCode: string,
|
||||||
|
data: {
|
||||||
|
revision?: string;
|
||||||
|
version?: string;
|
||||||
|
change_type: string;
|
||||||
|
change_description?: string;
|
||||||
|
changed_by?: string;
|
||||||
|
},
|
||||||
|
tableName?: string,
|
||||||
|
) {
|
||||||
|
const table = safeTableName(tableName || "", "bom_history");
|
||||||
|
const sql = `
|
||||||
|
INSERT INTO ${table} (bom_id, revision, version, change_type, change_description, changed_by, company_code)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
return queryOne(sql, [
|
||||||
|
bomId,
|
||||||
|
data.revision || null,
|
||||||
|
data.version || null,
|
||||||
|
data.change_type,
|
||||||
|
data.change_description || null,
|
||||||
|
data.changed_by || null,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 버전 (Version) ─────────────────────────────
|
||||||
|
|
||||||
|
export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) {
|
||||||
|
const table = safeTableName(tableName || "", "bom_version");
|
||||||
|
const sql = companyCode === "*"
|
||||||
|
? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY created_date DESC`
|
||||||
|
: `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY created_date DESC`;
|
||||||
|
const params = companyCode === "*" ? [bomId] : [bomId, companyCode];
|
||||||
|
return query(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBomVersion(
|
||||||
|
bomId: string, companyCode: string, createdBy: string,
|
||||||
|
versionTableName?: string, detailTableName?: string,
|
||||||
|
) {
|
||||||
|
const vTable = safeTableName(versionTableName || "", "bom_version");
|
||||||
|
const dTable = safeTableName(detailTableName || "", "bom_detail");
|
||||||
|
|
||||||
|
return transaction(async (client) => {
|
||||||
|
const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]);
|
||||||
|
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
|
||||||
|
const bomData = bomRow.rows[0];
|
||||||
|
|
||||||
|
const detailRows = await client.query(
|
||||||
|
`SELECT * FROM ${dTable} WHERE bom_id = $1 ORDER BY parent_detail_id NULLS FIRST, id`,
|
||||||
|
[bomId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastVersion = await client.query(
|
||||||
|
`SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`,
|
||||||
|
[bomId],
|
||||||
|
);
|
||||||
|
let nextVersionNum = 1;
|
||||||
|
if (lastVersion.rows.length > 0) {
|
||||||
|
const parsed = parseFloat(lastVersion.rows[0].version_name);
|
||||||
|
if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1;
|
||||||
|
}
|
||||||
|
const versionName = `${nextVersionNum}.0`;
|
||||||
|
|
||||||
|
const snapshot = {
|
||||||
|
bom: bomData,
|
||||||
|
details: detailRows.rows,
|
||||||
|
detailTable: dTable,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertSql = `
|
||||||
|
INSERT INTO ${vTable} (bom_id, version_name, revision, status, snapshot_data, created_by, company_code)
|
||||||
|
VALUES ($1, $2, $3, 'developing', $4, $5, $6)
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
const result = await client.query(insertSql, [
|
||||||
|
bomId,
|
||||||
|
versionName,
|
||||||
|
bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0,
|
||||||
|
JSON.stringify(snapshot),
|
||||||
|
createdBy,
|
||||||
|
companyCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.info("BOM 버전 생성", { bomId, versionName, companyCode, vTable, dTable });
|
||||||
|
return result.rows[0];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadBomVersion(
|
||||||
|
bomId: string, versionId: string, companyCode: string,
|
||||||
|
versionTableName?: string, detailTableName?: string,
|
||||||
|
) {
|
||||||
|
const vTable = safeTableName(versionTableName || "", "bom_version");
|
||||||
|
const dTable = safeTableName(detailTableName || "", "bom_detail");
|
||||||
|
|
||||||
|
return transaction(async (client) => {
|
||||||
|
const verRow = await client.query(
|
||||||
|
`SELECT * FROM ${vTable} WHERE id = $1 AND bom_id = $2`,
|
||||||
|
[versionId, bomId],
|
||||||
|
);
|
||||||
|
if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다");
|
||||||
|
|
||||||
|
const snapshot = verRow.rows[0].snapshot_data;
|
||||||
|
if (!snapshot || !snapshot.bom) throw new Error("스냅샷 데이터가 없습니다");
|
||||||
|
|
||||||
|
// 스냅샷에 기록된 detailTable을 우선 사용, 없으면 파라미터 사용
|
||||||
|
const snapshotDetailTable = safeTableName(snapshot.detailTable || "", dTable);
|
||||||
|
|
||||||
|
await client.query(`DELETE FROM ${snapshotDetailTable} WHERE bom_id = $1`, [bomId]);
|
||||||
|
|
||||||
|
const b = snapshot.bom;
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
const oldToNew: Record<string, string> = {};
|
||||||
|
for (const d of snapshot.details || []) {
|
||||||
|
const insertResult = await client.query(
|
||||||
|
`INSERT INTO ${snapshotDetailTable} (bom_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, company_code)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`,
|
||||||
|
[
|
||||||
|
bomId,
|
||||||
|
d.parent_detail_id ? (oldToNew[d.parent_detail_id] || null) : null,
|
||||||
|
d.child_item_id,
|
||||||
|
d.quantity,
|
||||||
|
d.unit,
|
||||||
|
d.process_type,
|
||||||
|
d.loss_rate,
|
||||||
|
d.remark,
|
||||||
|
d.level,
|
||||||
|
d.base_qty,
|
||||||
|
d.revision,
|
||||||
|
companyCode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
oldToNew[d.id] = insertResult.rows[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("BOM 버전 불러오기 완료", { bomId, versionId, vTable, snapshotDetailTable });
|
||||||
|
return { restored: true, versionName: verRow.rows[0].version_name };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBomVersion(bomId: string, versionId: string, tableName?: string) {
|
||||||
|
const table = safeTableName(tableName || "", "bom_version");
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -95,6 +95,9 @@ interface BomTreeConfig {
|
||||||
foreignKey?: string;
|
foreignKey?: string;
|
||||||
parentKey?: string;
|
parentKey?: string;
|
||||||
|
|
||||||
|
historyTable?: string;
|
||||||
|
versionTable?: string;
|
||||||
|
|
||||||
dataSource?: {
|
dataSource?: {
|
||||||
sourceTable?: string;
|
sourceTable?: string;
|
||||||
foreignKey?: string;
|
foreignKey?: string;
|
||||||
|
|
@ -109,6 +112,8 @@ interface BomTreeConfig {
|
||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
showQuantity?: boolean;
|
showQuantity?: boolean;
|
||||||
showLossRate?: boolean;
|
showLossRate?: boolean;
|
||||||
|
showHistory?: boolean;
|
||||||
|
showVersion?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -661,6 +666,140 @@ export function V2BomTreeConfigPanel({
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
|
{/* 이력/버전 테이블 설정 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-medium">이력/버전 관리</Label>
|
||||||
|
<p className="text-muted-foreground text-[10px]">
|
||||||
|
BOM 변경 이력과 버전 관리에 사용할 테이블을 선택하세요
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="tree-showHistory"
|
||||||
|
checked={config.features?.showHistory ?? true}
|
||||||
|
onCheckedChange={(checked) => updateFeatures("showHistory", !!checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="tree-showHistory" className="text-[10px]">이력 관리 사용</Label>
|
||||||
|
</div>
|
||||||
|
{(config.features?.showHistory ?? true) && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
disabled={loadingTables}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{config.historyTable
|
||||||
|
? allTables.find((t) => t.tableName === config.historyTable)?.displayName || config.historyTable
|
||||||
|
: "이력 테이블 선택..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList className="max-h-48">
|
||||||
|
<CommandEmpty className="py-3 text-center text-xs">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{allTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.displayName}`}
|
||||||
|
onSelect={() => updateConfig({ historyTable: table.tableName })}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.historyTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Database className="mr-2 h-3 w-3 text-gray-400" />
|
||||||
|
<span>{table.displayName}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="tree-showVersion"
|
||||||
|
checked={config.features?.showVersion ?? true}
|
||||||
|
onCheckedChange={(checked) => updateFeatures("showVersion", !!checked)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="tree-showVersion" className="text-[10px]">버전 관리 사용</Label>
|
||||||
|
</div>
|
||||||
|
{(config.features?.showVersion ?? true) && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
disabled={loadingTables}
|
||||||
|
className="h-8 w-full justify-between text-xs"
|
||||||
|
>
|
||||||
|
{config.versionTable
|
||||||
|
? allTables.find((t) => t.tableName === config.versionTable)?.displayName || config.versionTable
|
||||||
|
: "버전 테이블 선택..."}
|
||||||
|
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||||
|
<CommandList className="max-h-48">
|
||||||
|
<CommandEmpty className="py-3 text-center text-xs">
|
||||||
|
테이블을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{allTables.map((table) => (
|
||||||
|
<CommandItem
|
||||||
|
key={table.tableName}
|
||||||
|
value={`${table.tableName} ${table.displayName}`}
|
||||||
|
onSelect={() => updateConfig({ versionTable: table.tableName })}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-3 w-3",
|
||||||
|
config.versionTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Database className="mr-2 h-3 w-3 text-gray-400" />
|
||||||
|
<span>{table.displayName}</span>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
{/* 표시 옵션 */}
|
{/* 표시 옵션 */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-xs font-medium">표시 옵션</Label>
|
<Label className="text-xs font-medium">표시 옵션</Label>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface BomDetailEditModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
node: Record<string, any> | null;
|
||||||
|
isRootNode?: boolean;
|
||||||
|
tableName: string;
|
||||||
|
onSaved?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BomDetailEditModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
node,
|
||||||
|
isRootNode = false,
|
||||||
|
tableName,
|
||||||
|
onSaved,
|
||||||
|
}: BomDetailEditModalProps) {
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (node && open) {
|
||||||
|
if (isRootNode) {
|
||||||
|
setFormData({
|
||||||
|
base_qty: node.base_qty || "",
|
||||||
|
unit: node.unit || "",
|
||||||
|
remark: node.remark || "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
quantity: node.quantity || "",
|
||||||
|
unit: node.unit || node.detail_unit || "",
|
||||||
|
process_type: node.process_type || "",
|
||||||
|
base_qty: node.base_qty || "",
|
||||||
|
loss_rate: node.loss_rate || "",
|
||||||
|
remark: node.remark || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [node, open, isRootNode]);
|
||||||
|
|
||||||
|
const handleChange = (field: string, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!node) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const targetTable = isRootNode ? "bom" : tableName;
|
||||||
|
const realId = isRootNode ? node.id?.replace("__root_", "") : node.id;
|
||||||
|
await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData);
|
||||||
|
onSaved?.();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BomDetailEdit] 저장 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
const itemCode = isRootNode
|
||||||
|
? node.child_item_code || node.item_code || node.bom_number || "-"
|
||||||
|
: node.child_item_code || "-";
|
||||||
|
const itemName = isRootNode
|
||||||
|
? node.child_item_name || node.item_name || "-"
|
||||||
|
: node.child_item_name || "-";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">
|
||||||
|
{isRootNode ? "BOM 헤더 수정" : "품목 수정"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
{isRootNode
|
||||||
|
? "BOM 기본 정보를 수정합니다"
|
||||||
|
: "선택한 품목의 BOM 구성 정보를 수정합니다"}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 sm:space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">품목코드</Label>
|
||||||
|
<Input value={itemCode} disabled className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">품목명</Label>
|
||||||
|
<Input value={itemName} disabled className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">
|
||||||
|
{isRootNode ? "기준수량" : "구성수량"} *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={isRootNode ? formData.base_qty : formData.quantity}
|
||||||
|
onChange={(e) => handleChange(isRootNode ? "base_qty" : "quantity", e.target.value)}
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">단위</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.unit}
|
||||||
|
onChange={(e) => handleChange("unit", e.target.value)}
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isRootNode && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">공정</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.process_type}
|
||||||
|
onChange={(e) => handleChange("process_type", e.target.value)}
|
||||||
|
placeholder="예: 조립공정"
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">로스율 (%)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.loss_rate}
|
||||||
|
onChange={(e) => handleChange("loss_rate", e.target.value)}
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">규격</Label>
|
||||||
|
<Input
|
||||||
|
value={node.child_specification || node.specification || "-"}
|
||||||
|
disabled
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">재질</Label>
|
||||||
|
<Input
|
||||||
|
value={node.child_material || node.material || "-"}
|
||||||
|
disabled
|
||||||
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs sm:text-sm">메모</Label>
|
||||||
|
<Textarea
|
||||||
|
value={formData.remark}
|
||||||
|
onChange={(e) => handleChange("remark", e.target.value)}
|
||||||
|
placeholder="비고 사항을 입력하세요"
|
||||||
|
className="mt-1 min-h-[60px] text-xs sm:text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import apiClient from "@/lib/api/client";
|
||||||
|
|
||||||
|
interface BomHistoryItem {
|
||||||
|
id: string;
|
||||||
|
revision: string;
|
||||||
|
version: string;
|
||||||
|
change_type: string;
|
||||||
|
change_description: string;
|
||||||
|
changed_by: string;
|
||||||
|
changed_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BomHistoryModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
bomId: string | null;
|
||||||
|
tableName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANGE_TYPE_STYLE: Record<string, string> = {
|
||||||
|
"등록": "bg-blue-50 text-blue-600 ring-blue-200",
|
||||||
|
"수정": "bg-amber-50 text-amber-600 ring-amber-200",
|
||||||
|
"추가": "bg-emerald-50 text-emerald-600 ring-emerald-200",
|
||||||
|
"삭제": "bg-red-50 text-red-600 ring-red-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BomHistoryModal({ open, onOpenChange, bomId, tableName = "bom_history" }: BomHistoryModalProps) {
|
||||||
|
const [history, setHistory] = useState<BomHistoryItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && bomId) {
|
||||||
|
loadHistory();
|
||||||
|
}
|
||||||
|
}, [open, bomId]);
|
||||||
|
|
||||||
|
const loadHistory = async () => {
|
||||||
|
if (!bomId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/bom/${bomId}/history`, { params: { tableName } });
|
||||||
|
if (res.data?.success) {
|
||||||
|
setHistory(res.data.data || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BomHistory] 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[650px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">이력 관리</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
BOM 변경 이력을 확인합니다
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[400px] overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : history.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<p className="text-sm text-gray-400">이력이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-gray-50">
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "50px" }}>차수</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "50px" }}>버전</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "70px" }}>변경구분</th>
|
||||||
|
<th className="px-3 py-2.5 text-left text-[11px] font-semibold text-gray-500">변경내용</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "70px" }}>변경자</th>
|
||||||
|
<th className="px-3 py-2.5 text-center text-[11px] font-semibold text-gray-500" style={{ width: "130px" }}>변경일시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{history.map((item, idx) => (
|
||||||
|
<tr key={item.id} className={cn("border-b border-gray-100", idx % 2 === 0 ? "bg-white" : "bg-gray-50/30")}>
|
||||||
|
<td className="px-3 py-2.5 text-center tabular-nums">{item.revision || "-"}</td>
|
||||||
|
<td className="px-3 py-2.5 text-center tabular-nums">{item.version || "-"}</td>
|
||||||
|
<td className="px-3 py-2.5 text-center">
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
|
||||||
|
CHANGE_TYPE_STYLE[item.change_type] || "bg-gray-50 text-gray-500 ring-gray-200",
|
||||||
|
)}>
|
||||||
|
{item.change_type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 text-gray-700">{item.change_description || "-"}</td>
|
||||||
|
<td className="px-3 py-2.5 text-center text-gray-600">{item.changed_by || "-"}</td>
|
||||||
|
<td className="px-3 py-2.5 text-center text-gray-400">{formatDate(item.changed_date)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,10 +11,16 @@ import {
|
||||||
Expand,
|
Expand,
|
||||||
Shrink,
|
Shrink,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
History,
|
||||||
|
GitBranch,
|
||||||
|
Check,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { entityJoinApi } from "@/lib/api/entityJoin";
|
import { entityJoinApi } from "@/lib/api/entityJoin";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { BomDetailEditModal } from "./BomDetailEditModal";
|
||||||
|
import { BomHistoryModal } from "./BomHistoryModal";
|
||||||
|
import { BomVersionModal } from "./BomVersionModal";
|
||||||
|
|
||||||
interface BomTreeNode {
|
interface BomTreeNode {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -48,6 +54,7 @@ interface BomTreeComponentProps {
|
||||||
|
|
||||||
// 컬럼은 설정 패널에서만 추가 (하드코딩 금지)
|
// 컬럼은 설정 패널에서만 추가 (하드코딩 금지)
|
||||||
const EMPTY_COLUMNS: TreeColumnDef[] = [];
|
const EMPTY_COLUMNS: TreeColumnDef[] = [];
|
||||||
|
const INDENT_PX = 16;
|
||||||
|
|
||||||
export function BomTreeComponent({
|
export function BomTreeComponent({
|
||||||
component,
|
component,
|
||||||
|
|
@ -63,6 +70,35 @@ export function BomTreeComponent({
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<"tree" | "level">("tree");
|
||||||
|
|
||||||
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
|
const [editTargetNode, setEditTargetNode] = useState<BomTreeNode | null>(null);
|
||||||
|
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||||
|
const [versionModalOpen, setVersionModalOpen] = useState(false);
|
||||||
|
const [colWidths, setColWidths] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
const handleResizeStart = useCallback((colKey: string, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const startX = e.clientX;
|
||||||
|
const th = (e.target as HTMLElement).closest("th");
|
||||||
|
const startWidth = th?.offsetWidth || 100;
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
setColWidths((prev) => ({ ...prev, [colKey]: Math.max(40, startWidth + (ev.clientX - startX)) }));
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener("mousemove", onMove);
|
||||||
|
document.removeEventListener("mouseup", onUp);
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
};
|
||||||
|
document.body.style.cursor = "col-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
document.addEventListener("mousemove", onMove);
|
||||||
|
document.addEventListener("mouseup", onUp);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const config = component?.componentConfig || {};
|
const config = component?.componentConfig || {};
|
||||||
const overrides = component?.overrides || {};
|
const overrides = component?.overrides || {};
|
||||||
|
|
||||||
|
|
@ -87,6 +123,8 @@ export function BomTreeComponent({
|
||||||
const foreignKey = overrides.foreignKey || config.foreignKey || "bom_id";
|
const foreignKey = overrides.foreignKey || config.foreignKey || "bom_id";
|
||||||
const parentKey = overrides.parentKey || config.parentKey || "parent_detail_id";
|
const parentKey = overrides.parentKey || config.parentKey || "parent_detail_id";
|
||||||
const sourceFk = config.dataSource?.foreignKey || "child_item_id";
|
const sourceFk = config.dataSource?.foreignKey || "child_item_id";
|
||||||
|
const historyTable = config.historyTable || "bom_history";
|
||||||
|
const versionTable = config.versionTable || "bom_version";
|
||||||
|
|
||||||
const displayColumns = useMemo(() => {
|
const displayColumns = useMemo(() => {
|
||||||
const configured = config.columns as TreeColumnDef[] | undefined;
|
const configured = config.columns as TreeColumnDef[] | undefined;
|
||||||
|
|
@ -95,6 +133,8 @@ export function BomTreeComponent({
|
||||||
}, [config.columns]);
|
}, [config.columns]);
|
||||||
|
|
||||||
const features = config.features || {};
|
const features = config.features || {};
|
||||||
|
const showHistory = features.showHistory !== false;
|
||||||
|
const showVersion = features.showVersion !== false;
|
||||||
|
|
||||||
// ─── 데이터 로드 ───
|
// ─── 데이터 로드 ───
|
||||||
|
|
||||||
|
|
@ -267,9 +307,10 @@ export function BomTreeComponent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (col.key === "level") {
|
if (col.key === "level") {
|
||||||
|
const displayLevel = node._isVirtualRoot ? 0 : depth;
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded bg-gray-100 text-[10px] font-medium text-gray-600">
|
<span className="inline-flex h-5 w-5 items-center justify-center rounded bg-gray-100 text-[10px] font-medium text-gray-600">
|
||||||
{value ?? depth}
|
{displayLevel}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -415,6 +456,33 @@ export function BomTreeComponent({
|
||||||
return rows;
|
return rows;
|
||||||
}, [treeData, expandedNodes]);
|
}, [treeData, expandedNodes]);
|
||||||
|
|
||||||
|
// 레벨 뷰용: 전체 노드 평탄화 (expand 상태 무관)
|
||||||
|
const allFlattenedRows = useMemo(() => {
|
||||||
|
const rows: { node: BomTreeNode; depth: number }[] = [];
|
||||||
|
const traverse = (nodes: BomTreeNode[], depth: number) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
rows.push({ node, depth });
|
||||||
|
if (node.children.length > 0) traverse(node.children, depth + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
traverse(treeData, 0);
|
||||||
|
return rows;
|
||||||
|
}, [treeData]);
|
||||||
|
|
||||||
|
const maxDepth = useMemo(() => {
|
||||||
|
return allFlattenedRows.reduce((max, r) => Math.max(max, r.depth), 0);
|
||||||
|
}, [allFlattenedRows]);
|
||||||
|
|
||||||
|
const visibleRows = viewMode === "level" ? allFlattenedRows : flattenedRows;
|
||||||
|
const levelColumnsForView = useMemo(() => {
|
||||||
|
return Array.from({ length: maxDepth + 1 }, (_, i) => i);
|
||||||
|
}, [maxDepth]);
|
||||||
|
|
||||||
|
// 레벨 뷰에서 "level" 컬럼을 제외한 데이터 컬럼
|
||||||
|
const dataColumnsForLevelView = useMemo(() => {
|
||||||
|
return displayColumns.filter((c) => c.key !== "level");
|
||||||
|
}, [displayColumns]);
|
||||||
|
|
||||||
// ─── 메인 렌더링 ───
|
// ─── 메인 렌더링 ───
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -463,20 +531,69 @@ export function BomTreeComponent({
|
||||||
<div className="flex items-center border-b bg-gray-50/50 px-5 py-1.5">
|
<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="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">
|
<span className="ml-2 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
|
||||||
{flattenedRows.length}
|
{allFlattenedRows.length}
|
||||||
</span>
|
</span>
|
||||||
{features.showExpandAll !== false && (
|
<div className="ml-auto flex items-center gap-1">
|
||||||
<div className="ml-auto flex gap-1">
|
{showHistory && (
|
||||||
<Button variant="ghost" size="sm" onClick={expandAll} className="h-6 gap-1 px-2 text-[10px] text-gray-400 hover:text-gray-600">
|
<Button
|
||||||
<Expand className="h-3 w-3" />
|
variant="outline"
|
||||||
정전개
|
size="sm"
|
||||||
|
onClick={() => setHistoryModalOpen(true)}
|
||||||
|
className="h-6 gap-1 px-2 text-[10px]"
|
||||||
|
>
|
||||||
|
<History className="h-3 w-3" />
|
||||||
|
이력
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" onClick={collapseAll} className="h-6 gap-1 px-2 text-[10px] text-gray-400 hover:text-gray-600">
|
)}
|
||||||
<Shrink className="h-3 w-3" />
|
{showVersion && (
|
||||||
역전개
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setVersionModalOpen(true)}
|
||||||
|
className="h-6 gap-1 px-2 text-[10px]"
|
||||||
|
>
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
버전
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="mx-1 h-4 w-px bg-gray-200" />
|
||||||
|
<div className="flex overflow-hidden rounded-md border">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("tree")}
|
||||||
|
className={cn(
|
||||||
|
"h-6 px-2 text-[10px] font-medium transition-colors",
|
||||||
|
viewMode === "tree"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-white text-gray-500 hover:bg-gray-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
트리
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("level")}
|
||||||
|
className={cn(
|
||||||
|
"h-6 border-l px-2 text-[10px] font-medium transition-colors",
|
||||||
|
viewMode === "level"
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-white text-gray-500 hover:bg-gray-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
레벨
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{features.showExpandAll !== false && (
|
||||||
|
<div className={cn("flex gap-1", viewMode !== "tree" && "pointer-events-none invisible")}>
|
||||||
|
<Button variant="ghost" size="sm" onClick={expandAll} className="h-6 gap-1 px-2 text-[10px] text-gray-400 hover:text-gray-600">
|
||||||
|
<Expand className="h-3 w-3" />
|
||||||
|
정전개
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={collapseAll} className="h-6 gap-1 px-2 text-[10px] text-gray-400 hover:text-gray-600">
|
||||||
|
<Shrink className="h-3 w-3" />
|
||||||
|
역전개
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
|
|
@ -496,23 +613,122 @@ export function BomTreeComponent({
|
||||||
<Box className="h-8 w-8 text-gray-200" />
|
<Box className="h-8 w-8 text-gray-200" />
|
||||||
<p className="text-xs text-gray-400">등록된 하위 품목이 없습니다</p>
|
<p className="text-xs text-gray-400">등록된 하위 품목이 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : viewMode === "level" ? (
|
||||||
<table className="w-full border-collapse text-xs">
|
/* ═══ 레벨 뷰 ═══ */
|
||||||
|
<table
|
||||||
|
className="w-full border-collapse text-xs"
|
||||||
|
style={{ minWidth: `${(maxDepth + 1) * 30 + dataColumnsForLevelView.length * 90}px` }}
|
||||||
|
>
|
||||||
<thead className="sticky top-0 z-10">
|
<thead className="sticky top-0 z-10">
|
||||||
<tr className="border-b bg-gray-50">
|
<tr className="border-b bg-gray-50">
|
||||||
<th className="w-10 px-2 py-2.5"></th>
|
{levelColumnsForView.map((lvl) => (
|
||||||
{displayColumns.map((col) => {
|
<th
|
||||||
const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key);
|
key={`lv-${lvl}`}
|
||||||
|
className="whitespace-nowrap px-0.5 py-2.5 text-center text-[10px] font-semibold text-gray-500"
|
||||||
|
style={{ width: "30px", minWidth: "30px", maxWidth: "30px" }}
|
||||||
|
>
|
||||||
|
{lvl}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
{dataColumnsForLevelView.map((col) => {
|
||||||
|
const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no"].includes(col.key);
|
||||||
|
const w = colWidths[col.key];
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2.5 text-[11px] font-semibold text-gray-500",
|
"relative select-none whitespace-nowrap px-3 py-2.5 text-[11px] font-semibold text-gray-500",
|
||||||
centered ? "text-center" : "text-left",
|
centered ? "text-center" : "text-left",
|
||||||
)}
|
)}
|
||||||
style={{ width: col.width || "auto" }}
|
style={w ? { width: `${w}px` } : undefined}
|
||||||
>
|
>
|
||||||
{col.title}
|
<span className="truncate">{col.title}</span>
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-primary/30"
|
||||||
|
onMouseDown={(e) => handleResizeStart(col.key, e)}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{allFlattenedRows.map(({ node, depth }, rowIdx) => {
|
||||||
|
const isRoot = !!node._isVirtualRoot;
|
||||||
|
const displayDepth = isRoot ? 0 : depth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={node.id}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer border-b transition-colors",
|
||||||
|
isRoot
|
||||||
|
? "border-gray-200 bg-blue-50/40 font-medium hover:bg-blue-50/60"
|
||||||
|
: selectedNodeId === node.id
|
||||||
|
? "border-gray-100 bg-primary/5"
|
||||||
|
: rowIdx % 2 === 0
|
||||||
|
? "border-gray-100 bg-white hover:bg-gray-50/80"
|
||||||
|
: "border-gray-100 bg-gray-50/30 hover:bg-gray-50/80",
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedNodeId(node.id)}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
setEditTargetNode(node);
|
||||||
|
setEditModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{levelColumnsForView.map((lvl) => (
|
||||||
|
<td
|
||||||
|
key={`lv-${lvl}`}
|
||||||
|
className="py-2 text-center"
|
||||||
|
style={{ width: "30px", minWidth: "30px", maxWidth: "30px" }}
|
||||||
|
>
|
||||||
|
{displayDepth === lvl ? (
|
||||||
|
<Check className="mx-auto h-3.5 w-3.5 text-gray-700" />
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{dataColumnsForLevelView.map((col) => {
|
||||||
|
const centered = ["quantity", "loss_rate", "base_qty", "revision", "seq_no"].includes(col.key);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2",
|
||||||
|
centered ? "text-center" : "text-left",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderCellValue(node, col, depth)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
/* ═══ 트리 뷰 ═══ */
|
||||||
|
<table className="w-full border-collapse text-xs" style={{ minWidth: `${Math.max(52, maxDepth * INDENT_PX + 44) + displayColumns.length * 90}px` }}>
|
||||||
|
<thead className="sticky top-0 z-10">
|
||||||
|
<tr className="border-b bg-gray-50">
|
||||||
|
<th className="px-2 py-2.5" style={{ width: `${Math.max(52, maxDepth * INDENT_PX + 44)}px` }}></th>
|
||||||
|
{displayColumns.map((col) => {
|
||||||
|
const centered = ["quantity", "loss_rate", "level", "base_qty", "revision", "seq_no"].includes(col.key);
|
||||||
|
const w = colWidths[col.key];
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className={cn(
|
||||||
|
"relative select-none px-3 py-2.5 text-[11px] font-semibold text-gray-500",
|
||||||
|
centered ? "text-center" : "text-left",
|
||||||
|
)}
|
||||||
|
style={{ width: w ? `${w}px` : (col.width || "auto") }}
|
||||||
|
>
|
||||||
|
<span className="truncate">{col.title}</span>
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-primary/30"
|
||||||
|
onMouseDown={(e) => handleResizeStart(col.key, e)}
|
||||||
|
/>
|
||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -544,8 +760,12 @@ export function BomTreeComponent({
|
||||||
setSelectedNodeId(node.id);
|
setSelectedNodeId(node.id);
|
||||||
if (hasChildren) toggleNode(node.id);
|
if (hasChildren) toggleNode(node.id);
|
||||||
}}
|
}}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
setEditTargetNode(node);
|
||||||
|
setEditModalOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<td className="px-1 py-2" style={{ paddingLeft: `${depth * 20 + 8}px` }}>
|
<td className="px-1 py-2" style={{ paddingLeft: `${depth * INDENT_PX + 8}px` }}>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
<span className="flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
|
|
@ -573,7 +793,7 @@ export function BomTreeComponent({
|
||||||
<td
|
<td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 py-2",
|
"overflow-hidden text-ellipsis whitespace-nowrap px-3 py-2",
|
||||||
centered ? "text-center" : "text-left",
|
centered ? "text-center" : "text-left",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -588,6 +808,40 @@ export function BomTreeComponent({
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 품목 수정 모달 */}
|
||||||
|
<BomDetailEditModal
|
||||||
|
open={editModalOpen}
|
||||||
|
onOpenChange={setEditModalOpen}
|
||||||
|
node={editTargetNode}
|
||||||
|
isRootNode={!!editTargetNode?._isVirtualRoot}
|
||||||
|
tableName={detailTable}
|
||||||
|
onSaved={() => {
|
||||||
|
if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showHistory && (
|
||||||
|
<BomHistoryModal
|
||||||
|
open={historyModalOpen}
|
||||||
|
onOpenChange={setHistoryModalOpen}
|
||||||
|
bomId={selectedBomId}
|
||||||
|
tableName={historyTable}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showVersion && (
|
||||||
|
<BomVersionModal
|
||||||
|
open={versionModalOpen}
|
||||||
|
onOpenChange={setVersionModalOpen}
|
||||||
|
bomId={selectedBomId}
|
||||||
|
tableName={versionTable}
|
||||||
|
detailTable={detailTable}
|
||||||
|
onVersionLoaded={() => {
|
||||||
|
if (selectedBomId) loadBomDetails(selectedBomId, selectedHeaderData);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Loader2, Plus, Trash2, Download } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import apiClient from "@/lib/api/client";
|
||||||
|
|
||||||
|
interface BomVersion {
|
||||||
|
id: string;
|
||||||
|
version_name: string;
|
||||||
|
revision: number;
|
||||||
|
status: string;
|
||||||
|
created_by: string;
|
||||||
|
created_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BomVersionModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
bomId: string | null;
|
||||||
|
tableName?: string;
|
||||||
|
detailTable?: string;
|
||||||
|
onVersionLoaded?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLE: Record<string, { label: string; className: string }> = {
|
||||||
|
developing: { label: "개발중", className: "bg-red-50 text-red-600 ring-red-200" },
|
||||||
|
active: { label: "사용", className: "bg-emerald-50 text-emerald-600 ring-emerald-200" },
|
||||||
|
inactive: { label: "사용중지", className: "bg-gray-100 text-gray-500 ring-gray-200" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_version", detailTable = "bom_detail", onVersionLoaded }: BomVersionModalProps) {
|
||||||
|
const [versions, setVersions] = useState<BomVersion[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [actionId, setActionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && bomId) loadVersions();
|
||||||
|
}, [open, bomId]);
|
||||||
|
|
||||||
|
const loadVersions = async () => {
|
||||||
|
if (!bomId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/bom/${bomId}/versions`, { params: { tableName } });
|
||||||
|
if (res.data?.success) setVersions(res.data.data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BomVersion] 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateVersion = async () => {
|
||||||
|
if (!bomId) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable });
|
||||||
|
if (res.data?.success) loadVersions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BomVersion] 생성 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadVersion = async (versionId: string) => {
|
||||||
|
if (!bomId) return;
|
||||||
|
setActionId(versionId);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post(`/bom/${bomId}/versions/${versionId}/load`, { tableName, detailTable });
|
||||||
|
if (res.data?.success) {
|
||||||
|
onVersionLoaded?.();
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BomVersion] 불러오기 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setActionId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteVersion = async (versionId: string) => {
|
||||||
|
if (!bomId || !confirm("이 버전을 삭제하시겠습니까?")) return;
|
||||||
|
setActionId(versionId);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.delete(`/bom/${bomId}/versions/${versionId}`, { params: { tableName } });
|
||||||
|
if (res.data?.success) loadVersions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[BomVersion] 삭제 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setActionId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
if (!dateStr) return "-";
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatus = (status: string) => STATUS_STYLE[status] || STATUS_STYLE.inactive;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[550px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-base sm:text-lg">버전 관리</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
BOM 버전을 관리합니다. 불러오기로 특정 버전을 복원할 수 있습니다.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="max-h-[400px] space-y-2 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : versions.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<p className="text-sm text-gray-400">생성된 버전이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
versions.map((ver) => {
|
||||||
|
const st = getStatus(ver.status);
|
||||||
|
const isActing = actionId === ver.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ver.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg border p-3 transition-colors",
|
||||||
|
ver.status === "active" ? "border-emerald-200 bg-emerald-50/30" : "border-gray-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
|
Version {ver.version_name}
|
||||||
|
</span>
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex items-center rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset",
|
||||||
|
st.className,
|
||||||
|
)}>
|
||||||
|
{st.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex gap-3 text-[11px] text-gray-400">
|
||||||
|
<span>차수: {ver.revision}</span>
|
||||||
|
<span>등록일: {formatDate(ver.created_date)}</span>
|
||||||
|
{ver.created_by && <span>등록자: {ver.created_by}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleLoadVersion(ver.id)}
|
||||||
|
disabled={isActing}
|
||||||
|
className="h-7 gap-1 px-2 text-[10px]"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
<Button
|
||||||
|
onClick={handleCreateVersion}
|
||||||
|
disabled={creating}
|
||||||
|
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
{creating ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Plus className="mr-1 h-4 w-4" />}
|
||||||
|
신규 버전 생성
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue