Files
njts-accounting-core/frontend/bank-statement.html
2026-06-01 00:35:53 +09:00

993 lines
30 KiB
HTML
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.
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="js/auth.js"></script>
<title>銀行明細照会</title>
<link rel="stylesheet" href="css/style.css" />
<link rel="stylesheet" href="/css/mobile.css" />
<style>
body {
font-family: "MS Gothic", "Meiryo", sans-serif;
margin: 24px auto;
font-size: 14px;
max-width: 1200px;
padding: 0 16px;
}
h2 {
margin: 0 0 16px;
font-size: 20px;
}
/* ── ヘッダー ── */
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
}
.page-header h2 {
margin: 0;
}
.header-btns {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* ── 検索フォーム ── */
.search-form {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 14px 18px;
margin-bottom: 16px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.search-form label {
font-weight: bold;
white-space: nowrap;
}
.search-form select,
.search-form input[type="number"] {
padding: 5px 10px;
border: 1px solid #999;
border-radius: 3px;
font-size: 14px;
font-family: inherit;
}
/* ── ボタン共通 ── */
.btn {
padding: 6px 16px;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
font-family: inherit;
white-space: nowrap;
text-decoration: none;
display: inline-block;
}
.btn-primary {
background: #4caf50;
color: #fff;
}
.btn-primary:hover {
background: #43a047;
}
.btn-blue {
background: #2196f3;
color: #fff;
}
.btn-blue:hover {
background: #1976d2;
}
.btn-orange {
background: #ff9800;
color: #fff;
}
.btn-orange:hover {
background: #f57c00;
}
.btn-danger {
background: #e53935;
color: #fff;
}
.btn-danger:hover {
background: #c62828;
}
.btn-gray {
background: #9e9e9e;
color: #fff;
}
.btn-gray:hover {
background: #757575;
}
/* ── 月別サマリー ── */
.monthly-summary {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.month-chip {
padding: 5px 12px;
border: 1px solid #999;
border-radius: 16px;
background: #fff;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
}
.month-chip:hover {
background: #e3f2fd;
border-color: #2196f3;
}
.month-chip.active {
background: #2196f3;
color: #fff;
border-color: #1976d2;
}
/* ── 統計バー ── */
.stats-bar {
display: flex;
gap: 20px;
margin-bottom: 12px;
font-size: 13px;
flex-wrap: wrap;
}
.stat-item {
display: flex;
align-items: center;
gap: 6px;
}
.stat-label {
color: #666;
}
.stat-value {
font-weight: bold;
}
.stat-value.debit {
color: #e53935;
}
.stat-value.credit {
color: #1565c0;
}
/* ── テーブル ── */
table {
border-collapse: collapse;
width: 100%;
font-size: 13px;
}
th,
td {
border: 1px solid #bbb;
padding: 6px 10px;
}
th {
background: #e8e8e8;
font-weight: bold;
text-align: center;
white-space: nowrap;
}
td {
background: #fff;
}
tr:nth-child(even) td {
background: #fafafa;
}
tr:hover td {
background: #e3f2fd;
}
.text-right {
text-align: right;
}
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
td.debit-cell {
color: #c62828;
}
td.credit-cell {
color: #1565c0;
}
.no-data {
text-align: center;
color: #999;
padding: 30px;
font-size: 15px;
}
/* ── モーダル ── */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
justify-content: center;
align-items: flex-start;
padding: 30px 16px;
overflow-y: auto;
}
.modal-overlay.active {
display: flex;
}
.modal-box {
background: #fff;
border-radius: 8px;
padding: 28px;
width: 100%;
max-width: 920px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
}
.modal-box h3 {
margin: 0 0 18px;
font-size: 17px;
}
.modal-form-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.modal-form-row label {
font-weight: bold;
white-space: nowrap;
}
.modal-form-row input[type="text"] {
flex: 1;
min-width: 160px;
padding: 7px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
/* ドラッグ&ドロップエリア */
.drop-zone {
border: 2px dashed #999;
border-radius: 6px;
padding: 30px;
text-align: center;
color: #666;
cursor: pointer;
margin-bottom: 16px;
transition:
border-color 0.2s,
background 0.2s;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: #2196f3;
background: #e3f2fd;
color: #1565c0;
}
.drop-zone input[type="file"] {
display: none;
}
.drop-zone .drop-icon {
font-size: 36px;
display: block;
margin-bottom: 8px;
}
/* プレビュー結果 */
.preview-summary {
display: flex;
gap: 20px;
margin-bottom: 12px;
padding: 10px 16px;
background: #f5f5f5;
border-radius: 4px;
flex-wrap: wrap;
}
.preview-summary .ps-item {
font-size: 14px;
}
.ps-new {
color: #2e7d32;
font-weight: bold;
}
.ps-dup {
color: #e65100;
}
.ps-total {
font-weight: bold;
}
.preview-table-wrap {
max-height: 360px;
overflow-y: auto;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 16px;
}
.preview-table-wrap table {
font-size: 12px;
}
.preview-table-wrap th {
position: sticky;
top: 0;
z-index: 1;
}
tr.row-new td {
background: #e8f5e9 !important;
}
tr.row-dup td {
background: #fff3e0 !important;
color: #999;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: bold;
}
.badge-new {
background: #c8e6c9;
color: #1b5e20;
}
.badge-dup {
background: #ffe0b2;
color: #bf360c;
}
.modal-btn-row {
display: flex;
gap: 10px;
justify-content: flex-end;
flex-wrap: wrap;
}
/* ── メッセージ ── */
.msg-bar {
padding: 10px 16px;
border-radius: 4px;
margin-bottom: 14px;
display: none;
font-size: 14px;
}
.msg-bar.info {
background: #e3f2fd;
color: #0d47a1;
border: 1px solid #90caf9;
}
.msg-bar.success {
background: #e8f5e9;
color: #1b5e20;
border: 1px solid #a5d6a7;
}
.msg-bar.error {
background: #ffebee;
color: #b71c1c;
border: 1px solid #ef9a9a;
}
/* ── ページネーション ── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
padding: 8px 0;
}
.page-info {
font-size: 13px;
color: #333;
}
.btn-page {
background: #f0f0f0;
color: #333;
border: 1px solid #bbb;
}
.btn-page:hover:not(:disabled) {
background: #e0e0e0;
}
.btn-page:disabled {
opacity: 0.4;
cursor: default;
}
/* ── 印刷スタイル ── */
@media print {
.no-print {
display: none !important;
}
body {
margin: 0;
font-size: 12px;
}
table {
font-size: 11px;
}
th,
td {
padding: 4px 6px;
}
.monthly-summary,
.stats-bar {
margin-bottom: 6px;
}
.month-chip {
border: 1px solid #999 !important;
color: #000 !important;
background: #fff !important;
}
}
</style>
</head>
<body>
<div class="page-header no-print">
<h2>🏦 銀行明細照会</h2>
<div class="header-btns">
<button class="btn btn-orange" onclick="openImportModal()">
CSV取込
</button>
<button class="btn btn-blue" onclick="window.print()">印刷</button>
<a class="btn btn-gray" href="index.html">← メニューへ戻る</a>
</div>
</div>
<div id="msgBar" class="msg-bar no-print"></div>
<!-- 検索フォーム -->
<div class="search-form no-print">
<label>年:</label>
<input
type="number"
id="filterYear"
min="2000"
max="2099"
style="width: 80px"
/>
<label>月:</label>
<select id="filterMonth">
<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>
<label>銀行:</label>
<select id="filterBank">
<option value="">全銀行</option>
</select>
<button class="btn btn-primary" onclick="loadData()">検索</button>
</div>
<!-- 月別チップ -->
<div class="monthly-summary" id="monthlyChips"></div>
<!-- 統計バー -->
<div class="stats-bar" id="statsBar"></div>
<!-- 明細テーブル -->
<div id="tableWrap">
<div class="no-data">データを読み込み中…</div>
</div>
<div id="paginationBar" class="pagination no-print"></div>
<!-- ========== CSV取込モーダル ========== -->
<div class="modal-overlay no-print" id="importModal">
<div class="modal-box">
<h3>📂 銀行明細 CSV 取込</h3>
<!-- ステップ1: ファイル選択 -->
<div id="step1">
<div class="modal-form-row">
<label>銀行名:</label>
<input
type="text"
id="bankNameInput"
placeholder="例三菱UFJ銀行 本店普通口座"
/>
</div>
<div
class="drop-zone"
id="dropZone"
onclick="document.getElementById('csvFile').click()"
>
<span class="drop-icon">📄</span>
<div>CSVファイルをドラッグドロップ、またはクリックして選択</div>
<div style="font-size: 12px; color: #999; margin-top: 6px">
対応形式: CSVUTF-8 / Shift-JIS
</div>
<input
type="file"
id="csvFile"
accept=".csv,.txt"
onchange="onFileSelected(this)"
/>
</div>
<div id="step1msg" class="msg-bar"></div>
<div class="modal-btn-row">
<button class="btn btn-gray" onclick="closeImportModal()">
キャンセル
</button>
</div>
</div>
<!-- ステップ2: プレビュー -->
<div id="step2" style="display: none">
<div class="preview-summary" id="previewSummary"></div>
<div class="preview-table-wrap">
<table id="previewTable">
<thead>
<tr>
<th style="width: 36px">状態</th>
<th>日付</th>
<th>摘要</th>
<th>出金</th>
<th>入金</th>
<th>残高</th>
<th>メモ</th>
</tr>
</thead>
<tbody id="previewBody"></tbody>
</table>
</div>
<div id="step2msg" class="msg-bar"></div>
<div class="modal-btn-row">
<button class="btn btn-gray" onclick="backToStep1()">← 戻る</button>
<button
class="btn btn-primary"
id="importConfirmBtn"
onclick="confirmImport()"
>
新規データを取込む
</button>
</div>
</div>
<!-- ステップ3: 完了 -->
<div id="step3" style="display: none">
<div
id="step3msg"
class="msg-bar success"
style="display: block"
></div>
<div class="modal-btn-row" style="margin-top: 16px">
<button
class="btn btn-primary"
onclick="
closeImportModal();
loadData();
"
>
明細を表示する
</button>
<button class="btn btn-orange" onclick="backToStep1()">
続けて取込む
</button>
</div>
</div>
</div>
</div>
<script>
// ── 初期化 ──────────────────────────────────────────────
let _previewRows = [];
let _bankName = "";
let _allRows = [];
let _currentPage = 1;
const PAGE_SIZE = 100;
document.addEventListener("DOMContentLoaded", () => {
loadBanks();
loadData();
initDropZone();
});
// ── メッセージ表示 ──────────────────────────────────────
function showMsg(id, text, type) {
const el = document.getElementById(id);
el.textContent = text;
el.className = "msg-bar " + type;
el.style.display = "block";
}
function hideMsg(id) {
const el = document.getElementById(id);
el.style.display = "none";
}
// ── 銀行名リスト読み込み ────────────────────────────────
async function loadBanks() {
try {
const res = await fetch("/bank-statements?limit=1");
if (!res.ok) return;
const data = await res.json();
const sel = document.getElementById("filterBank");
const cur = sel.value;
// 既存オプションを保持しつつ追加
const existing = Array.from(sel.options).map((o) => o.value);
data.banks.forEach((b) => {
if (!existing.includes(b)) {
const opt = document.createElement("option");
opt.value = b;
opt.textContent = b;
sel.appendChild(opt);
}
});
if (cur) sel.value = cur;
} catch (_) {}
}
// ── 明細データ読み込み ──────────────────────────────────
async function loadData() {
const year = document.getElementById("filterYear").value;
const month = document.getElementById("filterMonth").value;
const bank = document.getElementById("filterBank").value;
hideMsg("msgBar");
const qp = [];
if (year) qp.push(`year=${year}`);
if (month) qp.push(`month=${month}`);
if (bank) qp.push(`bank_name=${encodeURIComponent(bank)}`);
const url = "/bank-statements" + (qp.length ? "?" + qp.join("&") : "");
try {
const res = await fetch(url);
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
_allRows = data.rows;
_currentPage = 1;
renderMonthlyChips(data.monthly, year, month);
renderStats();
renderTable();
updateBankFilter(data.banks);
} catch (e) {
showMsg("msgBar", "データ取得エラー: " + e.message, "error");
}
}
function updateBankFilter(banks) {
const sel = document.getElementById("filterBank");
const cur = sel.value;
// 全銀行以外のオプションをクリアして再構築
while (sel.options.length > 1) sel.remove(1);
banks.forEach((b) => {
const opt = document.createElement("option");
opt.value = b;
opt.textContent = b;
sel.appendChild(opt);
});
if (cur) sel.value = cur;
}
// ── 月別チップ ──────────────────────────────────────────
function renderMonthlyChips(monthly, selYear, selMonth) {
const wrap = document.getElementById("monthlyChips");
wrap.innerHTML = "";
if (!monthly.length) return;
// 「全月」チップ
const allChip = document.createElement("span");
allChip.className =
"month-chip no-print" + (!selMonth ? " active" : "");
allChip.textContent = "全月";
allChip.onclick = () => {
document.getElementById("filterMonth").value = "";
loadData();
};
wrap.appendChild(allChip);
monthly.forEach((m) => {
const [y, mo] = m.ym.split("-");
const chip = document.createElement("span");
const isActive =
String(selYear) === y && String(selMonth) === String(parseInt(mo));
chip.className = "month-chip no-print" + (isActive ? " active" : "");
chip.innerHTML = `${parseInt(mo)}月 <small style="color:inherit;opacity:.8">${
m.count
}件</small>`;
chip.title = `出金 ${fmt(m.total_debit)} / 入金 ${fmt(m.total_credit)}`;
chip.onclick = () => {
document.getElementById("filterYear").value = y;
document.getElementById("filterMonth").value = parseInt(mo);
loadData();
};
wrap.appendChild(chip);
});
}
// ── 統計バー ────────────────────────────────────────────
function renderStats() {
const bar = document.getElementById("statsBar");
if (!_allRows.length) {
bar.innerHTML = "";
return;
}
const totalDebit = _allRows.reduce((s, r) => s + (r.debit || 0), 0);
const totalCredit = _allRows.reduce((s, r) => s + (r.credit || 0), 0);
bar.innerHTML = `
<div class="stat-item">
<span class="stat-label">総件数:</span>
<span class="stat-value">${_allRows.length.toLocaleString()} 件</span>
</div>
<div class="stat-item">
<span class="stat-label">出金合計:</span>
<span class="stat-value debit">${fmt(totalDebit)}</span>
</div>
<div class="stat-item">
<span class="stat-label">入金合計:</span>
<span class="stat-value credit">${fmt(totalCredit)}</span>
</div>
<div class="stat-item">
<span class="stat-label">差引:</span>
<span class="stat-value">${fmt(totalCredit - totalDebit)}</span>
</div>
`;
}
// ── 明細テーブル ─────────────────────────────────────────
function renderTable() {
const wrap = document.getElementById("tableWrap");
if (!_allRows.length) {
wrap.innerHTML =
'<div class="no-data">該当する明細データがありません</div>';
renderPagination(0);
return;
}
const start = (_currentPage - 1) * PAGE_SIZE;
const pageRows = _allRows.slice(start, start + PAGE_SIZE);
let html = `
<table>
<thead>
<tr>
<th class="text-center">日付</th>
<th class="text-left">摘要</th>
<th class="text-right">出金</th>
<th class="text-right">入金</th>
<th class="text-right">残高</th>
<th class="text-left">メモ</th>
<th class="text-center no-print">銀行</th>
</tr>
</thead>
<tbody>
`;
pageRows.forEach((r) => {
html += `<tr>
<td class="text-center">${r.tx_date}</td>
<td class="text-left">${esc(r.description)}</td>
<td class="text-right debit-cell">${r.debit ? fmt(r.debit) : ""}</td>
<td class="text-right credit-cell">${r.credit ? fmt(r.credit) : ""}</td>
<td class="text-right">${r.balance != null ? fmt(r.balance) : ""}</td>
<td class="text-left">${esc(r.memo || "")}</td>
<td class="text-center no-print">${esc(r.bank_name || "")}</td>
</tr>`;
});
html += "</tbody></table>";
wrap.innerHTML = html;
renderPagination(_allRows.length);
}
function renderPagination(total) {
const bar = document.getElementById("paginationBar");
if (!bar) return;
const totalPages = Math.ceil(total / PAGE_SIZE);
if (totalPages <= 1) {
bar.innerHTML = "";
return;
}
const start = (_currentPage - 1) * PAGE_SIZE + 1;
const end = Math.min(_currentPage * PAGE_SIZE, total);
bar.innerHTML = `
<button class="btn btn-page" onclick="changePage(${_currentPage - 1})" ${_currentPage <= 1 ? "disabled" : ""}>◀ 前へ</button>
<span class="page-info">${_currentPage} / ${totalPages} ページ(${start}${end}件 / 全${total}件)</span>
<button class="btn btn-page" onclick="changePage(${_currentPage + 1})" ${_currentPage >= totalPages ? "disabled" : ""}>次へ ▶</button>
`;
}
function changePage(page) {
const totalPages = Math.ceil(_allRows.length / PAGE_SIZE);
if (page < 1 || page > totalPages) return;
_currentPage = page;
renderTable();
window.scrollTo(0, 0);
}
// ── CSV取込モーダル ──────────────────────────────────────
function openImportModal() {
backToStep1();
document.getElementById("importModal").classList.add("active");
}
function closeImportModal() {
document.getElementById("importModal").classList.remove("active");
}
function backToStep1() {
document.getElementById("step1").style.display = "";
document.getElementById("step2").style.display = "none";
document.getElementById("step3").style.display = "none";
document.getElementById("csvFile").value = "";
hideMsg("step1msg");
hideMsg("step2msg");
_previewRows = [];
}
// ドラッグ&ドロップ初期化
function initDropZone() {
const zone = document.getElementById("dropZone");
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 f = e.dataTransfer.files[0];
if (f) uploadAndPreview(f);
});
}
function onFileSelected(input) {
if (input.files[0]) uploadAndPreview(input.files[0]);
}
async function uploadAndPreview(file) {
if (!file.name.match(/\.(csv|txt)$/i)) {
showMsg(
"step1msg",
"CSVファイルを選択してください.csv または .txt",
"error",
);
return;
}
showMsg("step1msg", "解析中...", "info");
const fd = new FormData();
fd.append("file", file);
fd.append("bank_name", document.getElementById("bankNameInput").value);
try {
const res = await fetch("/bank-statements/preview", {
method: "POST",
body: fd,
});
const data = await res.json();
if (!res.ok) {
showMsg("step1msg", data.detail || "エラーが発生しました", "error");
return;
}
hideMsg("step1msg");
_previewRows = data.rows;
// 銀行名: 入力欄が空でAPIがヒントを返した場合は自動セット
if (
data.bank_name &&
!document.getElementById("bankNameInput").value.trim()
) {
document.getElementById("bankNameInput").value = data.bank_name;
}
_bankName = document.getElementById("bankNameInput").value;
renderPreview(data);
document.getElementById("step1").style.display = "none";
document.getElementById("step2").style.display = "";
} catch (e) {
showMsg("step1msg", "アップロードエラー: " + e.message, "error");
}
}
function renderPreview(data) {
// サマリー
document.getElementById("previewSummary").innerHTML = `
<div class="ps-item ps-total">合計: ${data.total} 件</div>
<div class="ps-item ps-new">🆕 新規: ${data.new} 件(取込対象)</div>
<div class="ps-item ps-dup">⚠️ 重複: ${data.duplicates} 件(スキップ)</div>
`;
// 取込ボタンの状態
const btn = document.getElementById("importConfirmBtn");
if (data.new === 0) {
btn.disabled = true;
btn.textContent = "新規データなし(取込不要)";
btn.style.opacity = "0.5";
} else {
btn.disabled = false;
btn.textContent = `新規 ${data.new} 件を取込む`;
btn.style.opacity = "1";
}
// プレビューテーブル
const tbody = document.getElementById("previewBody");
tbody.innerHTML = "";
data.rows.forEach((r) => {
const tr = document.createElement("tr");
tr.className = r.status === "new" ? "row-new" : "row-dup";
tr.innerHTML = `
<td class="text-center">
<span class="badge badge-${r.status === "new" ? "new" : "dup"}">
${r.status === "new" ? "新規" : "重複"}
</span>
</td>
<td>${r.tx_date}</td>
<td>${esc(r.description)}</td>
<td class="text-right">${r.debit ? fmt(r.debit) : ""}</td>
<td class="text-right">${r.credit ? fmt(r.credit) : ""}</td>
<td class="text-right">${r.balance != null ? fmt(r.balance) : ""}</td>
<td>${esc(r.memo || "")}</td>
`;
tbody.appendChild(tr);
});
}
async function confirmImport() {
const newRows = _previewRows.filter((r) => r.status === "new");
if (!newRows.length) return;
const btn = document.getElementById("importConfirmBtn");
btn.disabled = true;
btn.textContent = "取込中...";
hideMsg("step2msg");
try {
const res = await fetch("/bank-statements/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bank_name: _bankName, rows: newRows }),
});
const data = await res.json();
if (!res.ok) {
showMsg("step2msg", data.detail || "取込エラー", "error");
btn.disabled = false;
btn.textContent = `新規 ${newRows.length} 件を取込む`;
return;
}
// 完了画面
document.getElementById("step2").style.display = "none";
document.getElementById("step3").style.display = "";
document.getElementById("step3msg").textContent =
`✅ 取込完了:${data.inserted} 件を登録しました` +
(data.skipped ? `${data.skipped} 件スキップ)` : "");
loadBanks();
} catch (e) {
showMsg("step2msg", "通信エラー: " + e.message, "error");
btn.disabled = false;
btn.textContent = `新規 ${newRows.length} 件を取込む`;
}
}
// ── ユーティリティ ──────────────────────────────────────
function fmt(v) {
if (v == null || v === "") return "";
return Number(v).toLocaleString("ja-JP");
}
function esc(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
</script>
</body>
</html>