This commit is contained in:
admin
2026-05-21 00:35:55 +09:00
parent da3aef25ce
commit 4ea283941c
18 changed files with 2530 additions and 1 deletions

735
frontend/nenkin-portal.html Normal file
View File

@@ -0,0 +1,735 @@
<!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" />
<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>