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 정밀도 개선 등
573 lines
23 KiB
JavaScript
573 lines
23 KiB
JavaScript
/* ─────────────────────────────────────────────────────────────
|
||
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);
|
||
});
|
||
};
|