Compare commits
No commits in common. "bbbdd31311f44695c693df3b83cd92e3ddd7de27" and "38ade7562ebe41c6320bf9156b26e4c593b91593" have entirely different histories.
bbbdd31311
...
38ade7562e
|
|
@ -92,9 +92,9 @@ export async function createBomVersion(req: Request, res: Response) {
|
||||||
const { bomId } = req.params;
|
const { bomId } = req.params;
|
||||||
const companyCode = (req as any).user?.companyCode || "*";
|
const companyCode = (req as any).user?.companyCode || "*";
|
||||||
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
|
const createdBy = (req as any).user?.userName || (req as any).user?.userId || "";
|
||||||
const { tableName, detailTable, versionName } = req.body || {};
|
const { tableName, detailTable } = req.body || {};
|
||||||
|
|
||||||
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable, versionName);
|
const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable);
|
||||||
res.json({ success: true, data: result });
|
res.json({ success: true, data: result });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error("BOM 버전 생성 실패", { error: error.message });
|
logger.error("BOM 버전 생성 실패", { error: error.message });
|
||||||
|
|
@ -129,20 +129,6 @@ export async function activateBomVersion(req: Request, res: Response) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initializeBomVersion(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 result = await bomService.initializeBomVersion(bomId, companyCode, createdBy);
|
|
||||||
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) {
|
export async function deleteBomVersion(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
const { bomId, versionId } = req.params;
|
const { bomId, versionId } = req.params;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ router.post("/:bomId/history", bomController.addBomHistory);
|
||||||
// 버전
|
// 버전
|
||||||
router.get("/:bomId/versions", bomController.getBomVersions);
|
router.get("/:bomId/versions", bomController.getBomVersions);
|
||||||
router.post("/:bomId/versions", bomController.createBomVersion);
|
router.post("/:bomId/versions", bomController.createBomVersion);
|
||||||
router.post("/:bomId/initialize-version", bomController.initializeBomVersion);
|
|
||||||
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
|
router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion);
|
||||||
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
|
router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion);
|
||||||
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
|
router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion);
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,6 @@ export async function getBomVersions(bomId: string, companyCode: string, tableNa
|
||||||
export async function createBomVersion(
|
export async function createBomVersion(
|
||||||
bomId: string, companyCode: string, createdBy: string,
|
bomId: string, companyCode: string, createdBy: string,
|
||||||
versionTableName?: string, detailTableName?: string,
|
versionTableName?: string, detailTableName?: string,
|
||||||
inputVersionName?: string,
|
|
||||||
) {
|
) {
|
||||||
const vTable = safeTableName(versionTableName || "", "bom_version");
|
const vTable = safeTableName(versionTableName || "", "bom_version");
|
||||||
const dTable = safeTableName(detailTableName || "", "bom_detail");
|
const dTable = safeTableName(detailTableName || "", "bom_detail");
|
||||||
|
|
@ -108,24 +107,17 @@ export async function createBomVersion(
|
||||||
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
|
if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다");
|
||||||
const bomData = bomRow.rows[0];
|
const bomData = bomRow.rows[0];
|
||||||
|
|
||||||
// 버전명: 사용자 입력 > 순번 자동 생성
|
// 다음 버전 번호 결정
|
||||||
let versionName = inputVersionName?.trim();
|
const lastVersion = await client.query(
|
||||||
if (!versionName) {
|
`SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`,
|
||||||
const countResult = await client.query(
|
[bomId],
|
||||||
`SELECT COUNT(*)::int as cnt FROM ${vTable} WHERE bom_id = $1`,
|
|
||||||
[bomId],
|
|
||||||
);
|
|
||||||
versionName = `${(countResult.rows[0].cnt || 0) + 1}.0`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 중복 체크
|
|
||||||
const dupCheck = await client.query(
|
|
||||||
`SELECT id FROM ${vTable} WHERE bom_id = $1 AND version_name = $2`,
|
|
||||||
[bomId, versionName],
|
|
||||||
);
|
);
|
||||||
if (dupCheck.rows.length > 0) {
|
let nextVersionNum = 1;
|
||||||
throw new Error(`이미 존재하는 버전명입니다: ${versionName}`);
|
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`;
|
||||||
|
|
||||||
// 새 버전 레코드 생성 (snapshot_data 없이)
|
// 새 버전 레코드 생성 (snapshot_data 없이)
|
||||||
const insertSql = `
|
const insertSql = `
|
||||||
|
|
@ -257,68 +249,6 @@ export async function activateBomVersion(bomId: string, versionId: string, table
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 신규 BOM 초기화: 첫 번째 버전 자동 생성 + version_id null인 디테일 보정
|
|
||||||
* BOM 헤더의 version 필드를 그대로 버전명으로 사용 (사용자 입력값 존중)
|
|
||||||
*/
|
|
||||||
export async function initializeBomVersion(
|
|
||||||
bomId: string, companyCode: string, createdBy: string,
|
|
||||||
) {
|
|
||||||
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];
|
|
||||||
|
|
||||||
if (bomData.current_version_id) {
|
|
||||||
await client.query(
|
|
||||||
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
|
|
||||||
[bomData.current_version_id, bomId],
|
|
||||||
);
|
|
||||||
return { versionId: bomData.current_version_id, created: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미 버전 레코드가 존재하는지 확인 (동시 호출 방지)
|
|
||||||
const existingVersion = await client.query(
|
|
||||||
`SELECT id, version_name FROM bom_version WHERE bom_id = $1 ORDER BY created_date ASC LIMIT 1`,
|
|
||||||
[bomId],
|
|
||||||
);
|
|
||||||
if (existingVersion.rows.length > 0) {
|
|
||||||
const existId = existingVersion.rows[0].id;
|
|
||||||
await client.query(
|
|
||||||
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
|
|
||||||
[existId, bomId],
|
|
||||||
);
|
|
||||||
await client.query(
|
|
||||||
`UPDATE bom SET current_version_id = $1 WHERE id = $2 AND current_version_id IS NULL`,
|
|
||||||
[existId, bomId],
|
|
||||||
);
|
|
||||||
return { versionId: existId, created: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionName = bomData.version || "1.0";
|
|
||||||
|
|
||||||
const versionResult = await client.query(
|
|
||||||
`INSERT INTO bom_version (bom_id, version_name, revision, status, created_by, company_code)
|
|
||||||
VALUES ($1, $2, 0, 'active', $3, $4) RETURNING id`,
|
|
||||||
[bomId, versionName, createdBy, companyCode],
|
|
||||||
);
|
|
||||||
const versionId = versionResult.rows[0].id;
|
|
||||||
|
|
||||||
const updated = await client.query(
|
|
||||||
`UPDATE bom_detail SET version_id = $1 WHERE bom_id = $2 AND version_id IS NULL`,
|
|
||||||
[versionId, bomId],
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`UPDATE bom SET current_version_id = $1 WHERE id = $2`,
|
|
||||||
[versionId, bomId],
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info("BOM 초기 버전 생성", { bomId, versionId, versionName, updatedDetails: updated.rowCount });
|
|
||||||
return { versionId, versionName, created: true };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제
|
* 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
/**
|
|
||||||
* BOM Screen - Restoration Verification
|
|
||||||
* Screen 4168 - verify split panel, BOM list, and tree with child items
|
|
||||||
*/
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
import { mkdirSync, existsSync } from 'fs';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
const SCREENSHOT_DIR = join(process.cwd(), 'bom-detail-test-screenshots');
|
|
||||||
|
|
||||||
async function ensureDir(dir) {
|
|
||||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function screenshot(page, name) {
|
|
||||||
ensureDir(SCREENSHOT_DIR);
|
|
||||||
await page.screenshot({ path: join(SCREENSHOT_DIR, `${name}.png`), fullPage: true });
|
|
||||||
console.log(` [Screenshot] ${name}.png`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sleep(ms) {
|
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const browser = await chromium.launch({ headless: true });
|
|
||||||
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('\n--- Step 1-2: Login ---');
|
|
||||||
await page.goto('http://localhost:9771/login', { waitUntil: 'load', timeout: 45000 });
|
|
||||||
await page.locator('input[type="text"], input[placeholder*="ID"]').first().fill('topseal_admin');
|
|
||||||
await page.locator('input[type="password"]').first().fill('qlalfqjsgh11');
|
|
||||||
await Promise.all([
|
|
||||||
page.waitForNavigation({ waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {}),
|
|
||||||
page.locator('button:has-text("로그인")').first().click(),
|
|
||||||
]);
|
|
||||||
await sleep(3000);
|
|
||||||
|
|
||||||
console.log('\n--- Step 4-5: Navigate to screen 4168 ---');
|
|
||||||
await page.goto('http://localhost:9771/screens/4168', { waitUntil: 'load', timeout: 45000 });
|
|
||||||
await sleep(5000);
|
|
||||||
|
|
||||||
console.log('\n--- Step 6: Screenshot after load ---');
|
|
||||||
await screenshot(page, '10-bom-4168-initial');
|
|
||||||
|
|
||||||
const hasBomList = (await page.locator('text="BOM 목록"').count()) > 0;
|
|
||||||
const hasSplitPanel = (await page.locator('text="BOM 상세정보"').count()) > 0 || hasBomList;
|
|
||||||
const rowCount = await page.locator('table tbody tr').count();
|
|
||||||
const hasBomRows = rowCount > 0;
|
|
||||||
|
|
||||||
console.log('\n========== INITIAL STATE (Step 7) ==========');
|
|
||||||
console.log('BOM management screen loaded:', hasBomList || hasSplitPanel ? 'YES' : 'CHECK');
|
|
||||||
console.log('Split panel (BOM list left):', hasSplitPanel ? 'YES' : 'NO');
|
|
||||||
console.log('BOM data rows visible:', hasBomRows ? `YES (${rowCount} rows)` : 'NO');
|
|
||||||
|
|
||||||
if (hasBomRows) {
|
|
||||||
console.log('\n--- Step 8-9: Click first row ---');
|
|
||||||
await page.locator('table tbody tr').first().click();
|
|
||||||
await sleep(5000);
|
|
||||||
|
|
||||||
console.log('\n--- Step 10: Screenshot after row click ---');
|
|
||||||
await screenshot(page, '11-bom-4168-after-click');
|
|
||||||
|
|
||||||
const noDataMsg = (await page.locator('text="등록된 하위 품목이 없습니다"').count()) > 0;
|
|
||||||
const treeArea = page.locator('div:has-text("BOM 구성"), div:has-text("BOM 상세정보")').first();
|
|
||||||
const treeText = (await treeArea.textContent().catch(() => '') || '').substring(0, 600);
|
|
||||||
const hasChildItems = !noDataMsg && (treeText.includes('품번') || treeText.includes('레벨') || treeText.length > 150);
|
|
||||||
|
|
||||||
console.log('\n========== AFTER ROW CLICK (Step 11) ==========');
|
|
||||||
console.log('BOM tree shows child items:', hasChildItems ? 'YES' : noDataMsg ? 'NO (empty message)' : 'CHECK');
|
|
||||||
console.log('Tree preview:', treeText.substring(0, 300) + (treeText.length > 300 ? '...' : ''));
|
|
||||||
} else {
|
|
||||||
console.log('\n--- No BOM rows to click ---');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error:', err.message);
|
|
||||||
try { await page.screenshot({ path: join(SCREENSHOT_DIR, '99-error.png'), fullPage: true }); } catch (e) {}
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Loader2 } from "lucide-react";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* /screen/{screenCode} → /screens/{screenId} 리다이렉트
|
* /screen/COMPANY_7_167 → /screens/4153 리다이렉트
|
||||||
* 메뉴 URL이 screenCode 기반이므로, screenId로 변환 후 이동
|
* 메뉴 URL이 screenCode 기반이므로, screenId로 변환 후 이동
|
||||||
*/
|
*/
|
||||||
export default function ScreenCodeRedirectPage() {
|
export default function ScreenCodeRedirectPage() {
|
||||||
|
|
@ -26,14 +26,12 @@ export default function ScreenCodeRedirectPage() {
|
||||||
const resolve = async () => {
|
const resolve = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get("/screen-management/screens", {
|
const res = await apiClient.get("/screen-management/screens", {
|
||||||
params: { searchTerm: screenCode, size: 50 },
|
params: { screenCode },
|
||||||
});
|
});
|
||||||
const items = res.data?.data?.data || res.data?.data || [];
|
const screens = res.data?.data || [];
|
||||||
const arr = Array.isArray(items) ? items : [];
|
if (screens.length > 0) {
|
||||||
const exact = arr.find((s: any) => s.screenCode === screenCode);
|
const id = screens[0].screenId || screens[0].screen_id;
|
||||||
const target = exact || arr[0];
|
router.replace(`/screens/${id}`);
|
||||||
if (target) {
|
|
||||||
router.replace(`/screens/${target.screenId || target.screen_id}`);
|
|
||||||
} else {
|
} else {
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,6 @@ interface ItemSearchModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSelect: (items: ItemInfo[]) => void;
|
onSelect: (items: ItemInfo[]) => void;
|
||||||
companyCode?: string;
|
companyCode?: string;
|
||||||
existingItemIds?: Set<string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ItemSearchModal({
|
function ItemSearchModal({
|
||||||
|
|
@ -94,7 +93,6 @@ function ItemSearchModal({
|
||||||
onClose,
|
onClose,
|
||||||
onSelect,
|
onSelect,
|
||||||
companyCode,
|
companyCode,
|
||||||
existingItemIds,
|
|
||||||
}: ItemSearchModalProps) {
|
}: ItemSearchModalProps) {
|
||||||
const [searchText, setSearchText] = useState("");
|
const [searchText, setSearchText] = useState("");
|
||||||
const [items, setItems] = useState<ItemInfo[]>([]);
|
const [items, setItems] = useState<ItemInfo[]>([]);
|
||||||
|
|
@ -184,7 +182,7 @@ function ItemSearchModal({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs sm:text-sm">
|
<table className="w-full text-xs sm:text-sm">
|
||||||
<thead className="bg-muted sticky top-0 z-10">
|
<thead className="bg-muted/50 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-8 px-2 py-2 text-center">
|
<th className="w-8 px-2 py-2 text-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|
@ -202,53 +200,43 @@ function ItemSearchModal({
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{items.map((item) => {
|
{items.map((item) => (
|
||||||
const alreadyAdded = existingItemIds?.has(item.id) || false;
|
<tr
|
||||||
return (
|
key={item.id}
|
||||||
<tr
|
onClick={() => {
|
||||||
key={item.id}
|
setSelectedItems((prev) => {
|
||||||
onClick={() => {
|
const next = new Set(prev);
|
||||||
if (alreadyAdded) return;
|
if (next.has(item.id)) next.delete(item.id);
|
||||||
setSelectedItems((prev) => {
|
else next.add(item.id);
|
||||||
const next = new Set(prev);
|
return next;
|
||||||
if (next.has(item.id)) next.delete(item.id);
|
});
|
||||||
else next.add(item.id);
|
}}
|
||||||
return next;
|
className={cn(
|
||||||
});
|
"cursor-pointer border-t transition-colors",
|
||||||
}}
|
selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent",
|
||||||
className={cn(
|
)}
|
||||||
"border-t transition-colors",
|
>
|
||||||
alreadyAdded
|
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
? "cursor-not-allowed opacity-40"
|
<Checkbox
|
||||||
: "cursor-pointer",
|
checked={selectedItems.has(item.id)}
|
||||||
!alreadyAdded && selectedItems.has(item.id) ? "bg-primary/10" : !alreadyAdded ? "hover:bg-accent" : "",
|
onCheckedChange={(checked) => {
|
||||||
)}
|
setSelectedItems((prev) => {
|
||||||
>
|
const next = new Set(prev);
|
||||||
<td className="px-2 py-2 text-center" onClick={(e) => e.stopPropagation()}>
|
if (checked) next.add(item.id);
|
||||||
<Checkbox
|
else next.delete(item.id);
|
||||||
checked={selectedItems.has(item.id)}
|
return next;
|
||||||
disabled={alreadyAdded}
|
});
|
||||||
onCheckedChange={(checked) => {
|
}}
|
||||||
if (alreadyAdded) return;
|
/>
|
||||||
setSelectedItems((prev) => {
|
</td>
|
||||||
const next = new Set(prev);
|
<td className="px-3 py-2 font-mono">
|
||||||
if (checked) next.add(item.id);
|
{item.item_number}
|
||||||
else next.delete(item.id);
|
</td>
|
||||||
return next;
|
<td className="px-3 py-2">{item.item_name}</td>
|
||||||
});
|
<td className="px-3 py-2">{item.type}</td>
|
||||||
}}
|
<td className="px-3 py-2">{item.unit}</td>
|
||||||
/>
|
</tr>
|
||||||
</td>
|
))}
|
||||||
<td className="px-3 py-2 font-mono">
|
|
||||||
{item.item_number}
|
|
||||||
{alreadyAdded && <span className="text-muted-foreground ml-1 text-[10px]">(추가됨)</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">{item.item_name}</td>
|
|
||||||
<td className="px-3 py-2">{item.type}</td>
|
|
||||||
<td className="px-3 py-2">{item.unit}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
|
|
@ -751,40 +739,37 @@ export function BomItemEditorComponent({
|
||||||
[originalNotifyChange, markChanged],
|
[originalNotifyChange, markChanged],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
|
|
||||||
|
|
||||||
// EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장
|
// EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDesignMode || !bomId) return;
|
if (isDesignMode || !bomId) return;
|
||||||
const handler = (e: Event) => {
|
const handler = (e: Event) => {
|
||||||
const detail = (e as CustomEvent).detail;
|
const detail = (e as CustomEvent).detail;
|
||||||
if (handleSaveAllRef.current) {
|
console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", {
|
||||||
|
bomId,
|
||||||
|
treeDataLength: treeData.length,
|
||||||
|
hasRef: !!handleSaveAllRef.current,
|
||||||
|
});
|
||||||
|
if (treeData.length > 0 && handleSaveAllRef.current) {
|
||||||
const savePromise = handleSaveAllRef.current();
|
const savePromise = handleSaveAllRef.current();
|
||||||
if (detail?.pendingPromises) {
|
if (detail?.pendingPromises) {
|
||||||
detail.pendingPromises.push(savePromise);
|
detail.pendingPromises.push(savePromise);
|
||||||
|
console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("beforeFormSave", handler);
|
window.addEventListener("beforeFormSave", handler);
|
||||||
|
console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode });
|
||||||
return () => window.removeEventListener("beforeFormSave", handler);
|
return () => window.removeEventListener("beforeFormSave", handler);
|
||||||
}, [isDesignMode, bomId]);
|
}, [isDesignMode, bomId, treeData.length]);
|
||||||
|
|
||||||
|
const handleSaveAllRef = React.useRef<(() => Promise<void>) | null>(null);
|
||||||
|
|
||||||
const handleSaveAll = useCallback(async () => {
|
const handleSaveAll = useCallback(async () => {
|
||||||
if (!bomId) return;
|
if (!bomId) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
// version_id 확보: 없으면 서버에서 자동 초기화
|
// 저장 시점에도 최신 version_id 조회
|
||||||
let saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
|
const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId;
|
||||||
if (!saveVersionId) {
|
|
||||||
try {
|
|
||||||
const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`);
|
|
||||||
if (initRes.data?.success && initRes.data.data?.versionId) {
|
|
||||||
saveVersionId = initRes.data.data.versionId;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[BomItemEditor] 버전 초기화 실패:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => {
|
const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => {
|
||||||
const result: any[] = [];
|
const result: any[] = [];
|
||||||
|
|
@ -1353,18 +1338,6 @@ export function BomItemEditorComponent({
|
||||||
onClose={() => setItemSearchOpen(false)}
|
onClose={() => setItemSearchOpen(false)}
|
||||||
onSelect={handleItemSelect}
|
onSelect={handleItemSelect}
|
||||||
companyCode={companyCode}
|
companyCode={companyCode}
|
||||||
existingItemIds={useMemo(() => {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
const collect = (nodes: BomItemNode[]) => {
|
|
||||||
for (const n of nodes) {
|
|
||||||
const fk = n.data[cfg.dataSource?.foreignKey || "child_item_id"];
|
|
||||||
if (fk) ids.add(fk);
|
|
||||||
collect(n.children);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
collect(treeData);
|
|
||||||
return ids;
|
|
||||||
}, [treeData, cfg])}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,6 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
|
@ -42,20 +35,6 @@ export function BomDetailEditModal({
|
||||||
}: BomDetailEditModalProps) {
|
}: BomDetailEditModalProps) {
|
||||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [processOptions, setProcessOptions] = useState<{ value: string; label: string }[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && !isRootNode) {
|
|
||||||
apiClient.get("/table-categories/bom_detail/process_type/values")
|
|
||||||
.then((res) => {
|
|
||||||
const values = res.data?.data || [];
|
|
||||||
if (values.length > 0) {
|
|
||||||
setProcessOptions(values.map((v: any) => ({ value: v.value_code, label: v.value_label })));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => { /* 카테고리 없으면 빈 배열 유지 */ });
|
|
||||||
}
|
|
||||||
}, [open, isRootNode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (node && open) {
|
if (node && open) {
|
||||||
|
|
@ -88,15 +67,11 @@ export function BomDetailEditModal({
|
||||||
try {
|
try {
|
||||||
const targetTable = isRootNode ? "bom" : tableName;
|
const targetTable = isRootNode ? "bom" : tableName;
|
||||||
const realId = isRootNode ? node.id?.replace("__root_", "") : node.id;
|
const realId = isRootNode ? node.id?.replace("__root_", "") : node.id;
|
||||||
await apiClient.put(`/table-management/tables/${targetTable}/edit`, {
|
await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData);
|
||||||
originalData: { id: realId },
|
|
||||||
updatedData: { id: realId, ...formData },
|
|
||||||
});
|
|
||||||
onSaved?.();
|
onSaved?.();
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[BomDetailEdit] 저장 실패:", error);
|
console.error("[BomDetailEdit] 저장 실패:", error);
|
||||||
alert("저장 중 오류가 발생했습니다.");
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -164,28 +139,12 @@ export function BomDetailEditModal({
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs sm:text-sm">공정</Label>
|
<Label className="text-xs sm:text-sm">공정</Label>
|
||||||
{processOptions.length > 0 ? (
|
<Input
|
||||||
<Select
|
value={formData.process_type}
|
||||||
value={formData.process_type || ""}
|
onChange={(e) => handleChange("process_type", e.target.value)}
|
||||||
onValueChange={(v) => handleChange("process_type", v)}
|
placeholder="예: 조립공정"
|
||||||
>
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
<SelectTrigger className="mt-1 h-8 text-xs sm:h-10 sm:text-sm">
|
/>
|
||||||
<SelectValue placeholder="공정 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{processOptions.map((opt) => (
|
|
||||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<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>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-xs sm:text-sm">로스율 (%)</Label>
|
<Label className="text-xs sm:text-sm">로스율 (%)</Label>
|
||||||
|
|
|
||||||
|
|
@ -138,23 +138,6 @@ export function BomTreeComponent({
|
||||||
const showHistory = features.showHistory !== false;
|
const showHistory = features.showHistory !== false;
|
||||||
const showVersion = features.showVersion !== false;
|
const showVersion = features.showVersion !== false;
|
||||||
|
|
||||||
// 카테고리 라벨 캐시 (process_type 등)
|
|
||||||
const [categoryLabels, setCategoryLabels] = useState<Record<string, Record<string, string>>>({});
|
|
||||||
useEffect(() => {
|
|
||||||
const loadLabels = async () => {
|
|
||||||
try {
|
|
||||||
const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values`);
|
|
||||||
const vals = res.data?.data || [];
|
|
||||||
if (vals.length > 0) {
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
vals.forEach((v: any) => { map[v.value_code] = v.value_label; });
|
|
||||||
setCategoryLabels((prev) => ({ ...prev, process_type: map }));
|
|
||||||
}
|
|
||||||
} catch { /* 무시 */ }
|
|
||||||
};
|
|
||||||
loadLabels();
|
|
||||||
}, [detailTable]);
|
|
||||||
|
|
||||||
// ─── 데이터 로드 ───
|
// ─── 데이터 로드 ───
|
||||||
|
|
||||||
// BOM 헤더 데이터로 가상 0레벨 루트 노드 생성
|
// BOM 헤더 데이터로 가상 0레벨 루트 노드 생성
|
||||||
|
|
@ -185,18 +168,7 @@ export function BomTreeComponent({
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const searchFilter: Record<string, any> = { [foreignKey]: bomId };
|
const searchFilter: Record<string, any> = { [foreignKey]: bomId };
|
||||||
let versionId = headerData?.current_version_id;
|
const versionId = headerData?.current_version_id;
|
||||||
|
|
||||||
// version_id가 없으면 서버에서 자동 초기화
|
|
||||||
if (!versionId) {
|
|
||||||
try {
|
|
||||||
const initRes = await apiClient.post(`/bom/${bomId}/initialize-version`);
|
|
||||||
if (initRes.data?.success && initRes.data.data?.versionId) {
|
|
||||||
versionId = initRes.data.data.versionId;
|
|
||||||
}
|
|
||||||
} catch { /* 무시 */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (versionId) {
|
if (versionId) {
|
||||||
searchFilter.version_id = versionId;
|
searchFilter.version_id = versionId;
|
||||||
}
|
}
|
||||||
|
|
@ -489,11 +461,6 @@ export function BomTreeComponent({
|
||||||
return <span className="font-medium text-gray-900">{value || "-"}</span>;
|
return <span className="font-medium text-gray-900">{value || "-"}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (col.key === "status") {
|
|
||||||
const statusMap: Record<string, string> = { active: "사용", inactive: "미사용", developing: "개발중" };
|
|
||||||
return <span>{statusMap[String(value)] || value || "-"}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (col.key === "quantity" || col.key === "base_qty") {
|
if (col.key === "quantity" || col.key === "base_qty") {
|
||||||
return (
|
return (
|
||||||
<span className="font-medium tabular-nums text-gray-800">
|
<span className="font-medium tabular-nums text-gray-800">
|
||||||
|
|
@ -502,11 +469,6 @@ export function BomTreeComponent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (col.key === "process_type" && value) {
|
|
||||||
const label = categoryLabels.process_type?.[String(value)] || String(value);
|
|
||||||
return <span>{label}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (col.key === "loss_rate") {
|
if (col.key === "loss_rate") {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
if (!num) return <span className="text-gray-300">-</span>;
|
if (!num) return <span className="text-gray-300">-</span>;
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,6 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [actionId, setActionId] = useState<string | null>(null);
|
const [actionId, setActionId] = useState<string | null>(null);
|
||||||
const [newVersionName, setNewVersionName] = useState("");
|
|
||||||
const [showNewInput, setShowNewInput] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && bomId) loadVersions();
|
if (open && bomId) loadVersions();
|
||||||
|
|
@ -65,26 +63,11 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
|
||||||
|
|
||||||
const handleCreateVersion = async () => {
|
const handleCreateVersion = async () => {
|
||||||
if (!bomId) return;
|
if (!bomId) return;
|
||||||
const trimmed = newVersionName.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
alert("버전명을 입력해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.post(`/bom/${bomId}/versions`, {
|
const res = await apiClient.post(`/bom/${bomId}/versions`, { tableName, detailTable });
|
||||||
tableName, detailTable, versionName: trimmed,
|
if (res.data?.success) loadVersions();
|
||||||
});
|
} catch (error) {
|
||||||
if (res.data?.success) {
|
|
||||||
setNewVersionName("");
|
|
||||||
setShowNewInput(false);
|
|
||||||
loadVersions();
|
|
||||||
} else {
|
|
||||||
alert(res.data?.message || "버전 생성 실패");
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
const msg = error.response?.data?.message || "버전 생성 실패";
|
|
||||||
alert(msg);
|
|
||||||
console.error("[BomVersion] 생성 실패:", error);
|
console.error("[BomVersion] 생성 실패:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
|
|
@ -247,46 +230,15 @@ export function BomVersionModal({ open, onOpenChange, bomId, tableName = "bom_ve
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showNewInput && (
|
|
||||||
<div className="flex items-center gap-2 border-t pt-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newVersionName}
|
|
||||||
onChange={(e) => setNewVersionName(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleCreateVersion()}
|
|
||||||
placeholder="버전명 입력 (예: 2.0, B, 개선판)"
|
|
||||||
className="h-8 flex-1 rounded-md border px-3 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-10 sm:text-sm"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateVersion}
|
|
||||||
disabled={creating}
|
|
||||||
size="sm"
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
>
|
|
||||||
{creating ? <Loader2 className="h-4 w-4 animate-spin" /> : "생성"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => { setShowNewInput(false); setNewVersionName(""); }}
|
|
||||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
{!showNewInput && (
|
<Button
|
||||||
<Button
|
onClick={handleCreateVersion}
|
||||||
onClick={() => setShowNewInput(true)}
|
disabled={creating}
|
||||||
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 gap-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
{creating ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Plus className="mr-1 h-4 w-4" />}
|
||||||
신규 버전 생성
|
신규 버전 생성
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue