Files
njts-accounting-core/frontend/nenkin-portal.html
2026-05-22 23:05:38 +09:00

930 lines
29 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>社会保険電子文書ポータル</title>
<script src="js/auth.js"></script>
<link rel="stylesheet" href="css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
<style>
body {
font-family:
system-ui,
-apple-system,
"Yu Gothic UI",
"Hiragino Kaku Gothic ProN",
"メイリオ",
sans-serif;
padding: 24px;
background: #f5f7fa;
}
.page-header {
max-width: 1100px;
margin: 0 auto 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.page-header h1 {
margin: 0;
font-size: 20px;
}
.back-link {
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
}
.back-link:hover {
background: #0056b3;
}
/* ── アップロードゾーン ── */
.upload-zone {
max-width: 1100px;
margin: 0 auto 24px;
border: 2px dashed #adb5bd;
border-radius: 10px;
padding: 28px 24px;
text-align: center;
background: #fff;
cursor: pointer;
transition:
border-color 0.2s,
background 0.2s;
}
.upload-zone.dragover {
border-color: #007bff;
background: #e8f4ff;
}
.upload-zone h3 {
margin: 0 0 8px;
font-size: 16px;
color: #495057;
}
.upload-zone p {
margin: 0;
color: #6c757d;
font-size: 13px;
}
.upload-zone input[type="file"] {
display: none;
}
.upload-btn {
display: inline-block;
margin-top: 12px;
padding: 8px 20px;
background: #007bff;
color: #fff;
border-radius: 5px;
font-size: 14px;
cursor: pointer;
border: none;
}
.upload-btn:hover {
background: #0056b3;
}
#upload-status {
max-width: 1100px;
margin: 0 auto 12px;
font-size: 14px;
min-height: 24px;
}
.status-ok {
color: #28a745;
font-weight: bold;
}
.status-err {
color: #dc3545;
font-weight: bold;
}
/* ── フィルターバー ── */
.filter-bar {
max-width: 1100px;
margin: 0 auto 16px;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.type-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.type-tab {
padding: 5px 14px;
border-radius: 20px;
border: 1px solid #ced4da;
background: #fff;
cursor: pointer;
font-size: 13px;
color: #495057;
transition: all 0.15s;
}
.type-tab:hover {
border-color: #007bff;
color: #007bff;
}
.type-tab.active {
background: #007bff;
color: #fff;
border-color: #007bff;
}
.filter-select {
padding: 5px 10px;
border: 1px solid #ced4da;
border-radius: 5px;
font-size: 13px;
background: #fff;
}
/* ── 文書一覧テーブル ── */
.doc-table-wrap {
max-width: 1100px;
margin: 0 auto;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.07);
overflow: hidden;
}
.doc-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.doc-table thead th {
background: #f8f9fa;
padding: 10px 12px;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #dee2e6;
white-space: nowrap;
}
.doc-table tbody tr {
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.1s;
}
.doc-table tbody tr:hover {
background: #f0f7ff;
}
.doc-table tbody tr.selected {
background: #dbeafe;
}
.doc-table tbody td {
padding: 10px 12px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.badge-shakai {
background: #d1ecf1;
color: #0c5460;
}
.badge-nounyu {
background: #d4edda;
color: #155724;
}
.badge-shoyo {
background: #fff3cd;
color: #856404;
}
.badge-unknown {
background: #e2e3e5;
color: #383d41;
}
.no-data {
text-align: center;
color: #aaa;
padding: 40px;
}
/* ── 詳細パネル ── */
#detail-panel {
max-width: 1100px;
margin: 16px auto 0;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.07);
padding: 24px;
display: none;
}
#detail-panel.visible {
display: block;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.detail-header h2 {
margin: 0;
font-size: 18px;
}
.detail-close {
background: none;
border: 1px solid #ced4da;
border-radius: 4px;
padding: 4px 10px;
cursor: pointer;
font-size: 13px;
color: #6c757d;
}
.detail-close:hover {
background: #f8f9fa;
}
/* 事業所情報カード */
.info-cards {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.info-card {
flex: 1;
min-width: 180px;
background: #f8f9fa;
border-radius: 6px;
padding: 12px 16px;
}
.info-card .label {
font-size: 11px;
color: #6c757d;
margin-bottom: 4px;
}
.info-card .value {
font-size: 14px;
font-weight: 600;
color: #212529;
}
/* 金額テーブル */
.amount-section {
margin-bottom: 20px;
}
.amount-section h3 {
font-size: 15px;
margin: 0 0 12px;
border-left: 3px solid #007bff;
padding-left: 8px;
}
.amount-grid {
display: grid;
gap: 10px;
}
.amount-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 14px;
background: #f8f9fa;
border-radius: 5px;
font-size: 14px;
}
.amount-row.total {
background: #e8f4ff;
font-weight: 700;
font-size: 15px;
}
.amount-row .amt {
font-family: monospace;
font-size: 15px;
}
/* 告知額 vs 領収済額 サイドバイサイド */
.nounyu-compare {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 20px;
}
@media (max-width: 640px) {
.nounyu-compare {
grid-template-columns: 1fr;
}
}
.nounyu-box {
border-radius: 8px;
padding: 16px;
}
.nounyu-box.kokuchi {
background: #fff3cd;
border: 1px solid #ffc107;
}
.nounyu-box.ryoshu {
background: #d4edda;
border: 1px solid #28a745;
}
.nounyu-box h4 {
margin: 0 0 12px;
font-size: 14px;
}
/* 賞与明細テーブル */
.shoyo-table-wrap {
overflow-x: auto;
}
.shoyo-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.shoyo-table th {
background: #f8f9fa;
padding: 8px 10px;
text-align: center;
border: 1px solid #dee2e6;
white-space: nowrap;
font-size: 12px;
}
.shoyo-table td {
padding: 7px 10px;
border: 1px solid #f0f0f0;
text-align: right;
}
.shoyo-table td.name {
text-align: left;
font-weight: 600;
}
.shoyo-table tfoot td {
background: #e8f4ff;
font-weight: 700;
border-top: 2px solid #007bff;
}
.shoyo-table .empty-cell {
color: #ccc;
text-align: center;
}
</style>
</head>
<body>
<div class="page-header">
<h1>社会保険電子文書ポータル</h1>
<a class="back-link" href="payroll.html">← 給与管理メニューに戻る</a>
</div>
<!-- アップロードゾーン -->
<div class="upload-zone" id="upload-zone">
<h3>📥 社会保険 ZIP ファイルをここにドロップ</h3>
<p>またはボタンから選択してください(複数ファイル対応)</p>
<input type="file" id="zip-input" accept=".zip" multiple />
<br />
<button
class="upload-btn"
onclick="document.getElementById('zip-input').click()"
>
ファイルを選択
</button>
</div>
<div id="upload-status"></div>
<!-- フィルターバー -->
<div class="filter-bar">
<div class="type-tabs" id="type-tabs">
<button class="type-tab active" data-type="">すべて</button>
<button class="type-tab" data-type="SHAKAI">社会保険料額情報</button>
<button class="type-tab" data-type="NOUNYU">納入告知・領収</button>
<button class="type-tab" data-type="SHOYO">賞与保険料算出</button>
</div>
<select class="filter-select" id="year-select">
<option value="">年度(すべて)</option>
</select>
<select class="filter-select" id="month-select">
<option value="">月(すべて)</option>
<option value="1">1月</option>
<option value="2">2月</option>
<option value="3">3月</option>
<option value="4">4月</option>
<option value="5">5月</option>
<option value="6">6月</option>
<option value="7">7月</option>
<option value="8">8月</option>
<option value="9">9月</option>
<option value="10">10月</option>
<option value="11">11月</option>
<option value="12">12月</option>
</select>
</div>
<!-- 文書一覧テーブル -->
<div class="doc-table-wrap">
<table class="doc-table">
<thead>
<tr>
<th>種別</th>
<th>対象年月</th>
<th>事業所名</th>
<th>整理記号・番号</th>
<th>年金事務所</th>
<th>発行日</th>
<th>登録日時</th>
</tr>
</thead>
<tbody id="doc-list-body">
<tr>
<td colspan="7" class="no-data">データを読み込んでいます...</td>
</tr>
</tbody>
</table>
</div>
<!-- 詳細パネル -->
<div id="detail-panel">
<div class="detail-header">
<h2 id="detail-title">詳細</h2>
<button class="detail-close" onclick="closeDetail()">✕ 閉じる</button>
</div>
<div id="detail-body"></div>
</div>
<script>
const API = "";
let currentFilter = { type: "", year: "", month: "" };
let selectedDocId = null;
// ──────────────────────────────────
// ページ初期化
// ──────────────────────────────────
document.addEventListener("DOMContentLoaded", () => {
checkAuth();
loadDocuments();
loadFilterOptions();
initUploadZone();
document.getElementById("type-tabs").addEventListener("click", (e) => {
const tab = e.target.closest(".type-tab");
if (!tab) return;
document
.querySelectorAll(".type-tab")
.forEach((t) => t.classList.remove("active"));
tab.classList.add("active");
currentFilter.type = tab.dataset.type;
loadDocuments();
});
document
.getElementById("year-select")
.addEventListener("change", (e) => {
currentFilter.year = e.target.value;
loadDocuments();
});
document
.getElementById("month-select")
.addEventListener("change", (e) => {
currentFilter.month = e.target.value;
loadDocuments();
});
});
// ──────────────────────────────────
// 文書一覧ロード
// ──────────────────────────────────
async function loadDocuments() {
const params = new URLSearchParams();
if (currentFilter.type) params.set("doc_type", currentFilter.type);
if (currentFilter.year) params.set("year", currentFilter.year);
if (currentFilter.month) params.set("month", currentFilter.month);
const url = `${API}/nenkin/documents${params.toString() ? "?" + params : ""}`;
try {
const res = await fetch(url);
const docs = await res.json();
renderDocList(docs);
} catch (e) {
document.getElementById("doc-list-body").innerHTML =
`<tr><td colspan="7" class="no-data" style="color:#dc3545">読み込みエラー: ${e.message}</td></tr>`;
}
}
function renderDocList(docs) {
const tbody = document.getElementById("doc-list-body");
if (!docs.length) {
tbody.innerHTML =
'<tr><td colspan="7" class="no-data">文書が登録されていません</td></tr>';
return;
}
tbody.innerHTML = docs
.map((d) => {
const period =
d.purpose_year && d.purpose_month
? `${d.purpose_year}${d.purpose_month}`
: "";
const issueDate = d.issue_date
? d.issue_date.replace(/-/g, "/")
: "";
const uploadedAt = d.uploaded_at
? d.uploaded_at.slice(0, 16).replace("T", " ")
: "";
const selected = d.id === selectedDocId ? "selected" : "";
return `<tr class="${selected}" onclick="loadDetail(${d.id})" data-id="${d.id}">
<td>${badgeHtml(d.document_type, d.document_type_name)}</td>
<td>${period}</td>
<td>${escHtml(d.office_name || "")}</td>
<td><small>${escHtml(d.office_kigo || "")} ${escHtml(d.office_number || "")}</small></td>
<td>${escHtml(d.nenkin_jimusho || "")}</td>
<td>${issueDate}</td>
<td><small>${uploadedAt}</small></td>
</tr>`;
})
.join("");
}
function badgeHtml(type, name) {
const cls =
{
SHAKAI: "badge-shakai",
NOUNYU: "badge-nounyu",
SHOYO: "badge-shoyo",
}[type] || "badge-unknown";
return `<span class="badge ${cls}">${escHtml(name || type)}</span>`;
}
// ──────────────────────────────────
// フィルター選択肢(年度)
// ──────────────────────────────────
async function loadFilterOptions() {
try {
const res = await fetch(`${API}/nenkin/filter-options`);
const data = await res.json();
const sel = document.getElementById("year-select");
const years = [
...new Set(data.periods.map((p) => p.purpose_year)),
].sort((a, b) => b - a);
years.forEach((y) => {
const opt = document.createElement("option");
opt.value = y;
opt.textContent = `${y}`;
sel.appendChild(opt);
});
} catch (_) {}
}
// ──────────────────────────────────
// 詳細ロード
// ──────────────────────────────────
async function loadDetail(docId) {
// 選択行のハイライト
selectedDocId = docId;
document.querySelectorAll(".doc-table tbody tr").forEach((tr) => {
tr.classList.toggle("selected", parseInt(tr.dataset.id) === docId);
});
try {
const res = await fetch(`${API}/nenkin/documents/${docId}`);
const data = await res.json();
renderDetail(data);
} catch (e) {
showDetailError(e.message);
}
}
function closeDetail() {
selectedDocId = null;
document.getElementById("detail-panel").classList.remove("visible");
document
.querySelectorAll(".doc-table tbody tr")
.forEach((tr) => tr.classList.remove("selected"));
}
function renderDetail({ document: doc, detail }) {
const panel = document.getElementById("detail-panel");
const title = document.getElementById("detail-title");
const body = document.getElementById("detail-body");
const period =
doc.purpose_year && doc.purpose_month
? `${doc.purpose_year}${doc.purpose_month}月分`
: "";
title.textContent = `${doc.document_type_name} ${period}`;
// 共通事業所カード
const infoHtml = `
<div class="info-cards">
<div class="info-card">
<div class="label">事業所名</div>
<div class="value">${escHtml(doc.office_name || "")}</div>
</div>
<div class="info-card">
<div class="label">事業所番号 / 整理記号</div>
<div class="value">${escHtml(doc.office_number || "")} / ${escHtml(doc.office_kigo || "")}</div>
</div>
<div class="info-card">
<div class="label">担当年金事務所</div>
<div class="value">${escHtml(doc.nenkin_jimusho || "")}</div>
</div>
${
doc.issue_date
? `<div class="info-card">
<div class="label">発行日</div>
<div class="value">${doc.issue_date.replace(/-/g, "/")}</div>
</div>`
: ""
}
</div>`;
let typeHtml = "";
if (doc.document_type === "SHAKAI") {
typeHtml = renderShakai(detail);
} else if (doc.document_type === "NOUNYU") {
typeHtml = renderNounyu(detail);
} else if (doc.document_type === "SHOYO") {
typeHtml = renderShoyo(detail);
}
body.innerHTML = infoHtml + typeHtml;
panel.classList.add("visible");
panel.scrollIntoView({ behavior: "smooth", block: "start" });
}
// ──────────────────────────────────
// 社会保険料額情報 (SHAKAI)
// ──────────────────────────────────
function renderShakai(d) {
const kigen = d.nofu_kigen_date
? d.nofu_kigen_date.replace(/-/g, "/")
: "";
const addr = [
d.office_postcode ? `${d.office_postcode}` : "",
d.office_address,
]
.filter(Boolean)
.join(" ");
return `
<div class="amount-section">
<h3>保険料額</h3>
<div class="amount-grid">
${amtRow("健康保険料", d.kenkou_hokenryo)}
${amtRow("厚生年金保険料", d.kousei_nenkin_hokenryo)}
${amtRow("子ども・子育て拠出金", d.kodomo_kyoshutsukin)}
${amtRow("合 計", d.total, true)}
</div>
</div>
<div class="info-cards">
<div class="info-card">
<div class="label">納付期限</div>
<div class="value">${kigen}</div>
</div>
${
addr
? `<div class="info-card" style="flex:2">
<div class="label">事業所所在地</div>
<div class="value">${escHtml(addr)}</div>
</div>`
: ""
}
</div>`;
}
// ──────────────────────────────────
// 保険料納入告知額・領収済額 (NOUNYU)
// ──────────────────────────────────
function renderNounyu(d) {
const kigen = d.kokuchi_nofu_kigen
? d.kokuchi_nofu_kigen.replace(/-/g, "/")
: "";
const ryoshuDt = d.ryoshu_date ? d.ryoshu_date.replace(/-/g, "/") : "";
const ryoshuPeriod =
d.ryoshu_nofu_year && d.ryoshu_nofu_month
? `${d.ryoshu_nofu_year}${d.ryoshu_nofu_month}月分`
: "";
return `
<div class="nounyu-compare">
<div class="nounyu-box kokuchi">
<h4>📋 今月の保険料告知額(納付期限: ${kigen}</h4>
<div class="amount-grid">
${amtRow("健康保険料", d.kokuchi_kenkou)}
${amtRow("厚生年金保険料", d.kokuchi_kounen)}
${amtRow("子ども・子育て拠出金", d.kokuchi_kodomo)}
${amtRow("合 計", d.kokuchi_total, true)}
</div>
</div>
<div class="nounyu-box ryoshu">
<h4>✅ 前月分領収済額(${ryoshuPeriod}・領収日: ${ryoshuDt}</h4>
<div class="amount-grid">
${amtRow("健康保険料", d.ryoshu_kenkou)}
${amtRow("厚生年金保険料", d.ryoshu_kounen)}
${amtRow("子ども・子育て拠出金", d.ryoshu_kodomo)}
${amtRow("合 計", d.ryoshu_total, true)}
</div>
</div>
</div>
<div class="info-cards">
<div class="info-card">
<div class="label">事業所所在地</div>
<div class="value">${escHtml([d.office_postcode ? "〒" + d.office_postcode : "", d.office_address].filter(Boolean).join(" "))}</div>
</div>
</div>`;
}
// ──────────────────────────────────
// 賞与保険料算出内訳書 (SHOYO)
// ──────────────────────────────────
function renderShoyo(d) {
if (!d || !d.items) return "<p>詳細データなし</p>";
const totKenpo = sum(d.items, "kenpo_hongetsu");
const totKounen = sum(d.items, "kounen_hongetsu");
const totHKenpo = sum(d.items, "hyojun_shoyo_kenpo");
const totHKounen = sum(d.items, "hyojun_shoyo_kounen");
const rows = d.items
.map((it) => {
const hassei = it.hassei_date_iso
? it.hassei_date_iso.replace(/-/g, "/")
: it.hassei_ymd || "";
return `<tr>
<td>${escHtml(it.seiri_num || "")}</td>
<td class="name">${escHtml(it.shimei || "")}</td>
<td>${hassei}</td>
<td>${fmtAmt(it.hyojun_shoyo_kenpo)}</td>
<td>${fmtAmt(it.hyojun_shoyo_kounen)}</td>
<td>${fmtAmt(it.kenpo_hongetsu)}</td>
<td>${it.kenpo_zengetsu ? fmtAmt(it.kenpo_zengetsu) : '<span class="empty-cell"></span>'}</td>
<td>${fmtAmt(it.kounen_hongetsu)}</td>
<td>${it.kounen_zengetsu ? fmtAmt(it.kounen_zengetsu) : '<span class="empty-cell"></span>'}</td>
</tr>`;
})
.join("");
return `
<div class="info-cards" style="margin-bottom:16px">
<div class="info-card">
<div class="label">対象人員</div>
<div class="value">${d.jin_in_num} 名</div>
</div>
${
d.menjyo_hokenryo_ritsu
? `<div class="info-card">
<div class="label">免除保険料率</div>
<div class="value">${escHtml(d.menjyo_hokenryo_ritsu)}</div>
</div>`
: ""
}
</div>
<div class="shoyo-table-wrap">
<table class="shoyo-table">
<thead>
<tr>
<th rowspan="2">#</th>
<th rowspan="2">氏名</th>
<th rowspan="2">賞与支払日</th>
<th colspan="2" style="background:#fff8e1">標準賞与額</th>
<th colspan="2" style="background:#e3f2fd">健康保険料</th>
<th colspan="2" style="background:#e8f5e9">厚生年金保険料</th>
</tr>
<tr>
<th style="background:#fff8e1">健保</th>
<th style="background:#fff8e1">厚年</th>
<th style="background:#e3f2fd">本月額</th>
<th style="background:#e3f2fd">前月以前</th>
<th style="background:#e8f5e9">本月額</th>
<th style="background:#e8f5e9">前月以前</th>
</tr>
</thead>
<tbody>${rows}</tbody>
<tfoot>
<tr>
<td colspan="3" style="text-align:center">合 計</td>
<td>${fmtAmt(totHKenpo)}</td>
<td>${fmtAmt(totHKounen)}</td>
<td>${fmtAmt(totKenpo)}</td>
<td></td>
<td>${fmtAmt(totKounen)}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>`;
}
// ──────────────────────────────────
// アップロードゾーン
// ──────────────────────────────────
function initUploadZone() {
const zone = document.getElementById("upload-zone");
const input = document.getElementById("zip-input");
zone.addEventListener("dragover", (e) => {
e.preventDefault();
zone.classList.add("dragover");
});
zone.addEventListener("dragleave", () =>
zone.classList.remove("dragover"),
);
zone.addEventListener("drop", (e) => {
e.preventDefault();
zone.classList.remove("dragover");
const files = [...e.dataTransfer.files].filter((f) =>
f.name.endsWith(".zip"),
);
if (files.length) uploadFiles(files);
});
input.addEventListener("change", () => {
const files = [...input.files];
if (files.length) uploadFiles(files);
input.value = "";
});
}
async function uploadFiles(files) {
const status = document.getElementById("upload-status");
status.innerHTML = `<span style="color:#6c757d">アップロード中... (${files.length}件)</span>`;
let ok = 0,
skipped = 0,
errors = [];
for (const file of files) {
const fd = new FormData();
fd.append("file", file);
try {
const res = await fetch(`${API}/nenkin/upload`, {
method: "POST",
body: fd,
});
const json = await res.json();
if (res.status === 409) {
skipped++;
} else if (!res.ok) {
errors.push(`${file.name}: ${json.detail || res.statusText}`);
} else {
ok++;
}
} catch (e) {
errors.push(`${file.name}: ${e.message}`);
}
}
let msg = "";
if (ok > 0) msg += `<span class="status-ok">✓ ${ok}件登録完了</span> `;
if (skipped > 0)
msg += `<span style="color:#856404">⚠ ${skipped}件は既登録のためスキップ</span> `;
if (errors.length)
msg += `<span class="status-err">✗ エラー: ${errors.join(" / ")}</span>`;
status.innerHTML = msg;
if (ok > 0) {
loadDocuments();
loadFilterOptions();
}
}
// ──────────────────────────────────
// ユーティリティ
// ──────────────────────────────────
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmtAmt(v) {
if (v == null || v === "") return '<span class="empty-cell"></span>';
return (
"¥" +
Number(v).toLocaleString("ja-JP", {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
})
);
}
function amtRow(label, value, isTotal = false) {
return `<div class="amount-row${isTotal ? " total" : ""}">
<span>${label}</span>
<span class="amt">${fmtAmt(value)}</span>
</div>`;
}
function sum(items, key) {
return items.reduce((acc, it) => acc + (Number(it[key]) || 0), 0);
}
function showDetailError(msg) {
const panel = document.getElementById("detail-panel");
document.getElementById("detail-body").innerHTML =
`<p style="color:#dc3545">エラー: ${escHtml(msg)}</p>`;
panel.classList.add("visible");
}
</script>
</body>
</html>