Files
ExperionCrawler/src/Web/wwwroot/js/pid.js
windpacer 9bcba0a317 feat: 웹UI Phase 4 완료 — CSS 분리, pane 중첩 버그 수정, app.js 제거
Phase 4 — CSS 분리:
- style.css(2,230→670줄)에서 탭별 스타일을 css/<tab>.css 8개로 분할
  (t2s:437, pid:236, pb:106, hist:100, evt:50, opcsvr:14, llmchat:501, kbadmin:109)
- 크로스탭 공유 스타일(nm-*, hist-status, dt-picker 등)은 style.css 잔류
- index.html head에 11개 CSS link 태그 (1 style.css + 8 tab + 2 lib)

app.js 제거:
- index.html에서 <script src=/js/app.js> 참조 제거
- app.js → 10줄 placeholder (이미 Phase 0-3에서 모든 로직 이전 완료)

Pane wrapper 버그 수정:
- 16개 pane 파일에서 <section class=pane id=pane-xxx> wrapper 제거
- activateTab이 innerHTML로 주입 시 중첩 section + display:none 발생
- 내용이 전혀 안 보이는 문제 해결

문서 갱신:
- AGENTS.md: Frontend Architecture 섹션 추가
- 웹UI-개선플랜-byOPUS.md: Phase 0-4 완료 상태로 갱신, 결과 검증 추가

MCP:
- server.py: timestamp 정밀도 개선 등
2026-05-24 18:47:25 +09:00

573 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ─────────────────────────────────────────────────────────────
11 P&ID 추출
───────────────────────────────────────────────────────────── */
let pidCurrentPage = 1;
let pidPageSize = 20;
let pidLastResult = null; // Excel export용
async function pidUpload() {
const fileInput = document.getElementById('pid-file-input');
const file = fileInput.files[0];
const statusEl = document.getElementById('pid-upload-status');
if (!file) {
if (statusEl) statusEl.textContent = '❌ 파일을 선택하세요.';
return;
}
const ext = file.name.toLowerCase().split('.').pop();
if (!['dxf', 'pdf'].includes(ext)) {
if (statusEl) statusEl.textContent = '❌ 지원 형식: .dxf, .pdf';
return;
}
if (statusEl) statusEl.textContent = `📤 전송 중... (${(file.size / 1024).toFixed(0)} KB)`;
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/pid/upload', { method: 'POST', body: formData });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const data = await res.json();
if (statusEl) statusEl.textContent = `✅ 전송 완료: ${data.fileName} (${(data.fileSize / 1024).toFixed(0)} KB)`;
await pidLoadServerFiles(data.fileName);
} catch (e) {
if (statusEl) statusEl.textContent = `❌ 전송 실패: ${e.message}`;
}
}
async function pidLoadServerFiles(selectFileName) {
const sel = document.getElementById('pid-server-file');
if (!sel) return;
try {
const res = await fetch('/api/pid/server-files');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
// 응답이 JSON인지 확인
const contentType = res.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
const text = await res.text();
// HTML이 반환되면 P&ID 컨트롤러가 비활성화된 것
if (text.startsWith('<!DOCTYPE') || text.startsWith('<html')) {
throw new Error('P&ID 기능이 비활성화되어 있습니다. 관리자에게 문의하세요.');
}
throw new Error('예상치 못한 응답 형식');
}
const data = await res.json();
if (!data.files || data.files.length === 0) {
sel.innerHTML = '<option value="">-- 서버에 파일 없음 --</option>';
return;
}
sel.innerHTML = data.files.map(f =>
`<option value="${esc(f.fileName)}">${esc(f.fileName)} (${(f.fileSize / 1024).toFixed(0)} KB)</option>`
).join('');
if (selectFileName) sel.value = selectFileName;
} catch (e) {
sel.innerHTML = `<option value="">오류: ${e.message}</option>`;
}
}
// P&ID 추출 진행 상태 — 탭 전환 시 상태 초기화 방지
let pidExtracting = false;
let pidElapsedInterval = null;
async function pidExtract() {
const fileName = document.getElementById('pid-server-file').value;
const statusEl = document.getElementById('pid-status');
const logEl = document.getElementById('pid-log');
const elapsedEl = document.getElementById('pid-elapsed');
if (!fileName) {
if (statusEl) statusEl.textContent = '❌ 서버 파일을 선택하세요.';
return;
}
pidExtracting = true;
if (statusEl) statusEl.textContent = '추출 중...';
if (logEl) {
logEl.style.display = 'block';
logEl.innerHTML = '';
}
// 경과 시간 시계 시작
const startTime = Date.now();
if (elapsedEl) {
elapsedEl.style.display = 'inline';
pidElapsedInterval = setInterval(() => {
const seconds = Math.floor((Date.now() - startTime) / 1000);
const m = String(Math.floor(seconds / 60)).padStart(2, '0');
const s = String(seconds % 60).padStart(2, '0');
elapsedEl.textContent = `${m}:${s}`;
}, 1000);
}
try {
const res = await fetch('/api/pid/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName, useImageMode: false })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const data = await res.json();
pidExtracting = false;
const skipMsg = data.skippedDuplicates > 0 ? ` (${data.skippedDuplicates}건 중복 제외)` : '';
if (statusEl) statusEl.textContent = `✅ 추출 완료: ${data.totalCount}${skipMsg}`;
log('pid-log', [
{ c: 'ok', t: `✅ 추출 완료: ${data.totalCount}${skipMsg}` },
{ c: 'inf', t: ` 신뢰도 70%+: ${data.confidenceItems}` },
{ c: 'inf', t: ` 신뢰도 50%미만: ${data.lowConfidenceItems}` },
...(data.skippedDuplicates > 0 ? [{ c: 'warn', t: ` 중복 스킵: ${data.skippedDuplicates}` }] : [])
]);
pidCurrentPage = 1;
await pidLoadTable();
pidUpdateStats();
} catch (e) {
pidExtracting = false;
if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`;
log('pid-log', [{ c: 'err', t: `${e.message}` }]);
} finally {
// 경과 시간 시계 종료
if (pidElapsedInterval) {
clearInterval(pidElapsedInterval);
pidElapsedInterval = null;
}
if (elapsedEl) elapsedEl.style.display = 'none';
}
}
async function pidLoadTable(page = 1) {
pidCurrentPage = page;
const container = document.getElementById('pid-table-container');
const tbody = document.getElementById('pid-table-body');
if (!container || !tbody) return;
// tbody만 비우고 로딩 상태 표시 — container.innerHTML을 덮어쓰면 tbody가 DOM에서 떨어져 나가
// 이후 tbody.innerHTML 할당이 화면에 반영되지 않음.
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:20px">로딩 중...</td></tr>';
try {
const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
pidLastResult = data;
if (!data.items || data.items.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:20px">데이터가 없습니다</td></tr>';
document.getElementById('pid-pagination').innerHTML = '';
return;
}
tbody.innerHTML = data.items.map(item => `
<tr>
<td>${item.id}</td>
<td><strong>${esc(item.tagName)}</strong></td>
<td>${esc(item.equipmentName) || '-'}</td>
<td>${esc(item.instrumentType) || '-'}</td>
<td>${esc(item.lineNumber) || '-'}</td>
<td>${esc(item.pidDrawingNo) || '-'}</td>
<td style="text-align:center">${(item.confidence * 100).toFixed(1)}%</td>
<td style="text-align:center">
<span class="badge ${item.isActive ? 'ok' : 'warn'}">${item.isActive ? '활성' : '비활성'}</span>
</td>
<td>
${item.experionTagId
? `<span class="badge ok">✅ ${esc(item.experionTagName || '')}</span>`
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id})">매핑</button>`
}
</td>
<td>${item.category
? `<span class="badge ${pidCategoryBadge(item.category)}">${esc(item.category)}</span>`
: '-'}
</td>
<td style="text-align:center">
<button class="btn-sm btn-b" onclick="pidDeleteRow(${item.id})" title="삭제">삭제</button>
</td>
</tr>
`).join('');
pidRenderPagination(data.total, page);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center;padding:20px;color:var(--red)">오류: ${e.message}</td></tr>`;
}
}
function pidRenderPagination(total, currentPage) {
const pagination = document.getElementById('pid-pagination');
if (!pagination) return;
const totalPages = Math.ceil(total / pidPageSize);
if (totalPages <= 1) {
pagination.innerHTML = '';
return;
}
let html = '';
const start = Math.max(1, currentPage - 3);
const end = Math.min(totalPages, currentPage + 3);
if (currentPage > 1) {
html += `<button class="btn-sm" onclick="pidLoadTable(${currentPage - 1})"></button>`;
}
for (let i = start; i <= end; i++) {
html += `<button class="btn-sm ${i === currentPage ? 'btn-a' : 'btn-b'}"
onclick="pidLoadTable(${i})">${i}</button>`;
}
if (currentPage < totalPages) {
html += `<button class="btn-sm" onclick="pidLoadTable(${currentPage + 1})"></button>`;
}
pagination.innerHTML = html;
}
async function pidDeleteRow(id) {
if (!confirm('정말 이 레코드를 삭제하시겠습니까?')) return;
try {
const res = await fetch(`/api/pid/${id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || res.statusText);
}
await pidLoadTable(pidCurrentPage);
pidUpdateStats();
} catch (e) {
alert(`삭제 실패: ${e.message}`);
}
}
function pidUpdateStats() {
if (!pidLastResult || !pidLastResult.items) return;
const total = pidLastResult.total || 0;
const highConf = pidLastResult.items.filter(i => i.confidence >= 0.7).length;
const mapped = pidLastResult.items.filter(i => i.experionTagId).length;
const elTotal = document.getElementById('pid-stat-total');
const elHigh = document.getElementById('pid-stat-high');
const elMapped = document.getElementById('pid-stat-mapped');
if (elTotal) elTotal.textContent = total;
if (elHigh) elHigh.textContent = highConf;
if (elMapped) elMapped.textContent = mapped;
}
function pidClearLog() {
const logEl = document.getElementById('pid-log');
if (logEl) logEl.innerHTML = '';
}
async function pidAnalyzeConnections() {
const fileName = document.getElementById('pid-server-file').value;
const statusEl = document.getElementById('pid-status');
const logEl = document.getElementById('pid-log');
if (!fileName) {
if (statusEl) statusEl.textContent = '❌ 서버 파일을 선택하세요.';
return;
}
if (statusEl) statusEl.textContent = '연결 분석 중...';
if (logEl) {
logEl.style.display = 'block';
logEl.innerHTML = '<div class="ll inf">⏳ 연결 분석 시작...</div>';
}
try {
const res = await fetch('/api/pid/connections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const data = await res.json();
if (statusEl) statusEl.textContent = `✅ 연결 분석 완료: ${data.connectionCount}`;
log('pid-log', [
{ c: 'ok', t: `✅ 연결 분석 완료: ${data.connectionCount}개 from→to 연결` }
]);
pidCurrentPage = 1;
await pidLoadTable();
pidUpdateStats();
} catch (e) {
if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`;
log('pid-log', [{ c: 'err', t: `${e.message}` }]);
}
}
async function pidDeleteAll() {
if (!confirm('P&ID 추출 데이터를 전부 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.')) return;
const statusEl = document.getElementById('pid-status');
const logEl = document.getElementById('pid-log');
if (statusEl) statusEl.textContent = '전체 삭제 중...';
if (logEl) {
logEl.style.display = 'block';
logEl.innerHTML = '<div class="ll inf">⏳ 삭제 중...</div>';
}
try {
const res = await fetch('/api/pid/all', { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (statusEl) statusEl.textContent = `🗑 ${data.message}`;
log('pid-log', [{ c: 'warn', t: `🗑 전체 삭제 완료: ${data.deletedCount}` }]);
pidCurrentPage = 1;
await pidLoadTable();
pidUpdateStats();
} catch (e) {
if (statusEl) statusEl.textContent = `❌ 오류: ${e.message}`;
log('pid-log', [{ c: 'err', t: `${e.message}` }]);
}
}
/* ── Prefix 분류 정의 ─────────────────────────────────────── */
let pidPrefixPanelVisible = false;
function pidTogglePrefixPanel() {
const panel = document.getElementById('pid-prefix-panel');
const toggle = document.getElementById('pid-prefix-toggle');
pidPrefixPanelVisible = !pidPrefixPanelVisible;
if (panel) panel.style.display = pidPrefixPanelVisible ? 'block' : 'none';
if (toggle) toggle.textContent = pidPrefixPanelVisible ? '▲' : '▼';
if (pidPrefixPanelVisible) pidRefreshPrefixRules();
}
const CATEGORY_META = {
instrument: { label: 'Instrument', badge: 'ok' },
power_equipment: { label: 'Power Equipment', badge: 'warn' },
storage_equipment: { label: 'Storage Equipment', badge: 'inf' },
process_equipment: { label: 'Process Equipment', badge: '' },
utility_equipment: { label: 'Utility Equipment', badge: 'warn' },
pipings: { label: 'Pipings', badge: 'ok' }
};
const CATEGORY_ORDER = ['instrument', 'power_equipment', 'storage_equipment', 'process_equipment', 'utility_equipment', 'pipings'];
function pidCategoryBadge(cat) {
return CATEGORY_META[cat] ? CATEGORY_META[cat].badge : '';
}
async function pidRefreshPrefixRules() {
const container = document.getElementById('pid-prefix-groups');
if (!container) return;
container.innerHTML = '<div style="text-align:center;padding:12px;color:var(--t2)">로딩 중...</div>';
try {
const res = await fetch('/api/pid/prefix-rules');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) {
container.innerHTML = '<div style="text-align:center;padding:12px;color:var(--t2)">규칙이 없습니다. 아래에서 추가하세요.</div>';
return;
}
const grouped = {};
for (const r of items) {
if (!grouped[r.category]) grouped[r.category] = [];
grouped[r.category].push(r);
}
let html = '';
for (const cat of CATEGORY_ORDER) {
const rules = grouped[cat];
if (!rules) continue;
const meta = CATEGORY_META[cat] || { label: cat, badge: '' };
rules.sort((a, b) => a.sortOrder - b.sortOrder);
html += `<div class="pid-cat-group">
<div class="pid-cat-header">
<span class="badge ${meta.badge}">${meta.label}</span>
<span class="pid-cat-count">${rules.length}</span>
<span class="pid-cat-add" style="margin-left:auto">
<input class="inp pid-cat-prefix-input" placeholder="예: FT" style="width:90px;font-family:var(--mono)" />
<input class="inp pid-cat-desc-input" placeholder="설명 (선택)" style="width:160px" />
<input class="inp pid-cat-order-input" type="number" value="10" style="width:50px" />
<button class="btn-a btn-sm" onclick="pidAddPrefixRule('${cat}')">추가</button>
</span>
</div>
<div class="pid-cat-body">`;
for (const r of rules) {
html += `<div class="pid-cat-row">
<input class="inp pid-cat-prefix-input" value="${esc(r.prefix)}" style="width:80px;font-family:var(--mono)" />
<input class="inp pid-cat-desc-input" value="${esc(r.description) || ''}" style="flex:1;min-width:0" />
<input class="inp pid-cat-order-input" type="number" value="${r.sortOrder}" style="width:44px" />
<span class="pid-cat-actions">
<button class="btn-sm btn-a" onclick="pidUpdatePrefixRule(${r.id}, this)">수정</button>
<button class="btn-sm btn-b" onclick="pidDeletePrefixRule(${r.id}, '${esc(r.prefix)}')">삭제</button>
</span>
</div>`;
}
html += `</div></div>`;
}
container.innerHTML = html;
} catch (e) {
container.innerHTML = `<div style="text-align:center;padding:12px;color:var(--red)">오류: ${e.message}</div>`;
}
}
async function pidAddPrefixRule(category) {
const row = event.target.closest('.pid-cat-add');
const prefix = row.querySelector('.pid-cat-prefix-input').value.trim();
const desc = row.querySelector('.pid-cat-desc-input').value.trim();
const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10;
if (!prefix) { alert('Prefix를 입력하세요.'); return; }
try {
const res = await fetch('/api/pid/prefix-rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix, category, description: desc, sortOrder: order })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
row.querySelector('.pid-cat-prefix-input').value = '';
row.querySelector('.pid-cat-desc-input').value = '';
await pidRefreshPrefixRules();
} catch (e) {
alert(`추가 실패: ${e.message}`);
}
}
async function pidUpdatePrefixRule(id, btn) {
const row = btn.closest('.pid-cat-row');
const group = btn.closest('.pid-cat-group');
const catIdx = [...group.parentElement.children].indexOf(group);
const cat = CATEGORY_ORDER[catIdx];
const prefix = row.querySelector('.pid-cat-prefix-input').value.trim();
const desc = row.querySelector('.pid-cat-desc-input').value.trim();
const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10;
if (!prefix) { alert('Prefix를 입력하세요.'); return; }
try {
const res = await fetch(`/api/pid/prefix-rules/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prefix, category: cat, description: desc, sortOrder: order })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
await pidRefreshPrefixRules();
} catch (e) {
alert(`수정 실패: ${e.message}`);
}
}
async function pidDeletePrefixRule(id, prefix) {
if (!confirm(`"${prefix}" 규칙을 삭제하시겠습니까?`)) return;
try {
const res = await fetch(`/api/pid/prefix-rules/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
await pidRefreshPrefixRules();
} catch (e) {
alert(`삭제 실패: ${e.message}`);
}
}
async function pidApplyCategories() {
if (!confirm('기존 미분류 항목에 category를 재적용하시겠습니까?')) return;
try {
const res = await fetch('/api/pid/apply-categories', { method: 'POST' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
alert(`${data.applied}건 category 적용 완료`);
await pidLoadTable(pidCurrentPage);
pidUpdateStats();
} catch (e) {
alert(`재적용 실패: ${e.message}`);
}
}
function pidOpenMapping(id) {
// 매핑 모달 열기 (추후 구현)
console.log('pidOpenMapping:', id);
}
// 탭 진입 시 초기화
paneInit.pid = async function() {
pidCurrentPage = 1;
pidLastResult = null;
if (document.getElementById('pid-file-input')) document.getElementById('pid-file-input').value = '';
if (!pidExtracting) {
const st = document.getElementById('pid-status');
if (st) st.textContent = '대기 중...';
const elapsedEl = document.getElementById('pid-elapsed');
if (elapsedEl) elapsedEl.style.display = 'none';
if (pidElapsedInterval) {
clearInterval(pidElapsedInterval);
pidElapsedInterval = null;
}
const tb = document.getElementById('pid-table-body');
if (tb) tb.innerHTML = '';
const pg = document.getElementById('pid-pagination');
if (pg) pg.innerHTML = '';
const stTot = document.getElementById('pid-stat-total');
if (stTot) stTot.textContent = '0';
const stHi = document.getElementById('pid-stat-high');
if (stHi) stHi.textContent = '0';
const stMap = document.getElementById('pid-stat-mapped');
if (stMap) stMap.textContent = '0';
await pidLoadTable(1);
}
// Export buttons — attach once
const bind = (id, fn) => {
const el = document.getElementById(id);
if (el && !el.dataset.pidBound) { el.addEventListener('click', fn); el.dataset.pidBound = '1'; }
};
bind('btn-pid-export-csv', async () => {
if (!pidLastResult) return;
const res = await fetch('/api/pid/export/csv');
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pid-export-${new Date().toISOString().slice(0,10)}.csv`;
a.click();
URL.revokeObjectURL(url);
});
bind('btn-pid-export-excel', async () => {
if (!pidLastResult) return;
const res = await fetch('/api/pid/export/excel');
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pid-export-${new Date().toISOString().slice(0,10)}.xlsx`;
a.click();
URL.revokeObjectURL(url);
});
};